every Tech Blog

株式会社エブリーのTech Blogです。

Swift Observationフレームワークの利点と動作

Swift Observationフレームワークの利点と動作

この記事は every Tech Blog Advent Calendar 2025 の 3日目の記事です。

こんにちは、デリッシュキッチンでiOSエンジニアをしている谷口恭一です。

デリッシュキッチンのiOSでは現在、状態の変更通知の仕組みとして主にCombineを使用しています。最低互換のiOSバージョンをiOS16としているため、まだObservationフレームワークの導入はできていません。

しかし、おそらく来年にはiOS17+になると予想され、SwiftUIではObservationを使用したモダンな状態管理システムで新規画面は開発するかもしれません。

AppleはObservationフレームワークを、ObservableObjectから非常に簡単かつ安全に移行できるように設計しているため、既存画面改修などの際には、少しずつObservationに移行していく可能性もあります。

よって、Observation自体の仕組みや、他の状態の変更通知システムに対する利点をある程度は理解する事が重要であると考えています。

そこで、フレームワークの調査や学習を行い、そのアウトプットとしてObservationについて解説していくというのがこの記事の目的です。

まず、Observationフレームワークとは何かについて説明し、既存のCombineのObservableObjectを用いた方法との違いと利点を説明し、最後にObservation自体の動作の概要について説明します。

Observationフレームワークとは

