🥶

Hello Swift Observation

2024/01/28に公開

Swiftに追加された新たな状態監視のFrameworkであるObservationについて勉強したので個人的にまとめます。

Observationとは

WWDC23 で発表された新たな状態監視のFramework
CombineはクローズドなApple純正の状態監視のFrameworkであったのに対して
ObservationはSwiftリポジトリ上で開発されているオープンな言語機能
Swift5.9から使用可能
https://github.com/apple/swift-evolution/blob/main/proposals/0395-observability.md

従来のCombineとの比較

よくあるカウンターアプリを用いてCombineとObservationの記法の違いについて見ていきます。

Combineによる状態監視

Combineでは監視したいobjectをObservableObjectプロトコルに準拠させ監視対象のプロパティに
@Publishedのプロパティラッパーをつける必要があります。

CounterCombineApp.rb
import SwiftUI

struct ContentView: View {
    @StateObject var state = ContentViewState()

    var body: some View {
        VStack {
            Text("Count: \(state.count)")
            Button {
                state.countUp()
            } label: {
                Text("Count up")
            }
        }
        .padding()
    }
}

final class ContentViewState: ObservableObject {
    @Published var count = 0

    func countUp() {
        count += 1
    }
}

こうすることで@Publishedのプロパティのcountに変化があった際にObservableObjectプロトコルに用意されているobjectWillChangeに通知がいきContentViewStateを保持しているContentViewのBodyが再実行されViewが更新されます。
余談ですがViewの更新イベントはobjectWillChangeを通して呼ばれるため下記のようなコードでもViewの更新は実現できます。

CounterCombineApp.rb
final class ContentViewState: ObservableObject {
    var count = 0 {
        willSet(newValue) {
            objectWillChange.send()
        }
    }

    func countUp() {
        count += 1
    }
}

Observationによる状態監視

ObservationではWWDC23で発表されたSwift Macroを使用して監視対象のClassに@Observableをつけるのみで同じことが実現できます。

CounterObservationApp.rb
import SwiftUI
import Observation

struct ContentView: View {
    @State var state = ContentViewState()

    var body: some View {
        VStack {
            Text("Count: \(state.count)")
            Button {
                state.countUp()
            } label: {
                Text("Count up")
            }
        }
        .padding()
    }
}

@Observable
final class ContentViewState {
    var count = 0

    func countUp() {
        count += 1
    }
}

@Observableマクロを使用しているので裏側で生成されているコードは下記のようになっています。

CounterObservationApp.rb
@Observable
final class ContentViewState {
    var count = 0
    {
        @storageRestrictions(initializes: _count )
        init(initialValue) {
            _count  = initialValue
        }

        get {
            access(keyPath: \.count )
            return _count
        }

        set {
            withMutation(keyPath: \.count ) {
                _count  = newValue
            }
        }
    }

