【図解とコードで学ぶ】Observationの使い方と仕組みを理解する

Swift

Observationとは

Observation | Apple Developer Documentation
Make responsive apps that update the presentation when underlying data changes.

ObservationとはWWDC23で発表された、Swiftで値の変更を監視するためのフレームワークです。

iOS17以降で使用可能で、従来のCombineを使用した方法に比べて、よりシンプルに値の変更を検知するコードを書けるようになりました。

今回はObservationの基本的な使い方と、値の変更を検知して通知するまでの流れを、内部設計にまで踏み込んで解説していきます。

動作環境

  • macOS Tahoe 26.0
  • Xcode 26.0 beta

基本的な使い方

withObservationTracking

withObservationTracking(_:onChange:) | Apple Developer Documentation
Tracks access to properties.

まずはObservationを使う上で、最も基本的な関数となる withObservationTracking について解説していきます。

withObservationTrackingは、指定したプロパティの変更を監視し、変更時にコールバック処理を実行する関数です。これを使えば、SwiftUIの外でもObservationの仕組みを活用できます。

Swift
import Observation

@Observable
final class Chat {
    var message: String
    var isRead: Bool
}

var chat = Chat(message: "", isRead: false)

withObservationTracking {
    let _ = chat.isRead // isReadのみを参照
} onChange: {
    Task { @MainActor in
        print("On Changed: \(chat.message) | \(chat.isRead)")
    }
}

chat.message = "Hello, World!" // messageは監視対象外なのでonChangeは呼ばれない
chat.isRead = true
chat.isRead = false

// 実行結果
On Changed: Hello, World! | false

このコードのポイントをまとめます。

1. 参照したプロパティのみを監視

withObservationTrackingは、追跡クロージャ(apply: )内で実際に参照されたプロパティのみを監視対象とします。上の例ではchat.isReadのみを参照しているため、messageプロパティが変更されてもonChangeは呼ばれません。

2. willSetタイミングでの実行

変更通知はwillSetのタイミングで発生します。そのため、onChangeクロージャ内でプロパティを参照しても、まだ古い値が表示されます。上記の例でisReadfalseのままなのはこのためです。

3. onChangeが呼ばれるのは1回きり

withObservationTrackingによる監視は一回限りです。最初の変更が検知され、onChangeが実行されると監視が終了するため、継続的に監視したい場合は再帰的に実装する必要があります。

以下は再帰的に関数を呼ばし、継続的に変更を検知するコード例です。

Swift
import Observation

@Observable
final class Chat {
    var message: String
    var isRead: Bool
}

var chat = Chat(message: "", isRead: false)

@MainActor
func observeIsRead() {
    withObservationTracking {
        let _ = chat.isRead
    } onChange: {
        Task { @MainActor in
            print("On Changed: \(chat.message) | \(chat.isRead)")
            observeIsRead() // 自分を再帰的に呼び出す
        }
    }
}

Task { @MainActor in
    observeIsRead()

    chat.message = "Hello, World!"
    chat.isRead = true

    try? await Task.sleep(for: .seconds(1))

    chat.isRead = false
}

SwiftUIでの使用

ObservationはSwiftUIでの使用を前提としたフレームワークです。

従来のCombineを使用する方法と比べて、かなりシンプルにコードを記述できるようになります。

Combineを使った方法

  • クラスにObservableObjectプロトコルに準拠
  • 監視するプロパティに@Publishedを付与
  • View側で@ObservedObject@StateObjectを使用

Observationを使った方法

  • クラスに@Observableを付けるだけ
  • @Publishedは不要
  • View側では通常の@Stateで定義可能
Swift
import SwiftUI
import Observation

@Observable
final class Chat {
    var message: String
    var isRead: Bool
}

struct ContentView: View {
    @State var chat = Chat(message: "", isRead: false)
    var body: some View {
        VStack {
            Text("メッセージ: \(chat.message)")
            Text("既読済み: \(chat.isRead.description)")

            Spacer()

            TextField("", text: $chat.message)
                .textFieldStyle(.roundedBorder)
            Button("既読") {
                chat.isRead = true
            }
        }
        .padding()
    }
}

おそらくSwiftUIの内部で、自動的にwithObservationTrackingが自動的に補完されているのではないかと思います

