🍁

Swift Observationsの強参照に気をつけよ

に公開

iOS 26やmacOS 26からObservationsによる値の監視が可能になりました。
(Xcode 26 Beta 4から使えます。)
よくあるサンプルコードだと、

let store = Store() // Observable

let itemStream = Observations { store.item }

for await item in itemStream {
    print(item)
}

みたいな簡略化されたものしかまだ世に出回っていませんが、実践的にはこんな簡単に扱えるものではありません。もうちょっと参照やActorについて意識しないといけません。

例えば簡単に、nameというプロパティがあり、TextFieldで編集可能、編集する都度保存し、次回に保存内容を繰り越す場合を考えてみます。

onChangeの場合
@MainActor @Observable
final class Content {
    var name: String

    init(name: String = "") {
        self.name = name
    }

    func save(name: String) {
        UserDefaults.standard.set(name, forKey: "name")
    }

    func onAppear() {
        name = UserDefaults.standard.string(forKey: "name") ?? ""
    }
}

struct ContentView: View {
    @State var model = Content()

    var body: some View {
        VStack {
            TextField("Name", text: $model.name)
        }
        .onAppear {
            model.onAppear()
        }
        .onChange(of: model.name) { _, newValue in
            model.save(name: newValue)
        }
    }
}
withObservationTrackingの場合
@MainActor @Observable
final class Content {
    var name: String

    init(name: String = "") {
        self.name = name

        bind()
    }

    private func bind() {
        withObservationTracking {
            _ = name
        } onChange: {
            Task { @MainActor [weak self] in
                guard let self else { return }
                save(name: name)
                bind()
            }
        }
    }

    private func save(name: String) {
        UserDefaults.standard.set(name, forKey: "name")
    }

    func onAppear() {
        name = UserDefaults.standard.string(forKey: "name") ?? ""
    }
}

struct ContentView: View {
    @State var model = Content()

    var body: some View {
        VStack {
            TextField("Name", text: $model.name)
        }
        .onAppear {
            model.onAppear()
        }
    }
}

深く考えずに実装するとこんな感じです。

@MainActor @Observable
final class Content {
    var name: String

    @ObservationIgnored private var bindTask: Task<Void, Never>?

    init(name: String = "") {
        self.name = name

        bind()
    }

    deinit {
        bindTask?.cancel()
    }

    private func bind() {
        bindTask = Task { [weak self] in
            guard let self else { return }
            for await value in Observations({ self.name }).dropFirst() {
                save(name: value)
            }
        }
    }

    private func save(name: String) {
        UserDefaults.standard.set(name, forKey: "name")
    }

    func onAppear() {
        name = UserDefaults.standard.string(forKey: "name") ?? ""
    }
}

struct ContentView: View {
    @State var model = Content()

    var body: some View {
        VStack {
            TextField("Name", text: $model.name)
        }
        .onAppear {
            model.onAppear()
        }
    }
}

これだと、selfの強参照が起きてしまっていて、deinitは永久に発火しません。

ではちゃんと強参照を回避して実装すると以下のようになります。

@MainActor @Observable
final class Content {
    var name: String

    @ObservationIgnored private var bindTask: Task<Void, Never>?

    init(name: String = "") {
        self.name = name

        bind()
    }

    deinit {
        bindTask?.cancel()
    }

    private func bind() {
        let nameUpdatedStream = Observations { [weak self] in
            self?.name
        }.compactMap(\.self).dropFirst()

        bindTask = Task { [weak self] in
            for await value in nameUpdatedStream {
                guard let self else { return }
                self.save(name: value)
            }
        }
    }

    private func save(name: String) {
        UserDefaults.standard.set(name, forKey: "name")
    }

    func onAppear() {
        self.name = UserDefaults.standard.string(forKey: "name") ?? ""
    }
}

ポイント

  1. Observationsのスコープはselfを強参照するので[weak self]でキャプチャする
  2. 1で[weak self]したせいでストリームに流れる値がOptionalになってしまうのでcompactMapで型を戻す
  3. Taskの中のguard let self else { return }for awaitの中にする
    そうしないとfor awaitの直前で確保したselfを永久に手放さなくなってしまう

このようにすることで、正しくdeinitが呼ばれるようになります。

参考

Discussion