はじめに
Swift 5.9からのObservationがどうやってプロパティを監視しているのか、Swiftのコードから仕組みについて書いておきます。Swiftのコードを読み間違えて解釈していることもあるかもしれませんが、誤りなど見つけたらコメントをください。
本題
withObservationTrackingについて
Observationのプロポーザルから、次のようなコードを見たことがあると思います。これはクラスCarのnameとnumberの変更を監視し、.onChangeが動作するというものです。
import Observation
@Observable
class Car {
let name: String
let number: String
}
let cars: [Car] = ...
@MainActor
func renderCars() {
withObservationTracking {
for car in cars {
print(car.name)
}
} onChange: {
Task { @MainActor in
renderCars()
}
}
}
最初見た時不思議さを感じるがSwiftUI.Viewのbodyに使うことを考慮した自由さだろう
最初これ見た時に私が不思議だなと感じたのは、このwithObservationTracking
の1つ目のクロージャがprintをしている意味のわからなさです。これprintじゃなくてもいいんですが、監視されるのはprintされているname
だけではなくnumber
も監視しますので、実質ここではcar
が呼び出されていればいいだけなのです。
しかしそれであれば、クロージャでオブジェクトの配列を渡せばいいだけなのではと思いませんか?withObservationTracking(cars).onChange{}
でいいじゃんって気がします。
まあ多分それもやろうと思えばできるんです。ただ、このクロージャだからこそある程度の自由さがあるのでしょう。これは主にSwiftUI.bodyのような処理の場合に便利だからまあそうなってると納得できなくもないです。
最初に結論、どうなってるのwithObservationTracking
withObservationTracking
の内部の実装では、1つ目のクロージャに記載されたオブジェクトからAccessListを取り出し一つにマージしてそのAccessListに変化が通知されればonChangeを実行するようになっています。
例えばcar.name
と書かれたらcar
のAccessListを取得しているわけです。だからcar.number
の変化も取得できる、くらいの理解でいいはずです。
間にObservationRegisterなんかもありますが、これはSwift Macroによって生成されるものです。だいたい図にすると次のようになります。
以下はこれをちょっとだけ詳しく書いておきます。
@Observable
のSwift Macro
変化を監視される側がどのように変化を通知するのかをまず掘り下げましょう。
さきほどのクラスCar
につけた@Observable
についてSwift MacroをXcode上で展開するとname
とnumber
について@ObservationTracked
が付加されます。name
の@ObservationTracked
も展開してみると次のようなコードが生成されているのがわかるはずです。
@Observable
class Car {
+ @ObservationTracked
var name: String
+ {
+ @storageRestrictions(initializes: _name)
+ init(initialValue) {
+ _name = initialValue
+ }
+
+ get {
+ access(keyPath: \.name)
+ return _name
+ }
+ set {
+ withMutation(keyPath: \.name) {
+ _name = newValue
+ }
+ }
+ @ObservationIgnored private var _name: String
+ @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
+
+ internal nonisolated func access<Member>(
+ keyPath: KeyPath<Car , Member>
+ ) {
+ _$observationRegistrar.access(self, keyPath: keyPath)
+ }
+
+ internal nonisolated func withMutation<Member, MutationResult>(
+ keyPath: KeyPath<Car , Member>,
+ _ mutation: () throws -> MutationResult
+ ) rethrows -> MutationResult {
+ try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
+ }
@ObservationTracked
var number: String
}
生成されたコードについては下記の通りです。
-
@ObservationIgnored private var _name: String
- 値の実体が生成された
-
@ObservationTracked var name: String { ... }
- ストアドプロパティがコンピューティッドプロパティにされた
- get
- 下記の
func access
を呼び出す -
_name
の値を返す
- 下記の
- set
- 下記の
func withMutation
を呼び出す -
_name
の値を変更
- 下記の
- get
- ストアドプロパティがコンピューティッドプロパティにされた
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
-
func access
- observationRegistrarのaccessを呼び出す
-
func withMutation
- observationRegistrarのwithMutationを呼び出す
observationRegistrarのaccess/withMutationはそれぞれがAccessListに対しての操作となり、accessはAccessListがなければ作成し登録。withMutationはwillSet/didSet時にAccessListに対して変更を通知する機能を持ちます。
つまり、Swift MacroによってそのオブジェクトのAccessListが作られ、get時に登録され、set時に変更をAccessListにその変更を通知するようになったというわけです。
SwiftUIの場合
SwiftUIでObservationを使う場合、withObservationTracking
を書きません。
予想ですが、これは我々が見えないところでこのvar body
に対して繰り替しwithObservationTracking
のような処理を呼び出すような仕組みが働いているんでしょう。
struct SmoothieList: View {
var car: Car
var body: some View {
// ここでwithObservationTrackingの繰り返しを使っていない。
// 上位のレベルでシステムがAccessLisetから繰り返し変更通知を受け取れるようにしているんでしょう。
Text(car.name)
}
}
Observationの仕組みをそのまま利用したバックポートが作れるのでは?
Observationの仕組みが理解できたところで、そのままそのコードを利用してiOS17未満のためのバックポートが作れるのではないかと考える人もいるでしょう。
これはswift-perceptionとしてPointFreeの2人がOSSとして公開しています。
まとめ
もう一度最初の図から振り返ってみます
- 参照型のオブジェクトはSwift MacroによってOvservationRegisterを持ちます
- Swift Macroによって対象のプロパティはget/setが用意されます
- get/setによってAccessListの登録/AccessListへの変更通知がされるようになります
-
withObservationTracking(:)
は複数のAccessListを取り出し、それをマージして変更を検知します - 変更を検知するとonChangeが動作します
参考
実際のapple/swiftの実装
withObservationTracking(:)
AccessListのマージ
発表資料