Swift
struct ContentView: View {
    @State var chat = Chat(message: "", isRead: false)
    var body: some View {
		    withObservationTracking { // おそらくこれが自動で補完されている🤔
		        VStack {
		            Text("メッセージ: \\(chat.message)")
		            Text("既読済み: \\(chat.isRead.description)")
		
		            Spacer()
		
		            TextField("", text: $chat.message)
		                .textFieldStyle(.roundedBorder)
		            Button("既読") {
		                chat.isRead = true
		            }
		        }
		        .padding()
        } onChange: {
		        // Viewを再レンダリング
		        scheduleViewUpdate()
        }
    }
}

Observationの仕組み

Observationの核心は以下の2つの機能です。

  • 読み取り追跡:どのプロパティが読まれたかを記録
  • 変更通知:プロパティが変更されたときに通知

どうやってこの2つの機能を実現しているのでしょうか?

Observableマクロの展開

まずはクラスに付与しているObservableマクロを展開してみます。

展開前

Swift
@Observable
final class Chat {
    var message: String = ""
}

展開後

Swift
@Observable
final class Chat {
		@ObservationTracked
		var message: String = ""
		
		@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

		internal nonisolated func access<$s17ObservationSample4Chat10ObservablefMm_6MemberfMu_>(
		  keyPath: KeyPath<Chat, $s17ObservationSample4Chat10ObservablefMm_6MemberfMu_>
		) {
		  _$observationRegistrar.access(self, keyPath: keyPath)
		}
		
		internal nonisolated func withMutation<$s17ObservationSample4Chat10ObservablefMm_6MemberfMu0_, $s17ObservationSample4Chat10ObservablefMm_14MutationResultfMu_>(
		  keyPath: KeyPath<Chat, $s17ObservationSample4Chat10ObservablefMm_6MemberfMu0_>,
		  _ mutation: () throws -> $s17ObservationSample4Chat10ObservablefMm_14MutationResultfMu_
		) rethrows -> $s17ObservationSample4Chat10ObservablefMm_14MutationResultfMu_ {
		  try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
		}
		
		private nonisolated func shouldNotifyObservers<$s17ObservationSample4Chat10ObservablefMm_6MemberfMu1_>(_ lhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu1_, _ rhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu1_) -> Bool {
		    true
		}
		
		private nonisolated func shouldNotifyObservers<$s17ObservationSample4Chat10ObservablefMm_6MemberfMu2_: Equatable>(_ lhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu2_, _ rhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu2_) -> Bool {
		    lhs != rhs
		}
		
		private nonisolated func shouldNotifyObservers<$s17ObservationSample4Chat10ObservablefMm_6MemberfMu3_: AnyObject>(_ lhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu3_, _ rhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu3_) -> Bool {
		    lhs !== rhs
		}
		
		private nonisolated func shouldNotifyObservers<$s17ObservationSample4Chat10ObservablefMm_6MemberfMu4_: Equatable & AnyObject>(_ lhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu4_, _ rhs: $s17ObservationSample4Chat10ObservablefMm_6MemberfMu4_) -> Bool {
		    lhs != rhs
		}
}

extension Chat: Observation.Observable {
}

展開後のコードについてポイントを解説します。

1. ObservationRegistrarの追加

Swift
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

ObservationRegistrarのインスタンスが自動的に追加されます。これがObservationシステムの中核となっていて、アクセス履歴の保持も、値の変更による通知も、このクラスを起点に行われます。

2. accessメソッドの追加

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

このメソッドは、各プロパティが読み取られるたびに呼び出され、どのプロパティにアクセスされたかを記録します。このおかげでアクセスされたプロパティの変更のみを検知できるようになります。

3. withMutationメソッドの追加

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

このメソッドは、プロパティが変更されるときに呼び出され、変更前後の通知を行います。実際の変更処理をクロージャで受け取り、適切なタイミングで監視者に通知を送信します。

4. 各プロパティに @ObservationTracked を付与

Swift
@ObservationTracked
var message: String = ""

このマクロを展開すると、以下のようになります。

Swift
// 展開後
var message: String = ""
{
    @storageRestrictions(initializes: _value)
    init(initialValue) {
      _value = initialValue
    }
    get {
      access(keyPath: \.value) // アクセスを記録
      return _value
    }
    set {
      // 変更されたかどうかを検知
      // 変更されてなければ通知せず終了
      guard shouldNotifyObservers(_value, newValue) else {
        _value = newValue
        return
      }
      // 変更を通知
      withMutation(keyPath: \.value) {
        _value = newValue
      }
    }
}

アクセスされたら access(keyPath:) を、値が変更されたら withMutation(keyPath: mutation:) を呼び出しているのがわかると思います。

アクセス履歴の保持

ではここから、アクセス履歴の保持を行うまでの内部のフローを解説します。

