Swift Digest
SE-0395 | Swift Evolution

Observation

Proposal
SE-0395
Authors
Philippe Hausler, Nate Cook
Review Manager
Ben Cohen
Status
Implemented (Swift 5.9)

01 何が問題だったのか

Swiftで「値が変わったらUIを更新する」といった observer パターンを実現するには、従来は Foundation の KVO(Key-Value Observing)か Combine の ObservableObject を使う必要がありました。どちらもうまく機能する場面はあるものの、次のような不足や不便さがありました。

  • KVO は NSObject 継承に縛られる: Darwin プラットフォーム限定で、観測対象も観測者も NSObject 派生である必要があります。APIはSwiftのキーパスに更新されましたが、内部的には文字列ベースのイベントに依存しており、クロスプラットフォームなSwiftの型には適用できません。
  • ObservableObject は Combine に依存する: Combine は Darwin 限定で、Swift Concurrency(async/await)とも噛み合っていません。さらに、観測したい stored property すべてに @Published を付ける必要があり、冗長な割には「何が観測対象か」の意味をはっきり伝えるわけでもなく、慣れるほどに注意力を消耗する形骸的な注釈になりがちでした。computed property は直接観測できないという制約もあります。
  • 変更通知のタイミングが粗い: ObservableObject は値が書き換わる「前」にイベントを発火します(SwiftUIには都合がよいものの、それ以外の用途では驚きの元になります)。KVOは willSet/didSet の両方を拾える柔軟さはあるものの、複数の関連プロパティを一塊の「トランザクション」としてまとめる手段がありません。Combineでは同じことをやるのも複雑です。
  • SwiftUIでの過剰な再描画: @Published は「このプロパティが変わった」としか伝えないため、SwiftUI側はビュー内で実際にどのプロパティが読まれたかに関係なく、そのモデルを参照しているビュー全体を再評価しがちです。結果として不要なレイアウト・描画・更新が走ります。

つまり、プラットフォーム非依存で、NSObject にも Combine にも依存せず、@Published のような逐一の注釈が不要で、computed property もそのまま観測でき、しかも「このスコープで実際に読まれたプロパティ」だけを追跡して通知できる observer パターンが、Swift標準には存在していませんでした。

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

標準ライブラリとは別モジュールとして Observation モジュールが追加され、import Observation することで Observable プロトコルと @Observable マクロ、そして観測スコープを定義する withObservationTracking(_:onChange:) 関数が使えるようになります。

@Observable マクロで宣言する

観測可能にしたいクラスに @Observable を付けるだけで、そのクラスの すべての stored property が暗黙に観測対象 になります。@Published のようにプロパティごとの注釈は不要です。

@Observable class Car {
    var name: String
    var awards: [Award]
}

@Observable マクロは、展開時に次のことを行います。

  • Observable マーカープロトコルへの適合を宣言する
  • 観測状態を保持するための ObservationRegistrar プロパティを追加する
  • 各 stored property を computed property に変換し、get 時にアクセスを記録し、set 時に変更通知を発火するようにする
  • 実際の値を保持する _ プレフィックス付きの stored property を生成し、そこには @ObservationIgnored を付ける

たとえば次のコードは、

@Observable class Model {
    var order: Order?
    var account: Account?
}

概ね以下のように展開されます(access / withMutation@Observable が合わせて生成するヘルパーです)。

class Model: Observable {
    internal let _$observationRegistrar = ObservationRegistrar<Model>()

    var order: Order? {
        get {
            self.access(keyPath: \.order)
            return _order
        }
        set {
            self.withMutation(keyPath: \.order) {
                _order = newValue
            }
        }
    }
    // account も同様

    var _order: Order?
    var _account: Account?
}

この展開はあくまで「マクロが自動で書く内容」であり、より細かく制御したい場合は同じコードを手書きしてもかまいません。

観測したくないプロパティと、独自の computed property

観測対象から外したい stored property には @ObservationIgnored を付けます。逆に、外部ストレージに値を持つような computed property を観測対象にしたい場合は、get と set の中で access(keyPath:)withMutation(keyPath:) を自分で呼び出します。

@Observable
public class AtomicModel {
    @ObservationIgnored
    fileprivate let _scoreStorage = AtomicInt(initialValue: 0)

    public var score: Int {
        get {
            self.access(keyPath: \.score)
            return _scoreStorage.value
        }
        set {
            self.withMutation(keyPath: \.score) {
                _scoreStorage.value = newValue
            }
        }
    }
}

他の stored property を参照するだけの computed property(下の例の fullName など)は、参照先のプロパティが追跡されるため自動的に追跡されます。自分で access(keyPath:) を書く必要はありません。

willSet / didSet もそのまま使える

stored property に willSet / didSet が付いている場合も、@Observable は元のオブザーバを保ったまま変換します。生成される _ プレフィックス側の stored property にオブザーバが移されるため、willSet / didSet の挙動はこれまで通り動きます。

