🅾️

Observation frameworkについて

2024/01/30に公開

基本

Observation frameworkを利用する基本的な流れ

@Observable // ①
class Counter {
    var count = 0
}

func run() {
    let counter = Counter()
    
    withObservationTracking {
        print("count :", counter.count) // ②
    } onChange: {
        print("count changed") // ④
    }
    
    counter.count += 1 // ③
}

結果
count : 0
count changed

①プロパティを監視したいクラスに@Observableマクロを付ける
②withObservationTrackingのブロックでプロパティを参照する
③②で参照されたプロパティを更新する
④withObservationTrackingのonChangeブロックが実行される

既存のObservableObjectと違って個別のプロパティに@Publishedを付けなくても監視対象になる

注意点

①withObservationTrackingのブロックで参照されてないプロパティは更新されてもonChangeブロックは実行されない

@Observable
class Counter {
    var count = 0
    var count2 = 0
}

func run() {
    let counter = Counter()
    
    withObservationTracking {
        print("count :", counter.count)
    } onChange: {
        print("count changed")
    }
    
    counter.count2 += 1
}

count : 0

これが既存のObservableObjectとの違いで参照されたプロパティの更新時にのみonChangeブロックが実行されるので無駄な実行がない

②onChangeブロックは一回のみ実行される

@Observable
class Counter {
    var count = 0
}

func run() {
    let counter = Counter()
    
    withObservationTracking {
        print("count :", counter.count)
    } onChange: {
        print("count changed")
    }
    
    counter.count += 1
    counter.count += 1
}

count : 0
count changed

SE-0395-Observation - withObservationTracking(_:onChange:)に書いてあるように継続的にonChangeが実行されるためにはonChange内でもう一度登録を行う必要がある

@MainActor
func renderPerson(_ person: Person) {
    withObservationTracking {
        print("\(person.fullName) has \(person.friends.count) friends.")
    } onChange: {
        Task { @MainActor in
            renderPerson(person)
        }
    }
}

③プロパティは直接参照ではなくcomputedプロパティやメソッド内の参照でもOK

@Observable
class Counter {
    var count = 0
    
    var message: String {
        "count : \(count)"
    }
}

func run() {
    let counter = Counter()
    
    withObservationTracking {
        print(counter.message)
    } onChange: {
        print("count changed")
    }
    
    counter.count += 1
}

count : 0
count changed

④privateプロパティでもOK

@Observable
class Counter {
    private var count = 0
    
    func printCount() {
        print("count :", count)
    }
    func increaseCount() {
        count += 1
    }
}

func run() {
    let counter = Counter()
    
    withObservationTracking {
        counter.printCount()
    } onChange: {
        print("count changed")
    }
    
    counter.increaseCount()
}

count : 0
count changed

これは意図しない動作になる可能性があるのでちょっと注意が必要

@Observableマクロを展開してみる

@Observable
class Counter {
    var count = 0
}

↑コードで@Observableマクロを展開してみると以下のようになる

class Counter {
    @ObservationTracked
    var count = 0
    
    @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

    internal nonisolated func access<Member>(
        keyPath: KeyPath<Counter , Member>
    ) {
      _$observationRegistrar.access(self, keyPath: keyPath)
    }

    internal nonisolated func withMutation<Member, MutationResult>(
      keyPath: KeyPath<Counter , Member>,
      _ mutation: () throws -> MutationResult
    ) rethrows -> MutationResult {
      try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
}

extension Counter: Observation.Observable {
}

countプロパティに@ObservationTrackedマクロが付いて、
_$observationRegistrarというプロパティが追加されて、
access、withMutationの二つのメソッドも追加されている。

続けて@ObservationTrackedマクロも展開してみると以下のようになる

@ObservationIgnored private var _count  = 0

var count = 0
{
    @storageRestrictions(initializes: _count )
    init(initialValue) {
      _count  = initialValue
    }
    get {
      access(keyPath: \.count )
      return _count
    }
    set {
      withMutation(keyPath: \.count ) {
        _count  = newValue
      }
    }
}

値を保持する_countが追加されて、countプロパティはcomputedプロパティになった。

コードを眺めてみると結局getでaccessメソッドをsetでwithMutationメソッドを追加で呼び出していることがわかる。

それではプロパティを参照する代わりにaccessとwithMutationメソッドをkeyPathで直接呼び出してみる

func run() {
    let counter = Counter()
    
    withObservationTracking {
        counter.access(keyPath: \.count)
    } onChange: {
        print("count changed")
    }
    
    counter.withMutation(keyPath: \.count) { }
}

count changed

onChangeが実行された。

これで@Observableマクロは各プロパティのgetとsetで_$observationRegistrarのaccessとwithMutationを呼び出すことで動作することがわかった。

気になる点

- その1

プロパティが構造体の場合、構造体の参照されるプロパティの変更ではなく構造体の変更でonChangeブロックが実行される

struct LargeStruct {
    var count = 0
    var count2 = 0
}

@Observable
class Counter {
    var largeStruct = LargeStruct()
}

func run() {
    let counter = Counter()
    
    withObservationTracking {
        print("count :", counter.largeStruct.count)
    } onChange: {
        print("count changed")
    }
    counter.largeStruct.count2 += 1
}

count : 0
count changed

withObservationTrackingのブロックではcounter.largeStruct.countのみ参照されているがcounter.largeStruct.count2の更新でonChangeブロックが実行される。

上で見たように@Observableマクロは内部ではlargeStructのkeyPathで処理されるので当たり前だがこの動作は気になる。

- その2

accessとwithMutationメソッドがnonisolatedなので@Observableクラスが@MainActorでもonChangeブロックがバッググラウンドスレッドで実行される可能性がある

@MainActor
@Observable
class Counter {
    var count = 0
}

func run() async {
    let counter = await Counter()
    
    withObservationTracking {
        counter.access(keyPath: \.count)
    } onChange: {
        print("count changed", Thread.current)
    }
    
    Task {
        counter.withMutation(keyPath: \.count) { }
    }
}

count changed <NSThread: 0x60000170c940>{number = 6, name = (null)}

こんなことする人はいないはずだが気になる。

参照

Discussion