登場するクラスは以下の2つです。

  • ObservationRegistrar
  • ObservationTracking

まずはwithObservationTrackingの定義を確認します。

ObservationTracking.swift
public func withObservationTracking<T>(
  _ apply: () -> T,
  onChange: @autoclosure () -> @Sendable () -> Void
) -> T {
  let (result, accessList) = generateAccessList(apply)
  if let accessList {
    ObservationTracking._installTracking(accessList, onChange: onChange())
  }
  return result
}

アクセス履歴を保持するまでには以下の2つのステップを踏みます。

  1. _AccessListに一時的に保持
  2. ObservationRegistrarで履歴を集約する

_AccessListに一時的に保持

AccessListは、「どのオブジェクトのどのプロパティにアクセスしたか」を整理して記録している構造体です。

ObservationTracking.swift
public struct _AccessList: Sendable {
    // ObjectIdentifierはクラスのインスタンスを識別するためのID
    internal var entries = [ObjectIdentifier : Entry]()

    internal init() { }
    
    internal mutating func addAccess<Subject: Observable>(
      keyPath: PartialKeyPath<Subject>,
      context: ObservationRegistrar.Context
    ) {
      entries[context.id, default: Entry(context)].insert(keyPath)
    }
    
    // ..省略
  }
ObservationTracking.swift
struct Entry: @unchecked Sendable {
    let context: ObservationRegistrar.Context
    var properties: Set<AnyKeyPath>
    
    // ..省略
}

たとえば、withObservationTrackingで以下のようにプロパティを参照したとします。

Swift
withObservationTracking {
		print(chat.message)
		print(chat.isRead)
} onChange: {

}

withObservationTracking内では、まずは generateAccessList(apply) が実行されます。

ObservationTracking.swift
// 省略版
func generateAccessList<T>(_ apply: () -> T) -> (T, _AccessList?) {
    var accessList: _AccessList?
    let result = withUnsafeMutablePointer(to: &accessList) { ptr in
        // 現在のスレッドに追跡用AccessListポインタを設定
        _ThreadLocal.value = UnsafeMutableRawPointer(ptr)
        
        return apply()  // この中でのプロパティアクセスが追跡される
    }
    return (result, accessList)
}

ここの apply() 実行時に、前述したObservationRegistrar.accessが呼ばれます。

ObservationRegistrar.swift
public func access<Subject: Observable, Member>(
      _ subject: Subject,
      keyPath: KeyPath<Subject, Member>
  ) {
    if let trackingPtr = _ThreadLocal.value?
      .assumingMemoryBound(to: ObservationTracking._AccessList?.self) {
      if trackingPtr.pointee == nil {
	      // 初回アクセス時に_AccessListインスタンスを作成
	      // このリストに、現在の追跡セッション中のアクセスを追加する
        trackingPtr.pointee = ObservationTracking._AccessList()
      }
      trackingPtr.pointee?.addAccess(keyPath: keyPath, context: context)
    }
}

ここでAccessListのインスタンスを生成し、アクセス履歴を追加してます。

最終的にはこのような形になります。

Swift
_AccessList {
    entries = [
        ChatのインスタンスのID: Entry(
            context: Chatのインスタンス自身,
            properties: [\.message, \.isRead]
        )
    ]
}

ObservationRegistrarで履歴を集約する

次に_AccessListを元にして、ObservationRegistrarで履歴を集約します。

generateAccessList が終わると、 ObservationTracking._installTracking(accessList, onChange: onChange()) が実行されます。

ObservationTracking.swift
public static func _installTracking(
    _ tracking: ObservationTracking,
    willSet: (@Sendable (ObservationTracking) -> Void)? = nil,
    didSet: (@Sendable (ObservationTracking) -> Void)? = nil
  ) {
    let values = tracking.list.entries.mapValues { 
      switch (willSet, didSet) {
      
      // 省略...
      case (.some(let willSetObserver), .none):
        return Id.willSet($0.addWillSetObserver { keyPath in
          tracking.state.withCriticalRegion { $0.changed = keyPath }
          willSetObserver(tracking)
        })
        
      // 省略...
      }  
    }
    
    tracking.install(values)
}

この $0.addWillSetObserver のなかでObservationRegistrarに、監視対象のプロパティを渡しています。

