Swift Digest
SE-0506 | Swift Evolution

Advanced Observation Tracking

Proposal
SE-0506
Authors
Philippe Hausler
Review Manager
Steve Canon
Status
Accepted

01 何が問題だったのか

SE-0395で導入された Observation モジュールは、@Observable マクロと withObservationTracking(_:onChange:) 関数を中心に、@Observable な型のプロパティ変更を同期的に一度だけ観測できる仕組みを提供しました。SE-0475ではさらに Observations 型が追加され、変更をトランザクション単位の整合した値として非同期に流すためのAPIが整理されました。

多くのユースケースは @ObservableObservations、あるいは既存の withObservationTracking で十分にまかなえますが、同期的なシステム、特にミドルウェアや既存のUI基盤・ウィジェットシステムのような高度な用途では、次のような機能が不足していました。

  • 既存の withObservationTracking は「これから起きる変更」を一度だけ通知する形(willSet 相当)で、連続して発生する変更をまとめてしまうことがあります。二つのモデルを即時に同期させたい場面では、まとめられずに willSet / didSet の両方を必要に応じて受け取りたくなります。
  • 観測対象の @Observable インスタンスが解放されたことを知る手段がありません。解放時に合わせて後片付けを行いたい用途に対応できません。
  • Observations のような継続的な通知を、非同期コンテキストを使わずに同期的なコールバックとして受け取る手段がありません。既存のUIシステムと組み合わせるときに、これが制約になります。
  • どのプロパティが変化して通知が来たのかを、型のアクセス制御を壊さずに判別する方法がありません。

これらは「普通の利用者」向けというより、観測の仕組みを土台として別のフレームワークを作る側が必要とする制御であり、withObservationTracking / Observations という既存APIだけでは提供しにくい粒度の制御です。

02 どのように解決されるのか

Observation モジュールに、観測タイミングをより細かく制御できる二つのエントリポイントを追加します。どちらも高度な用途向けで、通常の利用者は引き続き @ObservableObservations を使えば十分です。

ObservationTracking 名前空間と Options

今回追加されるAPIは、名前衝突を避けるため既存の ObservationTracking 型の下にネストされます。この型自体はこれまでSPIとして内部利用されていたものが、型としてAPIに昇格される形です。

受け取りたいイベントの種類は ObservationTracking.Options で指定します。willSet / didSet / deinit の3種類を非排他に組み合わせられます。

extension ObservationTracking {
  public struct Options {
    public init()
    public static var willSet: Options { get }
    public static var didSet: Options { get }
    public static var `deinit`: Options { get }
  }
}

extension ObservationTracking.Options: SetAlgebra { }
extension ObservationTracking.Options: Sendable { }

OptionsOptionSet ではなく SetAlgebra への適合として提供されます。内部のビット構造がABIの一部になってしまわないようにするための選択です。

3種類すべてを指定しておくと、プロパティの代入ごとに willSetdidSet のイベントが一つずつ、さらに観測対象の @Observable インスタンスが解放される際に deinit イベントが一度発火します。

Event 型とキーパスによるマッチ

コールバックに渡される Event は次の形をしています。

extension ObservationTracking {
  public struct Event: ~Copyable {
    public struct Kind: Equatable, Sendable {
      public static var initial: Kind { get }
      public static var willSet: Kind { get }
      public static var didSet: Kind { get }
      public static var `deinit`: Kind { get }
    }

    public var kind: Kind { get }

    public func matches(_ keyPath: PartialKeyPath<some Observable>) -> Bool
    public func cancel()
  }
}

kind には、initial(継続的な観測のセットアップ時)、willSet / didSet(プロパティ変更時)、deinit(観測対象の解放時)のいずれかが入ります。

matches(_:) を使えば、どのプロパティが変化したのかをキーパスで判定できます。型のアクセス制御を壊さずに、観測側からは「自分の知っているキーパスと一致するか」だけを確認できる形です。ただし matches(_:)willSet / didSet に対してのみ意味を持ち、deinit イベントはどのキーパスにもマッチしません。

cancel() を呼ぶと、以降のイベント発火が止まります。たとえば willSet のイベントで cancel() を呼べば、対応する didSet は(Options に含めていても)通知されません。

withObservationTracking(options:_:onChange:)

既存の withObservationTrackingoptions: 付きのオーバーロードが追加されます。

