@Observableの内部構造:マクロ、レジストラ、ObservableObjectが間違っていたもの
Observation frameworkはiOS 17およびSwift 5.9で導入され、CombineベースのObservableObjectモデルを、マクロ駆動のプロパティ単位アクセス追跡システムに置き換えました1。呼び出し側では変更は小さく見えます(: ObservableObjectと各所の@Publishedの代わりに、@Observableマクロが1つだけ)が、ランタイムの挙動はパフォーマンス、正確性、そして移行パスに影響を与える形で異なります。一文で言えば、変更されたプロパティを読んでいなかったviewは、そのプロパティが変更されても再評価されなくなった、ということです。
本記事では、frameworkの内部構造をAppleのドキュメントとSE-0395 提案2に照らして辿っていきます。フレームは「マクロが実際に何を生成し、なぜそうなっているのか」です。多くのチームは構文目当てに@Observableを採用し、更新伝播における構造的な変化を見落としているからです。そこにこそ、本当のパフォーマンス向上(と移行の落とし穴)が宿っているのです。
TL;DR
@ObservableはSwiftマクロで、クラスをObservableマーカーprotocolに準拠した型に展開し、_$observationRegistrar: ObservationRegistrarのインスタンスを格納プロパティとして合成します3。- 各プロパティのgetterは
_$observationRegistrar.access(self, keyPath:)をラップします。setterは_$observationRegistrar.withMutation(of:keyPath:_:)をラップします。レジストラはどのスコープがどのkey pathにアクセスしたかを追跡します。 - 置き換え後の語彙は次のとおりです。
class Foo: ObservableObjectは@Observable class Fooになります。@Published var nameはvar nameになります。@StateObject var foo = Foo()は@State var foo = Foo()になります。@EnvironmentObjectは@Environment(Foo.self)になります。@ObservedObject var fooは単にプロパティを使うだけになります。 @Bindableは、observableインスタンスのプロパティへのbindingを作成するための新しいproperty wrapperです(bindingに使われていた一部の@ObservedObjectの用途を置き換えます)。- 移行時の落とし穴:参照型に対する
@Stateは、view identityまわりの微妙な点で@StateObjectとは異なる挙動をします。盲目的に置き換えたアプリは、viewのリビルド時に紛らわしい初期化挙動を生むことがあるのです。
マクロ展開
コンパイラが@Observableを見つけると、型に対して3つのものを追加して展開します3。
@Observable
class UserProfile {
var name: String = ""
var email: String = ""
var preferences: [String] = []
}
展開(簡略化)は次のようなコードを生成します。
class UserProfile: Observable {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
private var _name: String = ""
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
// ... emailとpreferencesにも同じパターンが適用される
func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
func withMutation<Member, T>(
keyPath: KeyPath<UserProfile, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
構造的な変更は3つあります。
レジストラ。privateなObservationRegistrarインスタンスが追跡状態を保持します。レジストラはモデルのミューテーションと、それに依存するスコープの再評価をつなぐ橋渡し役です。マクロはレジストラ自体に@ObservationIgnoredを付けるので、レジストラ自身は追跡対象になりません。
プロパティストレージの書き換え。宣言された各格納プロパティは、privateなバッキングフィールドと、getter/setterからレジストラを呼び出すcomputed propertyに置き換えられます。コンパイラ生成のアクセサこそが、プロパティ単位の追跡を成立させる仕組みなのです。
Observableへの準拠。レジストラのAPIが期待するマーカーprotocolです。このprotocolには要件はなく、interface contractではなく準拠チェックとして機能します。
レジストラの役割
ObservationRegistrarは2つのことを行います3。
アクセスの追跡。withObservationTracking { ... } onChange: { ... }(SwiftUIがview bodyで使う基盤の追跡API)がクロージャを実行すると、レジストラは読み取られたすべての(self, keyPath)ペアを記録します。アクセスされたパスの集合が、そのスコープの「依存フットプリント」となります。
無効化のトリガー。プロパティが変更されると、レジストラはその特定のkeyPathにアクセスしていたすべてのスコープを見つけ、onChangeクロージャを発火させます。そのkeyPathにアクセスしていなかったスコープは影響を受けません。
ObservableObjectとの対比こそが構造的な変化です。ObservableObjectのobjectWillChangeパブリッシャーは@Publishedのミューテーションごとに発火し、すべてのサブスクライバーが通知を受け取ります。SwiftUIのview body機構はこのパブリッシャーを使って「何かが変わった、再評価せよ」と判断するのです。再評価はview全体に対して走り、その後SwiftUIが実際に変化した依存viewを計算して該当するものだけを更新するのですが、bodyの再評価自体はすでに発生してしまっています。@Observableでは、body再評価そのものがゲートされます。bodyが変更されたプロパティを読んでいなければ、再実行されないのです。
3つのプロパティを持つUserProfileと、nameだけを読むviewを考えてみましょう。差は確かに存在します。@ObservableObjectモデルではemailやpreferencesの変更でもbody再評価がトリガーされますが、@Observableモデルではされません。多数のモデルと多数のviewを持つ複雑なアプリでは、累積的な節約は大きくなります。
移行マッピング
移行語彙を並べて示します4。
| ObservableObject | @Observable |
|---|---|
class Foo: ObservableObject |
@Observable class Foo |
@Published var name: String |
var name: String |
@StateObject var foo = Foo() |
@State var foo = Foo() |
@ObservedObject var foo: Foo |
var foo: Foo(bindingの場合は@Bindable var foo: Foo) |
@EnvironmentObject var foo: Foo |
@Environment(Foo.self) var foo |
.environmentObject(foo) |
.environment(foo) |
@Bindableラッパーには独立した解説が必要です。これは@Observableインスタンスのプロパティに対してBindingを作成するための新しい方法です。
@Bindable var profile: UserProfile
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
@Bindableがないと$profile.nameという構文は使えません。@Observable型は自動的にprojected valueを提供しないからです。@Bindableを付ければ、すべてのプロパティにbinding形式が用意されます。子viewが親のobservableモデルへの双方向バインディングを必要とする場合は@Bindableを、子が読み取り専用の場合はプレーンな参照(var profile: UserProfile)を使ってください。
@State vs @StateObject の落とし穴
本番環境で最も多くのバグを引き起こす移行ラインがこれです。@StateObject var foo = Foo()が@State var foo = Foo()になる、というものです。コードはコンパイルされます。挙動は微妙な仕組みを通じて分岐します。デフォルト値の式がどのように評価されるか、というところに違いがあるのです5。
@Stateと@StateObjectはどちらも、viewのidentityが安定している間はSwiftUIのviewリビルドをまたいでインスタンスを保持します。どちらもidentityをキーとするバッキングストアを持ち、親由来の再初期化を捨てるのです。違いは、初期化式がいつ実行されるかにあります。
@StateObjectは@autoclosureでパラメータを宣言します。Foo()という初期化式はラップされ、SwiftUIが実際にインスタンスを構築する必要があるときにのみ評価されます。viewのidentityが保持され既存インスタンスが再利用される親リビルドでは、この式は決して呼び出されません。重い初期化処理は走らないのです。
@Stateはautoclosureでラップされていません。Foo()という初期化式は、viewのinitが走るたびに即座に評価されます(これは親リビルドのたびに発生し、viewのidentityが保持されストレージ内の既存インスタンスが維持される場合でも例外ではありません)。Foo()のアロケーションは発生し、SwiftUIは新しいインスタンスを破棄して保存済みのものを使い続けます。init()が軽いモデルでは、無駄なアロケーションは目に見えません。init()が重いモデル(ネットワークリクエスト、大きなデータロード、init内で起動される非同期処理)では、この差は、動くアプリと、親リビストのたびに自身のbackendをDDoSするアプリを分ける差になります。
防衛的なパターンはこうです。モデルのinit()を軽く保って差を無関係にするか、重い初期化を要するモデルをアプリレベルで一度だけ初期化して.environment()経由で渡す。重いセットアップを必要とするモデルは、どちらのproperty wrapperが保持していようが、その作業をinitで実行すべきではありません。@Stateと@StateObjectの両方のケースにおいて、遅延初期化や明示的なセットアップメソッドこそが正しいパターンです。
明示的な追跡のための withObservationTracking
SwiftUIの外では、追跡プリミティブはwithObservationTracking { ... } onChange: { ... }です6。
import Observation
let profile = UserProfile()
withObservationTracking {
print("Name: \(profile.name)")
} onChange: {
print("Something we read changed")
}
profile.name = "Alice" // onChangeをトリガーする
profile.email = "..." // onChangeはトリガーしない(読んでいないため)
クロージャは1度だけ実行され、observableなアクセスをすべて記録します。それらアクセスのソースプロパティのいずれかが変更されると、onChangeがちょうど1回だけ発火します(ワンショットコールバックなのです)。再追跡するには、クロージャをもう一度セットアップしなければなりません。これはSwiftUIがview bodyの依存関係を追跡するために内部で使っているパターンと同じです。SwiftUI以外のコード(NSWindowController、Cocoaアプリ、コマンドラインツール)にとって、withObservationTrackingが適切なプリミティブとなります。
ObservableObjectがまだ正解となるとき
ObservableObjectが依然として居場所を保つケースは3つあります。
iOS 16以前を対象とするアプリ。Observation frameworkはiOS 17+です。古いdeployment targetを持つアプリではObservableObjectが必要となります。deployment targetが17+に上がれば、移行は安全になります。
値グラフの外に通知を発行する必要があるモデル。ObservableObjectのobjectWillChangeはCombineパブリッシャーです。Combineパイプラインを通じて「あらゆる変更」を購読したいコード(debouncing、throttling、イベントストリームの変換)はObservableObjectなら無料で得られますが、@Observableでは同等の機能を作り直す必要があります。Observation frameworkは、任意のパブリッシャー購読よりもviewの再評価効率を優先しているのです。
移行コストがメリットを上回る既存コードベース。パフォーマンス問題が計測されていない動作中のObservableObjectコードベースは、移行で監査コストを正当化できるほどのメリットを得られません。ファイルに既に手を入れているとき、またはプロファイリングがホットスポットを特定したときに移行してください。
新規コードでは、iOS 17+ターゲットにおいて@Observableがモダンなデフォルトであり、移行パスは明確です。
このパターンがiOS 26+アプリにとって意味すること
要点は3つあります。
-
新規コードでは
@Observableをデフォルトに。マクロは簡潔で、プロパティ単位の追跡は一般的なケースでパフォーマンスを改善し、移行語彙は明確です。iOS 17+のコードベースにおける新しいモデルは@Observableにすべきでしょう。 -
@StateObject→@Stateの移行をview identityの観点で監査。置き換え自体はクリーンにコンパイルされますが、条件付き構造を持つviewでは驚くような再初期化を生むことがあります。重い@StateObjectのinit()処理を行うモデルには慎重な移行が必要です。そうでないモデルは安全です。 -
@Bindableを意識的に使う。これはobservableモデルへの双方向バインディングのための新しいパターンです。親のモデルをミューテーションする必要のある子viewでは積極的に使ってください。読み取り専用のviewにはプレーンな参照(var foo: Foo)を残してください。
Apple Ecosystemクラスター全体は次のとおりです。型付きのApp Intents、MCPサーバー、ルーティングの問題、Foundation Models、ランタイム vs ツーリングLLMの区別、3つの表面、single source of truthパターン、2つのMCPサーバー、Apple開発のためのhooks、Live Activities、watchOSランタイム、SwiftUI内部、RealityKitの空間的メンタルモデル、SwiftDataスキーマ規律、Liquid Glassパターン、マルチプラットフォーム出荷、プラットフォームマトリクス、Vision framework、Symbol Effects、Core MLによるオンデバイス推論、Writing Tools API、Swift Testing、Privacy Manifest、プラットフォームとしてのアクセシビリティ、SF Proタイポグラフィ、visionOSの空間パターン、Speech framework、SwiftDataマイグレーション、tvOS focus engine、書かないと決めたこと。ハブはApple Ecosystem Seriesにあります。AIエージェントを伴うiOS開発全般の文脈はiOS Agent Development guideを参照してください。
FAQ
なぜAppleはObservableObjectを置き換えたのですか?
理由は2つあります。第一にパフォーマンスです。ObservableObjectのobjectWillChangeパブリッシャーは@Publishedのミューテーションごとに発火し、依存するすべてのviewでbody再評価をトリガーします。viewが実際に変更されたプロパティを読んでいるかどうかは関係ありません。@Observableのプロパティ単位の追跡は、viewが実際にアクセスしたプロパティに対してbody再評価をゲートします。第二に構文です。プロパティごとの@Publishedアノテーションと、@StateObject/@ObservedObject/@EnvironmentObjectという階段は、概念的には1つのアイデア(「これはミュータブルな共有状態である」)に対して冗長でした。@Observableプラス@Stateプラス@Environmentの方が短いのです。
@Observableはstructで動きますか?
いいえ。@Observableは参照セマンティクスを必要とし、structは該当しません。マクロは複数のviewにまたがってミュータブルな状態を保持するクラスのためのものです。単一view内の値型状態には、値型をそのまま@Stateで使ってください。
同じアプリで@ObservableとObservableObjectを併用できますか?
できます。両者は競合せず共存します。移行はファイル単位で進められます。境界は型単位です。1つのクラスはObservableObjectか@Observableのどちらかであって両方ではありませんが、同じアプリ内の異なるクラスは異なるアプローチを使えます。
Combineパイプラインを発火する@Publishedプロパティはどうなりますか?
@Observableは個々のプロパティに対するCombineパブリッシャー相当を提供しません。@Publishedプロパティから$foo.publisherパターンを使っているコードは、@Observableでは別の方法で購読を作り直す必要があります(例えば、プロパティを値型モデルにラップしてSwiftUIの更新サイクルを通じて観察する、またはwithObservationTrackingを繰り返し使う)。Combine中心のコードパスでは、移行は本格的なエンジニアリング作業となります。
@ObservableはSwiftDataの@Modelとどう連携しますか?
@Model(SwiftData)型は自動的に@Observableになります。永続化frameworkがコード生成の一部としてObservable準拠を追加するため、SwiftDataモデルはプレーンな@Observable型と同じプロパティ単位の追跡に参加します。@Model型のプロパティを観察するviewは、同じ細粒度の再評価挙動を得られます。クラスター内のSwiftDataマイグレーションとSwiftDataスキーマ規律の記事は、同じobservation surfaceの永続化側を扱っています。
@ObservationIgnoredは何のためにあるのですか?
格納プロパティを観察追跡から除外するためのものです。マクロは通常、すべての格納プロパティをレジストラ経由になるよう書き換えますが、@ObservationIgnoredが付いたプロパティは追跡なしで直接ストレージを保持します。viewの再評価をトリガーすべきでないプロパティに使ってください。キャッシュ、ファイルハンドル、メトリクスカウンター、レジストラ自身などです。
References
-
Apple Developer Documentation: Observation framework.
Observableプロトコルと@Observableマクロを扱うframeworkリファレンス。iOS 17+、macOS 14+、Swift 5.9+で利用可能。 ↩ -
Swift Evolution: SE-0395 Observability. 設計の根拠、セマンティック要件、およびレジストラprotocolの契約を含む、承認されたSwift提案。 ↩
-
Apple Developer Documentation:
ObservationRegistrarとObservable. マクロが準拠を生成するランタイム型と、合成されたアクセサが呼び出すレジストラAPI。 ↩↩↩ -
Apple Developer Documentation: Migrating from the Observable Object protocol to the Observable macro. property wrapperのマッピング表とSwiftUI統合の変更を扱うApple公式の移行ガイド。 ↩
-
Apple Developer Documentation:
StateとStateObject. 2つのproperty wrapperのview identityとリビルドライフサイクルに関する初期化セマンティクスを文書化したもの。 ↩ -
Apple Developer Documentation:
withObservationTracking(_:onChange:). SwiftUIの自動view body追跡の外で使われる明示的な追跡プリミティブ。 ↩