18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

株式会社ゆめみAdvent Calendar 2023

Day 13

SwiftのObservationフレームワークによる値の監視

Posted at

Observationフレームワークとは

Observationフレームワークは、Swiftで値の変更を監視するためのフレームワークです。

モデル層とビュー層のあいだのデータバインディングの実現に利用でき、とくにSwiftUIでの利用が想定されています。

このフレームワークはSwift 5.9で追加されました。iOS 17、macOS 14など現時点での最新OSでのみ動作するため、それより前のOSをサポートする必要があるアプリの開発には利用できません。そのためすぐには利用できない場合が多いでしょうが、将来のデファクトスタンダードになる可能性があるため、今のうちに知っておくと良いでしょう。

Swiftにおける値の変更の監視

これまでにも、Swiftで値の変更を監視する方法がありました。

  • KVO(Key-Value Observing)
    • Objective-Cからの機能
  • ObservableObject
    • Combineフレームワークの機能
  • @Observable
    • Observationフレームワークの機能

KVOはSwift以前のObjective-Cから存在している機能です。Swiftでも利用できますが、レガシーな仕組みであるため制約もあります。

ObservableObjectはCombineフレームワークの機能で、iOS 13からのSwiftUIで利用されています。SwiftUIとCombineはiOS 13で同時に登場しました。宣言的UIであるSwiftUIには値の変更を監視する機能が必要であり、その実現にCombineが利用されました。

@ObservableはObservationフレームワークの機能で、iOS 17からのSwiftUIで利用されています。ObservableObjectをSwiftUIで利用する際に存在していた、いくつかの欠点を改善しています。

なお、@ObservableはSwift 5.9で追加されたマクロ機能を使って実現されています。

@Observableの基本

次のように、クラスに @Observable をつけると、そのクラスが監視可能になります。

import Observation

@Observable
final class Model {
    var value: Int = 0
}

監視する側では、次のように withObservationTracking 関数を使います。これはObservationプロトコルによって、トップレベルに用意されている関数です。

import Observation

final class ViewController: UIViewController {
    private let model = Model()

    private func tracking() {
        withObservationTracking { [weak self] in
            guard let self else { return }
            print("value: \(model.value)")
        } onChange: {
            print("onChange")
        }
    }
}

withObservationTracking のクロージャで監視対象が決まります。この中でObservableである Model クラスの model.value を参照していますが、これによって自動的に model.value の値が監視対象となります。

        withObservationTracking { ...
            ...
            print("value: \(model.value)")
        } onChange: {
            ...
        }

監視対象である model.value の値が変更されると、変更の通知として onChange クロージャが呼ばれます。

        withObservationTracking { ...
            ...
        } onChange: {
            print("onChange")
        }

実際に動作を追ってみましょう。tracking() を呼び出すと print("value: \(model.value)") が呼ばれて次が出力されます。

value: 0

それと同時に、model.value が監視対象になります。model.value の値が変更されると print("onChange") が呼ばれて次が出力されます。

onChange

これによって、値の変更を監視できていることがわかります。

なお、注意点として、この通知は1回だけ来ます。つまり、もう一度 model.value の値が変更されても、onChange は呼ばれません。

監視を継続する

変更の通知は1回だけということでしたが、値の変更の監視を継続的に行うにはどうすれば良いでしょうか。

そのためには、先述のコード例を次のように変更します。監視を再帰的に呼び出すことで、変更の監視を継続できます。

    private func tracking() {
        withObservationTracking { ...
            ...
        } onChange: {
            Task { @MainActor [weak self] in
                guard let self else { return }
                tracking()
            }
        }
    }

UIKitでの利用例

@Observable をUIKitで利用する場合の例を示します。

    private func tracking() {
        withObservationTracking { [weak self] in
            guard let self else { return }
            label.text = "value: \(model.value)"
        } onChange: {
            Task { @MainActor [weak self] in
                guard let self else { return }
                tracking()
            }
        }
    }

この動作を追ってみましょう。tracking() を呼び出すと label.text = "value: \(model.value)" が呼ばれて、ラベルの表示文字列が更新されます。

value: 0

