🍁
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") ?? ""
}
}
ポイント
-
Observationsのスコープはselfを強参照するので[weak self]でキャプチャする - 1で
[weak self]したせいでストリームに流れる値がOptionalになってしまうのでcompactMapで型を戻す -
Taskの中のguard let self else { return }はfor awaitの中にする
そうしないとfor awaitの直前で確保したselfを永久に手放さなくなってしまう
このようにすることで、正しくdeinitが呼ばれるようになります。
参考
Discussion