Appleによると、Observationは以下の3つの機能を提供するフレームワークです。

  • Marking a type as observable(型を観測可能にする
  • Tracking changes within an instance of an observable type(観測可能な型のインスタンス内の変更を追跡する
  • Observing and utilizing those changes elsewhere, such as in an app’s user interface(アプリのUIのようなどのような場所からもこれらの変更を観測し活用できる

https://developer.apple.com/documentation/Observation

よって、値の変更を観測可能にする機能と、それを通知する機能であると言えます。SwiftUI側はObservationの機能を内部的に利用して、値の変更をUI再描画に活用しています。

しかしObservationの値の変更の活用自体はSwiftUIのスコープに限らず、どのような場所からもできるという汎用的な機能であることがわかります。 とはいえ、大部分のユースケースはSwiftUIのUIシステムに対するデータバインディングだと思います。

例えば、以下のようなSwiftUIの画面実装で役に立ちます

import SwiftUI

@Observable
class Car {
    var name: String = ""
    var needsRepairs: Bool = false
}

struct ContentView: View {
    @State private var car1 = Car()

    var body: some View {
        VStack(spacing: 20) {
            Text(car1.name.isEmpty ? "No name" : car1.name)

            TextField("Name", text: $car1.name)
                .textFieldStyle(.roundedBorder)

            Toggle("Needs repairs", isOn: $car1.needsRepairs)
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

この実装では、車の名前と、修理が必要かどうかという2つの値がユーザのインタラクションによって変更される可能性があり、それらの変更をUI上に即座に反映させたいという要件があります。

このようなときに、監視対象のクラスに対して@Observableマクロを付けると、そのクラスのプロパティの値をそれぞれ監視/通知し、SwiftUI側が変更通知によって自動でUI更新までやってくれるというのが大まかな機能です。監視対象のクラスはObservableプロトコルに適合されます。

なお、@StateはViewのライフサイクルでインスタンスを破棄せず、この値がView階層内でSSoT(Single Source of Truth、信頼できる唯一の情報源)な値となるために引き続き付ける必要があります。しかし、このプロパティラッパーは、値の監視やObservationの機能とは全く異なるものです。

ここで、Observableプロトコルと、@Observableマクロという概念が登場しました

Appleによると、Observableは以下のようなプロトコルです。

A type that emits notifications to observers when underlying data changes. 内在するデータが変更されたときにオブザーバーに通知を送信する型 https://developer.apple.com/documentation/observation/observable

内在するデータとは、このプロトコルに準拠した型のプロパティのことだと思われます。このプロトコルは単にそれが監視ができるということを表すだけであるということに注意が必要です

次に、@Observableマクロについては以下のような説明があります。

Defines and implements conformance of the Observable protocol. Observable プロトコルの準拠を定義および実装します。 https://developer.apple.com/documentation/observation/observable()

よってこのマクロでは、2つのことを行っていることがわかります

  • マクロを付けた型をObservableプロトコルに準拠させる
  • マクロを付けた型に対して、Observableプロトコルの準拠のための実装をする

ここでのプロトコルは、概念や型を抽象化、共通化するような目的のプロトコルの使い方とは若干異なるような気がしました。プロトコルを特定の制約の明示に使っていて、その実装などはマクロのコード追加が担うというコンセプトのようです。

Observationフレームワークが提供する機能としてはこれでほぼすべてになります。非常にシンプルであることがわかります。

SwiftUI側は、Observableに適合した型に対してUI更新などの特別な操作を内部的に仕込んでおくことによって、その変更を活用することができるという仕組みになっています。

ObservableObjectとの違いについて

現在でもSwiftUIでの状態管理にはCombineのObservableObjectプロトコルと@StateObjectプロパティラッパーがよく使われていて、Observationによる実装はこちらとほぼ同等な機能を提供していると思います。 ではなぜObservationが必要なのでしょうか?また、ObservableObjectに対して、Observationフレームワークはどのような利点があるのでしょうか?私は、主に以下の4つあると考えています。

  • 監視したい型のプロパティそれぞれに対して監視が行える
  • ネストした型の子要素も監視できる
  • SwiftUIのView階層で、監視したい値を伝播させる必要がない
  • 監視対象の各プロパティに対して、監視することを明示する必要がない

まず1つ目が、監視したい型のプロパティそれぞれに対して監視が行えるという点です。

ObservableObjectの場合、このプロトコルに適合したクラスの@Publishedなプロパティの変更によって、そのクラスを使用するUI要素すべてが再描画されてしまうという問題がありました。しかし、監視対象のプロパティがたくさんある場合、そのプロパティに関係があるUI要素のみを再描画するほうが理にかなっています。Observationはこのような要件を満たすことができています。

次に、監視対象の型のネストについてです。

ObservableObjectに適合したクラスの@PublishedなプロパティをObservableObjectに準拠させて監視しようとしても、ネストされた子オブジェクトの変更は自動では通知されません。これは単純な理由で、状態の変更通知はView側でObservableObjectプロトコルのobjectWillChange()というメソッドによって行われるため、ネストした子オブジェクトのobjectWillChange()は呼ばれないから通知されないという訳です。 よって、子オブジェクトのobjectWillChange()が呼ばれると、親オブジェクトのobjectWillChange()を連鎖的に呼ぶような実装を明示的にすればこの問題は解決できますが、クリーンな方法とは言えなそうです。 Observationでは、単にアクセスした値を自動的に監視するというコンセプトのため、その値を保持するデータ構造に依存しません。よって、ネストされていても問題なく変更通知を受け取ることができます。

3つめにSwiftUI上での値の伝播についてです。 SwiftUIでは、@StateObject@Stateで定義したViewのスコープのライフサイクルにおいて、この値の監視とUI更新を行います。よって、子Viewでの値の変更には対応していません。よって、@Bindingというプロパティラッパーを使用して、子Viewと値を共有するという仕組みを提供しています。よって、末端の子Viewで値が変更されると、その子Viewを含むView階層全体を更新します。データの伝播が仕組みとして複雑であるという点と、1つ目の問題と同様にパフォーマンス的な問題があります。しかし、Observationフレームワークでは、変更対象の値を持つ各Viewそれぞれが値を監視するというシンプルな仕組みのため、値の伝播のための複雑な機能を必要としません。

最後に、ObservableObjectの実装では、監視対象のプロパティに@Publishedというプロパティラッパーを付与する必要があるという点です。SwiftUIで使うクラス内のミュータブルな値は、基本的に監視したいというユースケースなはずです。よって、監視するという状況をデフォルトにして、監視したくないときにそれを明示するほうが自然であるという考え方もできると思います。Observationはこのような方法を取るため、よりスッキリとしたコードを書くことができます。これは書き心地の問題なので、これまでのものより些細な問題かもしれません。

Observationフレームワークの動作の概要

それではObservationフレームワークが提供するObservableマクロはどのような実装をしているのでしょうか?Swiftのマクロは展開することができるため、実装を確認することができます。実装を見れば大体何をしていそうかが推測できると思います。Xcode上ではマクロを表す部分(@Observable)を右クリックすると、Expand Macrosという選択肢が出てきます。

@Observable
class Car: Observable {
    var name: String = ""
    var needsRepairs: Bool = false
}

この例のようなクラスのマクロをXcodeを使って展開すると以下のようになります。

@Observable class Car: Observable {
    @ObservationTracked 
    var name: String = ""
    {
        @storageRestrictions(initializes: _name)
        init(initialValue) {
            _name = initialValue
        }
        get {
            access(keyPath: \.name)
            return _name
        }
        set {
            withMutation (keyPath: \.name) {
                _name = newValue
            }
        }
        _modify {
            access(keyPath: \.name)
            _$observationRegistrar.willSet(self, keyPath: \.name)
            defer {
                _$observationRegistrar.didSet(self, keyPath: \.name)
            }
            yield &_name
        }
    }

    @ObservationIgnored private var _name: String = ""

    @ObservationTracked
    var needsRepairs: Bool = false
    {
        @storageRestrictions(initializes: _needsRepairs)
        init(initialValue) {
            _needsRepairs = initialValue
        }
        get {
            access (keyPath: \.needsRepairs)
            return _needsRepairs
        }
        set {
            withMutation (keyPath: \. needsRepairs) {
                _needsRepairs = newValue
            }
        }
        _modify {
            access (keyPath: \.needsRepairs)
            _$observationRegistrar willSet(self, keyPath: \.needsRepairs)
            defer {
                _$observationRegistrar didSet(self, keyPath: \.needsRepairs)
            }
            yield &_needsRepairs
        }
    }

    @ObservationIgnored private var _needsRepairs: Bool = false

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

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

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

まず、Observationフレームワークの登場人物は以下になります

  • ObservationRegistrar: どこでどの@ObservationTrackedなプロパティを見ているかを保持する登録簿のようなものを管理する責務を持つ
  • access(keyPath:): プロパティが読まれたことをレジストラに伝える
  • withMutation(keyPath:, mutation: ): 値の変更を監視者全体に伝える

@Observableマクロを付けると、定義したプロパティはすべて計算プロパティになります。SwiftのAttached Macroはコードの変更や削除はできず、追加することしかできないという制約があります。これはコードの安全性を保つためです。しかし、元の変数を_nameのように別の変数によけて、本来の変数を計算プロパティにすると言う方法はAttached Macroのコンセプトの割には結構技巧的だなと個人的には思いました。

これによって、定義したプロパティのgetsetで特定の処理を行うことができるようになっています。マクロを使用する部分の本質はここだと思っています。ここでしたい処理の殆どはObservationRegistrarという値の監視/通知を司る構造体経由で行っていることがわかります。ObservationRegistraraccessというメソッドでは監視対象の登録、withMutationでは変更の全体通知のような役割を持っています。

また、ObservationRegistrarの定義の末尾に以下のようなグローバル関数が定義されています。

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

この関数は、apply内でレジストラに登録されたプロパティの変更を検出すると、onChange内のクロージャが実行されるというシンプルな関数です。

Appleのドキュメントでは以下のようなコード例が示されていました。

func render() {
    withObservationTracking {
        for car in cars {
            print(car.name)
        }
    } onChange: {
        print("Schedule renderer.")
    }
}

一見するとこの関数は不思議に思うかもしれません。なぜなら、apply内では単にprint文を呼んでいるだけで、意味のないコードに見えるからです。しかし、Observableマクロの仕組みを見るとわかるように、プロパティのgetaccessが呼ばれないと、監視対象に追加されないという設計のため、このプロパティを1度でも参照する必要があるということです。

SwiftUIのViewのbodyでは、このメソッドにUI再描画の必要があるプロパティを渡して監視する機能が内部で実装されているのであろうということが推測できます。これによって、必要なViewのスコープで必要なプロパティだけを監視して部分的に再描画するということが可能になっている技術的な理由がわかると思います。

まとめ

  • Observationは以下の機能を提供するシンプルなフレームワークである
    • 型を観測可能にする
    • 観測可能な型のインスタンス内の変更を追跡する
    • アプリのUIのような、どのような場所からもこれらの変更を観測し活用できる
  • SwiftUIでの状態管理では、ObservationフレームワークはCombineのObservableObjectに対していくつかのメリットがある
  • Observationフレームワークは、値の監視機能をマクロを使って実装されていて、ObservationRegistrar経由で監視と通知が行われている

SwiftUIにおいてObservationフレームワークを使用したシンプルな状態管理はiOSアプリ開発におけるデファクトスタンダードになると私は思います。もちろんCombineなどを使用するべきユースケースもあると思います。デリッシュキッチンの最低互換バージョンが上がったら、実際に移行してみて試してみたいと思います。