public func withObservationTracking<Result: ~Copyable, Failure: Error>(
  options: ObservationTracking.Options,
  _ apply: () throws(Failure) -> Result,
  onChange: @escaping @Sendable (borrowing ObservationTracking.Event) -> Void
) throws(Failure) -> Result

apply の中で触れた観測対象プロパティが、指定した Options に該当する変化を起こしたときに onChange が呼ばれます。キーパスによって通知元を振り分けるには matches(_:) を使います。

withObservationTracking(options: [.willSet]) {
  print(myObject.foo + myObject.bar)
} onChange: { event in
  if event.matches(\MyObject.foo) {
    print("got a change of foo")
  }
  if event.matches(\MyObject.bar) {
    print("got a change of bar")
  }
}

myObject.bar += 1
// got a change of bar

deinit を指定しておくと、観測対象が解放されたタイミングでもイベントを受け取れます。

var myObject: MyObject? = MyObject()

withObservationTracking(options: [.deinit]) {
  if let myObject {
    print(myObject.foo + myObject.bar)
  }
} onChange: { event in
  print("got a deinit event")
}

myObject = nil

deinit イベントを受け取った時点で、対象への weak 参照はすでに nil になっていますが、オブジェクト自体の deinit 処理が完了しているとは限らない点に注意が必要です。

willSetdidSet の両方を指定した場合の挙動は次のようになります。

_ = withObservationTracking(options: [.willSet, .didSet, .deinit]) {
  observable.property
} onChange: { event in
  switch event.kind {
  case .initial: print("initial event")
  case .willSet: print("property will set")
  case .didSet: print("property did set")
  case .deinit: print("an Observable instance deallocated")
  }
}

observable.property += 1
// property will set
// property did set

willSet の時点ではまだ新しい値はインスタンスに書き込まれておらず、didSet の時点で書き込みが完了している、という既存のプロパティオブザーバと同じセマンティクスが保たれます。

withContinuousObservationTracking(options:apply:)

もう一つのエントリポイントは、変更があるたびに何度でも呼び出される継続版です。

public func withContinuousObservationTracking(
  options: ObservationTracking.Options,
  @_inheritActorContext apply: @isolated(any) @Sendable @escaping (borrowing ObservationTracking.Event) -> Void
) -> ObservationTracking.Token

こちらは「観測対象プロパティへのアクセスを記録するブロック」と「通知コールバック」を別々に持ちません。apply クロージャ自体が、イベントを受け取る本体であると同時に、毎回の再評価で観測対象を読み取ることで次の追跡対象を決めていきます。

戻り値の ObservationTracking.Token は観測の寿命を表します。トークンを保持している間だけ観測が続き、トークンを破棄するか cancel() を明示的に呼んだ時点で観測は解除され、以降のコールバックは発火しません。

extension ObservationTracking {
  public struct Token: ~Copyable {
    public consuming func cancel()
  }
}

apply には @isolated(any)@_inheritActorContext が付いており、呼び出し元の isolation をそのまま引き継ぎます。@MainActor な文脈で withContinuousObservationTracking を呼べば、apply は常にメインアクター上で実行されます。継続版のもう一つの特徴として、イベントそのものではなく「イベントのあとに訪れる次のサスペンションポイント」で apply が呼び出されるため、同じ isolation に閉じた複数の変更はまとめて観測されます。

たとえばビューのラベルをモデルの値に追従させるような使い方は次のように書けます。

@MainActor
final class Controller {
  var view: MyView
  var model: MyObservable
  let synchronization: ObservationTracking.Token

  init(view: MyView, model: MyObservable) {
    synchronization = withContinuousObservationTracking(options: [.willSet]) { [view, model] event in
      view.label.text = model.someStringValue
    }
  }
}

Controller が生きている間は synchronization トークンも保持され、model.someStringValue の変化に応じて view.label.text が更新され続けます。Controller が解放されればトークンも一緒に破棄され、観測は自動的に止まります。

今後の展望

ObservationTracking.Options は、Swiftの言語機能としてのプロパティオブザーバに対応する形で設計されています。将来、例えば modified のような新しいオブザーバがSwiftに追加され、@Observable マクロもそれを取り込むことになれば、それに対応する新しい Options の追加が検討される見込みです(speculativeなもので、実現を約束するものではありません)。