🌱

SwiftUIとObservationフレームワークによる@State活用再整理—class/actorを安全に保持する

に公開

SwiftUIとObservationフレームワークによる@State活用再整理—class/actorを安全に保持する

SwiftUI で状態を扱う場合、「@State@StateObject のどちらを使うべきか」という議論がよく起きます。iOS 17 で Observation フレームワーク が加わったことで、選択肢と考慮点が変化しました。本記事では、従来の @State@StateObject の役割を再確認しつつ、Observation を導入したときに class/actor を安全に保持するパターンを整理します。

1. 従来の状態管理をおさらいします

1‑1 @State の基本

@State は View 内に値型のストレージを確保し、再描画をまたいで値を保持する仕組みです。初期化クロージャは 初回のみ実行 され、以降は同じ値が再利用されます(公式ドキュメント を参照してください)。
値を書き換えると、@State が所有する 値全体 が置き換わり、それをトリガーとして View が更新されます。

1‑2 @StateObject の基本

@StateObjectObservableObject 準拠クラスのインスタンスを View が所有するときに使います。内部で @Published プロパティが変わると objectWillChange が発火し、View が再描画されます(詳しくは StateObject ドキュメント を参照してください)。
初期化は @State と同じく初回のみですが、参照型かつ変更通知を持つ という点で @State と役割が異なります。

ラッパー 対象 再描画トリガー 初期化タイミング
@State 値型/軽量参照型 プロパティ全体の置換 View 初回生成時のみ
@StateObject ObservableObject クラス objectWillChange View 初回生成時のみ

2. Observation フレームワークがもたらす変化

2‑1 Observation の概要

Observation フレームワークは @Observable マクロで型を観測可能にし、プロパティ単位で変更を追跡します(Observation ドキュメント を参照してください)。
プロパティへの アクセス を自動で記録し、変更が起きたときに そのプロパティを使用している View だけ を再描画します。詳しくは WWDC23 のセッション「Discover Observation in SwiftUI」や「What’s New in SwiftUI」が参考になります。
Observation は Combine に依存せず、ビルド時にコードを生成するため、オーバーヘッドが抑えられる仕組みになっています(Better Programming の解説記事 も参考になります)。

2‑2 @State の新しい位置づけ

Observation を導入すると、再描画のトリガーは @State ではなく Observation 自身 が担います。@State は「View の再生成をまたいでインスタンスを保持する」ためだけに使います。
そのため、参照型でも @State で保持して問題ありません という結論になります。詳しくは objc.io の記事「Swift Observation: Access Tracking」が参考になります。

import Observation
import SwiftUI

@Observable class CounterModel {
    var count = 0       // <- 変更を自動検知します
}

struct CounterView: View {
    @State private var model = CounterModel() // インスタンスは 1 度きりです

    var body: some View {
        VStack {
            Text("Count: \(model.count)")     // プロパティアクセスを Observation が追跡します
            Button("Up") { model.count += 1 }  // count プロパティを使用している部分のみ再描画されます
        }
    }
}

3. @Observable クラスを保持する 3 つの実装パターン

3‑1 同期生成なら @State で直接保持します

@Observable class MessageService {
    var messages: [String] = ["Hello"]
}

struct ChatView: View {
    @State private var service = MessageService()

    var body: some View {
        List(service.messages, id: \.self) { Text($0) }
    }
}

@State は初回のみ MessageService() を呼び出し、以降は同じインスタンスを保持します。
この方法は最もシンプルで、同期生成・軽量な初期化に適しています。

3‑2 非同期生成なら .task を併用します

@Observable class MessageService {
    var messages: [String] = []
    func load() async {
        // ネットワークからメッセージ取得
        messages = ["Hello", "Hi", "Bonjour"]
    }
}

struct ChatView: View {
    @State private var service: MessageService?

    var body: some View {
        List(service?.messages ?? [], id: \.self) { Text($0) }
            .task {
                if service == nil {
                    let svc = MessageService()
                    await svc.load()
                    service = svc
                }
            }
    }
}

.task は View が表示されたタイミングで非同期タスクを走らせ、View が消えると自動キャンセルします(task 修飾子のドキュメント を参照してください)。
重いネットワーク初期化やキャンセルが必要な処理に最適です。実例は Swift with Majid の記事「The Power of task View Modifier in SwiftUI」がわかりやすいです。

3‑3 Environment による注入

アプリ全体で共有したい場合は App のエントリーポイントでサービスクラスを生成し、@Environment で下位 View に渡します(Environment のドキュメント を参照してください)。
これにより、View 層の依存を明示的に注入でき、テストもしやすくなります。

@Observable class AppStateService {
    var currentUser: User?
    var isLoggedIn: Bool { currentUser != nil }
}

@main
struct MyApp: App {
    @State private var appState = AppStateService()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
        }
    }
}

struct ContentView: View {
    @Environment(AppStateService.self) private var appState
    
    var body: some View {
        if appState.isLoggedIn {
            Text("Welcome, \(appState.currentUser?.name ?? "")")
        } else {
            Text("Please log in")
        }
    }
}

4. actor を使いたい場合の補足

actor はスレッド安全性を言語レベルで保証する並行プログラミング構造ですが、現状では @Observable マクロを付与できません。UI と連動させたい場合は、次のいずれかのパターンが考えられます。

  1. ラッパークラス方式
    actor を内部プロパティとして保持する @Observable クラスを用意し、外部はそのクラスを @State で保持します。UI はラッパーのプロパティを観測し、ラッパーが actor からデータを取得してプロパティを更新します。
@Observable @MainActor
class MessageViewModel {
    private let service = MessageServiceActor() // actor  はスレッドセーフ
    var messages: [String] = []

    func reload() async {
        messages = await service.fetch()
    }
}
  1. @State で actor を直接保持
    UI と密結合しないバックグラウンド処理に actor を使い、結果を @State で保持した別プロパティへコピーします。この場合、UI 更新はコピー先プロパティを通じて行います。
actor MessageServiceActor {
    private var messages: [String] = []
    
    func fetch() async -> [String] {
        // 非同期でメッセージを取得
        return messages
    }
}

struct ChatView: View {
    @State private var service = MessageServiceActor()
    @State private var messages: [String] = []
    
    var body: some View {
        List(messages, id: \.self) { Text($0) }
            .task {
                messages = await service.fetch()
            }
    }
}

いずれの場合も 保持メカニズムとしては @State を使う 点はクラスと共通です。ただし actor は常に非同期アクセスとなるため、UI 更新は @MainActor コンテキストで行うよう注意してください。

5. まとめ

  • Observation フレームワークの導入により、@State保持、Observation は 変更検知 を担当する責務分担が明確になりました。
  • @Observable を付与した参照型を一度だけ生成したい場合、@State に直接保持する方法がシンプルで有効です。
  • 重い初期化やキャンセル制御が必要な場合は .task を併用し、アプリ全体で共有する場合は @Environment で注入します。
  • actor を使いたいときは 2 つの戦略があります:
    1. ラッパークラス方式actor を内部で保持する @Observable クラスを作成し、UI はそのクラスを観測します。
    2. @State で actor を直接保持actor 自体は UI と疎結合に保ち、結果を @MainActor でプロパティに反映して UI を更新します。
  • 既存コードが @StateObject を使っていても慌てて置き換える必要はありませんが、iOS 17 以上のみをターゲットにする新規機能では Observation + @State を第一候補にすると良いです。

Observation で「保持と通知」を分離して考えると、SwiftUI の状態管理はさらにシンプルになります。ぜひプロジェクトで試してみてください。

GitHubで編集を提案

Discussion