SwiftUIとObservationフレームワークによる@State活用再整理—class/actorを安全に保持する
SwiftUIとObservationフレームワークによる@State活用再整理—class/actorを安全に保持する
SwiftUI で状態を扱う場合、「@State
と @StateObject
のどちらを使うべきか」という議論がよく起きます。iOS 17 で Observation フレームワーク が加わったことで、選択肢と考慮点が変化しました。本記事では、従来の @State
/@StateObject
の役割を再確認しつつ、Observation を導入したときに class/actor を安全に保持するパターンを整理します。
1. 従来の状態管理をおさらいします
@State
の基本
1‑1 @State
は View 内に値型のストレージを確保し、再描画をまたいで値を保持する仕組みです。初期化クロージャは 初回のみ実行 され、以降は同じ値が再利用されます(公式ドキュメント を参照してください)。
値を書き換えると、@State
が所有する 値全体 が置き換わり、それをトリガーとして View が更新されます。
@StateObject
の基本
1‑2 @StateObject
は ObservableObject
準拠クラスのインスタンスを 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 の解説記事 も参考になります)。
@State
の新しい位置づけ
2‑2 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 つの実装パターン
@State
で直接保持します
3‑1 同期生成なら @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()
を呼び出し、以降は同じインスタンスを保持します。
この方法は最もシンプルで、同期生成・軽量な初期化に適しています。
.task
を併用します
3‑2 非同期生成なら @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 と連動させたい場合は、次のいずれかのパターンが考えられます。
-
ラッパークラス方式
actor
を内部プロパティとして保持する@Observable
クラスを用意し、外部はそのクラスを@State
で保持します。UI はラッパーのプロパティを観測し、ラッパーがactor
からデータを取得してプロパティを更新します。
@Observable @MainActor
class MessageViewModel {
private let service = MessageServiceActor() // actor はスレッドセーフ
var messages: [String] = []
func reload() async {
messages = await service.fetch()
}
}
-
@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 つの戦略があります:-
ラッパークラス方式 …
actor
を内部で保持する@Observable
クラスを作成し、UI はそのクラスを観測します。 -
@State
で actor を直接保持 …actor
自体は UI と疎結合に保ち、結果を@MainActor
でプロパティに反映して UI を更新します。
-
ラッパークラス方式 …
- 既存コードが
@StateObject
を使っていても慌てて置き換える必要はありませんが、iOS 17 以上のみをターゲットにする新規機能では Observation +@State
を第一候補にすると良いです。
Observation で「保持と通知」を分離して考えると、SwiftUI の状態管理はさらにシンプルになります。ぜひプロジェクトで試してみてください。
Discussion