ObservationのwithObservationTrackingの仕組みとSwiftUI以外の用途の可能性を探る

iOS17から導入された [Observation](https://developer.apple.com/documentation/Observation) フレームワークですが、従来の `@Published` を用いた変更監視の仕組みと比較してよりシンプルにビューモデルを記述する、より優れた手法だと思いました。

ただ従来の `@Published` は変数名の頭に `$` をつけることで、イベントストリームの `Publisher` を得ることができたのですが、Observationにはそのような仕組みが備わっていません。

これはSwiftUIとObsevationの組み合わせではほとんど必要性はないものの、UIKitや他のデータモデルとの組み合わせでは、イベントストリームのような仕組みで変更監視ができたほうが便利ではないでしょうか？

この可能性を探るため、Observationフレームワークが提供する数少ない関数のひとつである `withObservationTracking` に注目しました。

## withObservationTrackingについて

[withObservationTracking](https://developer.apple.com/documentation/observation/withobservationtracking(_:onchange:))については、LINEのエンジニアの方が深掘りしてくださったブログがありますので、こちらを一読してもらうほうが理解が早いかと思います。

https://techblog.lycorp.co.jp/en/observation-in-depth

ポイントとしては、

1. withObservationTrackingの `apply` クロージャ内に記述されているプロパティが監視対象としてマークされる
2. 監視対象の変更があると、**一度だけ** `onChange` が呼ばれる
3. `onChange` が呼ばれるのは **値の変更直前(willSet)** である

あたりですが、マクロを利用したよく練られた仕組みですね。

## 永続的に監視できるようにする

`withObservationTracking` の監視は上記2のとおり一度きりなので、永続的に監視できる関数を作ってみます。

```swift
func continuousObservationTracking<T>(
    _ apply: @escaping () -> T,
    onChange: @escaping (@Sendable () -> Void)
) {
    _ = withObservationTracking(apply, onChange: {
        onChange()
        continuousObservationTracking(apply, onChange: onChange)
    })
}
```

単純に `onChange` の中で再帰的に監視を追加すれば実現できます。監視の停止については考慮していません。

## プロパティの変更前後の値を得られるようにする

特定のプロパティのみ、値の変更が発生したときにその前後の値が渡されるような監視メソッドを作ってみます。SwiftUIの [onChange()](https://developer.apple.com/documentation/swiftui/view/onchange(of:initial:_:)-4psgg) のようなイメージです。

```swift
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` を取得します。

## 動作確認

単純なカウンターアプリを作ります。

```swift
@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()` に連続でコールバックされ、変更前後の値が来ているのでうまくいってそう！？
ところが、ボタンを押したときにカウントアップしているコードを以下のように変えてみてください。

```swift
            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では公開されていないのですが、これが使えるようになればこの問題は解消できそうです。

https://github.com/apple/swift/blob/98e65d015979c7b5a58a6ecf2d8598a6f7c85794/stdlib/public/Observation/Sources/Observation/ObservationTracking.swift#L214-L220

早く使えるようになるよう、Appleにリクエスト🙇‍♂️送っておきます。