    func countUp() {
        count += 1
    }

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

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

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

一部生成されたコードを省略していますが、監視対象のプロパティのcountの値が参照されたときにaccsess、代入された際にwithMutationが呼ばれてobservationRegistrarに通知がいきViewの更新がされているのがなんとなく伝わると思います。

また監視対処ではないプロパティには@ObservationIgnoredをつけることで監視対象から外すことができるようです。

差分

差分としてObservableObjectプロトコルに準拠させる必要や@Publishedを使用する必要がなくなりました。

- @StateObject var state = ContentViewState()
+ @State var state = ContentViewState()
- final class ContentViewState: ObservableObject {
+ @Observable
final class ContentViewState {
-  @Published var count = 0
+ var count = 0

Observationのメリット

ObservableObjectで管理したいたものが
新しい@Observableというマクロに置き換わり、書き方がシンプルになるだけかと思っていましたが
中身の処理を実際に追うと実際に内部で起きている更新でも大きく違うことがわかりました。
先ほどのカウンターアプリだと分かりづらいので下記のようなTodoリストアプリを使用して紹介します。

プロパティの個別監視による不必要なViewの再更新の制御

ObservableObjectを監視する場合、プロパティ毎の個別の変更を監視しているわけではなくObservableObjectのobjectWillChangeを通してViewの再描画の通知を行っていました。
objectWillChangeはObservableObjectのいずれかのプロパティが変更された際に発火します。
そのため、Viewの描画に関係のないプロパティが更新された場合でも、不必要に再描画が走ってしまうことがありました。

Observation では、各プロパティを個別に監視しているため、関係のない値の変更によって不要な更新が走るといったことはありません。

CombineによるViewの再描画

今までCombineで上記のようなアプリを作ろうとすると下記のように表現していたと思います。

TodoListComibineApp.rb
struct TodoListView: View {
    @StateObject var state = TodoListViewState()

    var body: some View {
        let _ = print("TodoListView body")
        NavigationView{
            ScrollView {
                ForEach(state.todoItems.indices, id: \.self) { i in
                    TodoItemView(todoItem: $state.todoItems[i])
                }
            }
            .navigationTitle("TodoList")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

struct TodoItemView: View {
    @Binding var todoItem: TodoItem

    var body: some View {
        let _ = print("TodoItemView body", todoItem.taskName)
        HStack {
            let systemName = todoItem.isDone
            ? "checkmark.circle.fill"
            : "circle"
            Button(action: {
                todoItem.updateIsDone()
            }, label: {
                Image(systemName: systemName)
                    .resizable()
                    .frame(width: 16, height: 16)
            })
            Text(todoItem.taskName)

            Spacer()
        }
    }
}

final class TodoListViewState: ObservableObject {
   @Published var todoItems: [TodoItem] = [
        .init(taskName: "aaa", isDone: false),
        .init(taskName: "bbb", isDone: false),
        .init(taskName: "ccc", isDone: false),
        .init(taskName: "ddd", isDone: false),
        .init(taskName: "eee", isDone: false),
        .init(taskName: "fff", isDone: false),
        .init(taskName: "ggg", isDone: false),
    ]
}

struct TodoItem {
    let taskName: String
    var isDone: Bool

    init(taskName: String, isDone: Bool) {
        self.taskName = taskName
        self.isDone = isDone
    }

    mutating func updateIsDone() {
        isDone.toggle()
    }
}

実際にビルドしてチェックボックスをタップするとTodoの”aaa”をタップした際に他の関係のないTodoItemViewのBodyも不必要に再実行されています。

ObservationによるViewの再描画

今までObservableObjectで管理していたStateクラスをただのクラスにして監視したプロパティを持った
TodoItemに@Observableのマクロを付与しています。

ObservationTodoApp.rb
import SwiftUI

struct TodoListView: View {
    @State var state = TodoListViewState()

    var body: some View {
        let _ = print("TodoListView body")
        NavigationView{
            ScrollView {
                ForEach(state.todoItems.indices, id: \.self) { i in
                    TodoItemView(todoItem: state.todoItems[i])
                }
            }
            .navigationTitle("TodoList")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

final class TodoListViewState {
    var todoItems: [TodoItem] = [
        .init(taskName: "aaa", isDone: false),
        .init(taskName: "bbb", isDone: false),
        .init(taskName: "ccc", isDone: false),
        .init(taskName: "ddd", isDone: false),
        .init(taskName: "eee", isDone: false),
        .init(taskName: "fff", isDone: false),
        .init(taskName: "ggg", isDone: false),
    ]
}


@Observable
class TodoItem {
    let taskName: String
    var isDone: Bool

    init(taskName: String, isDone: Bool) {
        self.taskName = taskName
        self.isDone = isDone
    }

    func updateIsDone() {
        isDone.toggle()
    }
}

struct TodoItemView: View {
    let todoItem: TodoItem

    var body: some View {
        let _ = print("TodoItemView body", todoItem.taskName)
        HStack {
            let systemName = todoItem.isDone
            ? "checkmark.circle.fill"
            : "circle"
            Button(action: {
                todoItem.updateIsDone()
            }, label: {
                Image(systemName: systemName)
                    .resizable()
                    .frame(width: 16, height: 16)
            })
            Text(todoItem.taskName)

            Spacer()
        }
    }
}

実際にビルドしてチェックボックスをタップするとTodoの”aaa”をタップした際にaaaのTodoItemを持ったTodoItemViewのBodyのみが実行されているのがわかると思います!
元のCombineを使用したアプリでも他の関係のないViewのBodyが再実行されてもSwiftUI上の仮想View上でレンダリングが走っており差分更新してるそうでこの程度のアプリではパフォーマンスは気にならないかもsれないですがObservationを使用した方がより効率的であることは確実そうです!
また今まで値型で定義していたTodoItemも@Observableを使用する際は参照型で定義する必要があり今後のSwiftUIの設計にもとてもインパクトがありそうだと感じています、

プロパティラッパーの単純化

今までSwiftUIでプロパティを監視する際は主に下記のプロパティラッパーを使い分ける必要がありました。

  • State
  • Binding
  • Environment
  • StateObject
  • ObservedObject
  • EnvironmentObject

ですがObservableObjectをやめて@Observableを使用することで主に下記三つのプロパティラッパーのみを使い分けるで済むようになったのも個人的には大きなメリットがあると感じています。

  • State
  • Binding
  • Environment

UIKitでも使用可能

まとめ

  • Observationは従来の記述よりもシンプルで処理も効率的
  • SwiftUIとの相性もよし

Discussion