それと同時に、model.value が監視対象になります。model.value の値が変更されると onChange が呼ばれて、その中で tracking() が呼ばれます。すると、再び label.text = "value: \(model.value)" が呼ばれて、ラベルの表示文字列が更新されます。

value: 1

それと同時に、再度 model.value が監視対象になります。こうして、値が変更されるたびにラベルの表示文字列が更新されます。

ここまでに説明した例の、全体のコードを挙げておきます。Observationに関する部分は既に説明したため、問題なく理解できるでしょう。

import Observation
import UIKit

@Observable
final class Model {
    var value: Int = 0
}

final class ViewController: UIViewController {
    private let model = Model()
    private let label = UILabel()
    private let button = UIButton()

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        tracking()
    }

    private func setup() {
        view.backgroundColor = .white

        view.addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            label.leadingAnchor.constraint(
                equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
            label.trailingAnchor.constraint(
                equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
            label.heightAnchor.constraint(equalToConstant: 32),
        ])
        label.textColor = .black
        label.textAlignment = .center

        view.addSubview(button)
        button.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            button.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 16),
            button.leadingAnchor.constraint(
                equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
            button.trailingAnchor.constraint(
                equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
            button.heightAnchor.constraint(equalToConstant: 32),
        ])
        var configuration = UIButton.Configuration.plain()
        configuration.title = "Update"
        button.configuration = configuration

        button.addAction(
            UIAction { [weak self] _ in
                guard let self else { return }
                update()
            },
            for: .touchUpInside)
    }

    private func update() {
        model.value += 1
    }

    private func tracking() {
        withObservationTracking { [weak self] in
            guard let self else { return }
            label.text = "value: \(model.value)"
        } onChange: {
            Task { @MainActor [weak self] in
                guard let self else { return }
                tracking()
            }
        }
    }
}

SwiftUIの場合

次に、@Observable をSwiftUIで利用する場合の例を示します。

実は、SwiftUIではUIKitよりずっと楽に利用できます。

struct MyView: View {
    var model = Model()
    
    var body: some View {
        Text(model.value)
    }
}

これだけで継続的に値の変更を監視できます。

Viewbody 内で参照されているプロパティが自動的に監視対象になります。そして継続的に監視されます。withObservationTracking 関数のコード記述は不要です。

Observationの特徴

Observationフレームワークの特徴的な性質をいくつか紹介します。

次のように、監視可能なプロパティがふたつある場合を考えます。

@Observable
final class Model {
    var value1: Int = 0
    var value2: Int = 0
}

struct MyView: View {
    var model = Model()
    
    var body: some View {
        Text(model.value1)
    }
}

body 内で参照されている model.value1 のみが監視対象になり、model.value2 は監視対象になりません。model.value2 の値が変更されても、MyView は再描画されずにすみます。不要な監視は発生せず、効率的です。

また、次のようにコレクションを含む場合を考えます。これはAppleのドキュメントに挙げられている例です。

@Observable
final class Book: Identifiable {
    var title = "Sample Book Title"
    ...
}

struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            Text(book.title)
        }
    }
}

この場合、books への要素の追加や削除に応じてListを更新できます。つまり、コレクションが監視できます。例えば要素が3つから4つに増えると、Listの表示アイテムも4つに増えます。

また、どれかひとつの book.title が変更されると、その book.title に対応するViewだけが更新されます。例えば books[1].title が変更されると、その books[1] に対応するViewだけが更新されます。全体が再描画されないため、効率的です。

Observableフレームワークの制約

制約もいくつかあります。これらは今後解消されなさそうです。

構造体には @Observable をつけられません。値型(構造体)では使えず、参照型(クラス)で使える機能となります。これについては、Swiftは構造体の値変更を監視するようには設計されてないとのことです。

バックポートには対応しません。Swiftの別の機能であるConcurrencyがバックポートに対応したという事例があったため、Observationにもバックポートが期待されました。しかし、議論の結果、バックポートには対応しないことになりました。古いバージョンのSwiftUIを対応させるのが難しいとのことです。

おわりに

Observationフレームワークによる値の変更の監視は、既存の方法よりもシンプルで効率的です。とくにSwiftUIとの相性が良いです。

古いOSで使えないのでまだ採用できない場合が多いでしょうが、将来のデファクトスタンダードとなりそうです。

参考文献

18
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?