@Observable class PropertyExample {
    var a = 0 {
        willSet { print("will set triggered") }
        didSet { print("did set triggered") }
    }
}

初期化子とサブクラス

@Observable マクロは、すべての stored property に デフォルト値が与えられている ことを要求します。これはメンバーワイズイニシャライザに頼らず、追加のイニシャライザを extension で自由に足せるようにするための制約です。デフォルト値が書けない場合は、この制約が今後緩められる可能性があります(後述の今後の見通しを参照)。

サブクラスで @Observable を適合させることもできますが、観測対象になるのは Observable の追跡要件を実装している型のプロパティだけです。非観測クラスを継承した観測サブクラスの場合、親クラスの stored property は追跡されません。

withObservationTracking(_:onChange:) で「使われたプロパティ」だけを追跡する

Observation モジュールの中心的なAPIが withObservationTracking(_:onChange:) です。apply クロージャの中で実際にアクセスされた観測対象のプロパティだけが追跡され、そのうちのどれかが 最初に変化した一度だけ onChange が呼ばれます。

public func withObservationTracking<T>(
    _ apply: () -> T,
    onChange: @autoclosure () -> @Sendable () -> Void
) -> T

たとえば次の Car の配列を描画するコードでは、apply の中で各 car.name だけを読んでいるため、name の変更では onChange が呼ばれますが、awards への要素追加では呼ばれません。

let cars: [Car] = ...

@MainActor
func renderCars() {
    withObservationTracking {
        for car in cars {
            print(car.name)
        }
    } onChange: {
        Task { @MainActor in
            renderCars()
        }
    }
}

追跡の粒度は次の通りです。

  • 観測対象オブジェクトの追跡対象プロパティへのアクセス
  • 観測対象型を持つプロパティの、さらにその追跡対象プロパティへのアクセス
  • 他のプロパティを参照する computed property 越しのアクセス

最後の点のおかげで、internal なプロパティを束ねる public な computed property を経由して読んでも、元の internal プロパティ側の変更がきちんと通知されます。

@Observable public class Person: Sendable {
    internal var firstName = ""
    internal var lastName = ""
    public var age: Int?

    public var fullName: String {
        "\(firstName) \(lastName)"
    }

    public var friends: [Person] = []
}

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

この例では、fullName を読んだことで firstNamelastName が、friends.count を読んだことで friends が追跡対象に入ります。配列自体の差し替えは検知されますが、配列の要素個別の変更までは追跡されません。

onChange が「最初の変化で一度だけ」呼ばれるのは、UIフレームワークなどが「再度 withObservationTracking を呼び直して、再度アクセスされたプロパティだけを追跡し直す」という使い方を想定しているためです。

ObservationRegistrar

追跡のための実体は ObservationRegistrar という Sendable な struct です。@Observable マクロはこれをプロパティとして埋め込み、access / willSet / didSet / withMutation を通じてアクセス・変更をシステムに伝えます。スレッドセーフで、複数の isolation から触れても安全に動くよう設計されています。

SwiftUIへの影響(参考)

Combine の ObservableObject + @Published では、@Published を付けたプロパティの変更はすべてビューの再評価を引き起こしがちで、実際にはそのビュー内で参照していないプロパティの変更でも再描画が走る、という過剰な更新が起きやすい構造でした。@ObservablewithObservationTracking を前提にすれば、SwiftUIはビューの body の中で実際に読まれたプロパティだけを追跡できるようになります。

たとえば、

@Observable class Model {
    var order: Order?
    var account: Account?
    var searchString = ""
    // ...
}

struct SmoothieList: View {
    var smoothies: [Smoothie]
    var model: Model  // @ObservedObject も @Published も不要

    var body: some View {
        List(
            smoothies
                .filter { $0.matches(model.searchString) }
                .sorted(by: { $0.title.localizedCompare($1.title) == .orderedAscending })
        ) { smoothie in
            // ...
        }
    }
}

のように、モデル側は @Published の繰り返しが消え、ビュー側も @ObservedObject が不要になります(実際のSwiftUI側の対応はこの Proposal のスコープ外で、別途SwiftUI側で進められます)。

今後の見通し

この Proposal では踏み込まなかったものの、今後検討されうる方向として次のようなものが挙げられています(speculativeなものであり、実現を約束するものではありません)。

  • すべての stored property にデフォルト値を要求する制約の緩和(プロパティラッパーの「イニシャライザから初期値を渡す」仕組みを一般化できれば、@Observable でも通常のイニシャライザを書けるようになります)
  • actor 型の観測対象への対応(現状のキーパスが actor 向けには機能しないため、専用の仕組みが必要になります)
  • 変更を非同期に受け取るための AsyncSequence API(個別プロパティの変更や、トランザクションとしてまとめた変更のストリーム)。これは以前のバージョンで values(for:) / changes(for:) として含まれていたもので、改めて別 Proposal として提案される可能性があります