3
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

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

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

withObservationTrackingについて

withObservationTrackingについては、LINEのエンジニアの方が深掘りしてくださったブログがありますので、こちらを一読してもらうほうが理解が早いかと思います。

ポイントとしては、

  1. withObservationTrackingの apply クロージャ内に記述されているプロパティが監視対象としてマークされる
  2. 監視対象の変更があると、一度だけ onChange が呼ばれる
  3. 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にリクエスト🙇‍♂️送っておきます。

3
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?