ObservationTracking.swift
struct Entry: @unchecked Sendable {
    func addWillSetObserver(_ changed: @Sendable @escaping (AnyKeyPath) -> Void) -> Int {
	      return context.registerTracking(for: properties, willSet: changed)
    }
}
ObservationRegistrar.swift
internal mutating func registerTracking(for properties: Set<AnyKeyPath>, willSet observer: @Sendable @escaping (AnyKeyPath) -> Void) -> Int {
		let id = generateId()
    observations[id] = Observation(kind: .willSetTracking(observer), properties: properties)
    for keyPath in properties {
		    lookups[keyPath, default: []].insert(id)
    }
    return id
}

この lookupsobservations でプロパティへのアクセス履歴を保持しています。

  • lookups:監視対象のプロパティと、監視IDを保持
  • observations:監視IDと、監視対象プロパティが更新された時に実行するメソッドを保持

最終的に、それぞれに以下のような値が入ります。

Swift
lookups = [
    \.isRead: [observerId1]  // このプロパティを監視している観察者ID
    \.message: [observerId1]
]

// 本来はObservationRegistrar.State.Observation型だが、簡略化している
observations = [
    observerId1: { onChange() },
    observerId2: { onChange() },  
]

アクセス履歴を保持するまでのフローをシーケンス図に簡単にまとめます。

Screenshot

プロパティの変更を通知

ではここから、プロパティの変更を検知し、それを通知するまでの流れについて解説します。

こちらはアクセス履歴の保持よりは複雑ではないので、さらっと解説します。

まずプロパティが変更されると、 ObservationRegistrar.withMutation が呼ばれます。

ObservationRegistrar.swift
public func withMutation<Subject: Observable, Member, T>(
    of subject: Subject,
    keyPath: KeyPath<Subject, Member>,
    _ mutation: () throws -> T
  ) rethrows -> T {
    willSet(subject, keyPath: keyPath)
    defer { didSet(subject, keyPath: keyPath) }
    return try mutation()
  }
}

mutation()で値が変更される前に、willSetが呼ばれます。

ObservationRegistrar.swift
public func willSet<Subject: Observable, Member>(
    _ subject: Subject,
    keyPath: KeyPath<Subject, Member>
) {
    context.willSet(subject, keyPath: keyPath)
}
ObservationRegistrar.swift
internal struct Context: Sendable {
		// .. 省略
		internal func willSet<Subject: Observable, Member>(
		    _ subject: Subject,
			  keyPath: KeyPath<Subject, Member>
    ) {
	      let tracking = state.withCriticalRegion { $0.willSet(keyPath: keyPath) } // State.willSetを実行
	      for action in tracking {
		        action(keyPath) // onChangeで指定した処理を実行
	      }
    }
    
    // .. 省略
}

$0.willSet(keyPath: keyPath) で、State.willSetを呼び出しています。

ObservationRegistrar.swift
private struct State: @unchecked Sendable {
		internal mutating func willSet(keyPath: AnyKeyPath) -> [@Sendable (AnyKeyPath) -> Void] {
				var trackers = [@Sendable (AnyKeyPath) -> Void]()
				if let ids = lookups[keyPath] { // 指定したkeypathがlookupsに登録されているかどうか(プロパティが監視対象かどうか)
						for id in ids {
								if let tracker = observations[id]?.willSetTracker {
										trackers.append(tracker) // onChangeの処理が入る
								}
						}
				}
				return trackers
		}
}

if let ids = lookups[keyPath] で、指定したプロパティが監視されているかどうかを判定しています。この処理のおかげで、監視対象のプロパティが変更された時のみ、onChangeが実行されるようになっています。

ここまでのフローをシーケンス図に簡単にまとめます。

まとめ

今回はObservationの使い方と、その仕組みについて解説しました。

今後の開発では、Combineから移行されてどんどん主流のやり方となっていくと思います。

ぜひご自身の手で色々と試してみてください。

ここまでのご閲覧ありがとうございました!

参考にした記事

SwiftのObservationフレームワークによる値の監視 - Qiita
Observationフレームワークとは Observationフレームワークは、Swiftで値の変更を監視するためのフレームワークです。 モデル層とビュー層のあいだのデータバインディングの実現に利用でき、とくにSwiftUIでの利用が想定...
[Swift][Observation] Observation の仕組み その1: Observable
⌛️ 2 min.Sponsor Link 環境&対象 Observable Observable は、Observation フレームワークの一部として導入されました。 なお、Observable という名称をもつものは、以下の2種類あ ...
深入理解 Observation - 原理,back porting 和性能
SwiftUI 遵循 Single Source of Truth 的原则,只有修改 View 所订阅的状态,才能改变 view tree 并触发对 body 的重新求值,进而刷新 UI。最初发布时,SwiftUI 提供了 @State、@...

コメント

タイトルとURLをコピーしました