iOS17から導入された Observation フレームワークですが、従来の @Published
を用いた変更監視の仕組みと比較してよりシンプルにビューモデルを記述する、より優れた手法だと思いました。
ただ従来の @Published
は変数名の頭に $
をつけることで、イベントストリームの Publisher
を得ることができたのですが、Observationにはそのような仕組みが備わっていません。
これはSwiftUIとObsevationの組み合わせではほとんど必要性はないものの、UIKitや他のデータモデルとの組み合わせでは、イベントストリームのような仕組みで変更監視ができたほうが便利ではないでしょうか?
この可能性を探るため、Observationフレームワークが提供する数少ない関数のひとつである withObservationTracking
に注目しました。
withObservationTrackingについて
withObservationTrackingについては、LINEのエンジニアの方が深掘りしてくださったブログがありますので、こちらを一読してもらうほうが理解が早いかと思います。
ポイントとしては、
- withObservationTrackingの
apply
クロージャ内に記述されているプロパティが監視対象としてマークされる - 監視対象の変更があると、一度だけ
onChange
が呼ばれる -
onChange
が呼ばれるのは 値の変更直前(willSet) である
あたりですが、マクロを利用したよく練られた仕組みですね。
永続的に監視できるようにする
withObservationTracking
の監視は上記2のとおり一度きりなので、永続的に監視できる関数を作ってみます。
func continuousObservationTracking<T>(
_ apply: @escaping () -> T,
onChange: @escaping (@Sendable () -> Void)
) {
_ = withObservationTracking(apply, onChange: {
onChange()
continuousObservationTracking(apply, onChange: onChange)
})
}
単純に onChange
の中で再帰的に監視を追加すれば実現できます。監視の停止については考慮していません。
プロパティの変更前後の値を得られるようにする
特定のプロパティのみ、値の変更が発生したときにその前後の値が渡されるような監視メソッドを作ってみます。SwiftUIの onChange() のようなイメージです。
extension Observable {
func observe<Member>(
_ keyPath: KeyPath<Self, Member>,
onChange: @escaping @Sendable (Member, Member) -> Void
) {
continuousObservationTracking {
self[keyPath: keyPath]
} onChange: {
let oldValue = self[keyPath: keyPath]
// ⚠️このやり方には問題がある!!後で解説
DispatchQueue.main.async {
let newValue = self[keyPath: keyPath]
onChange(oldValue, newValue)
}
}
}
}
上記3にて、変更前(willSet)のタイミングで onChange
がコールバックされるということだったので、この時点の値を oldValue
として取得しておき、さらに DispatchQueue.main.async
することで1サイクル遅らせた後、変更後の値として newValue
を取得します。
動作確認
単純なカウンターアプリを作ります。
@Observable
final class Counter {
var count = 0
}
struct ContentView: View {
let counter = Counter()
var body: some View {
VStack {
Text(counter.count, format: .number)
Button("count++") {
counter.count += 1
}
}
.onAppear { test() }
}
private func test() {
counter.observe(\.count) {
print("count changed: \($0) -> \($1)")
assert($0 != $1, "New value is same as old value!")
}
}
}
動かしてみると、count
プロパティの observe()
に連続でコールバックされ、変更前後の値が来ているのでうまくいってそう!?
ところが、ボタンを押したときにカウントアップしているコードを以下のように変えてみてください。
Button("count++") {
DispatchQueue.global().async {
counter.count += 1
}
}
ボタンを押し続けるとどこかで New value is same as old value!
で停止してしまうはずです。ログウィンドウにも
count changed: 0 -> 1
count changed: 1 -> 2
count changed: 2 -> 2
のような感じで、確かに変更後の値が変更前の値として来ていました。
お察しのとおり、非メインスレッドで値が変更されてしまうと、DispatchQueue.main.async
では変更後の値をうまく得られない場合があります。
まとめ
withObservationTracking
を少しアレンジすれば、永続的な値の監視はできますが、その性質から変更後の値を得られない場合があります。
実は Observation のソースコードがSwiftのリポジトリにあり、そこをみると didSet
時にコールバックされる withObservationTracking
の実装があるようなんです!残念ながらiOS17 SDKでは公開されていないのですが、これが使えるようになればこの問題は解消できそうです。
早く使えるようになるよう、Appleにリクエスト🙇♂️送っておきます。