@StateObject vs @ObservedObject — 使い分けの基準
SwiftUI で ObservableObject を扱う際、@StateObject と @ObservedObject という2つのプロパティラッパーが登場する。どちらもビューに変更を通知する役割を持つが、インスタンスの所有権とライフサイクルに決定的な違いがある。この違いを理解しないまま使うと、ビューの再描画時にデータが消えるという厄介なバグを招く。
根本的な違い:所有するか、借りるか
@StateObject はインスタンスを「所有」し、@ObservedObject はインスタンスを「借りる」。所有とは、そのビューがオブジェクトの生成と破棄に責任を持つということだ。借りるとは、外部から渡されたオブジェクトを参照するだけで、ライフサイクルの管理には関与しないことを意味する。
ビューが初めて描画されるときに一度だけインスタンスを生成し、ビューが破棄されるまで保持する。親ビューの再描画でも再生成されない。
インスタンスの生成・管理は行わない。親ビューが再描画されるたびに、渡されたインスタンスをそのまま受け取る。
@StateObject の挙動
@StateObject で宣言したプロパティは、ビューの body が何度呼ばれても同じインスタンスが維持される。SwiftUI が内部的にインスタンスをストレージに保持しているためだ。
struct ParentView: View {
var body: some View {
ChildView()
}
}
struct ChildView: View {
@StateObject private var model = CounterModel()
var body: some View {
Text("\(model.count)")
Button("increment") {
model.increment()
}
}
}ParentView が何らかの理由で再描画されても、ChildView 内の model は再生成されない。カウンターの値はそのまま保たれる。
@ObservedObject で起きる問題
もし上のコードで @StateObject の代わりに @ObservedObject を使ったらどうなるか。
struct ChildView: View {
@ObservedObject private var model = CounterModel()
var body: some View {
Text("\(model.count)")
Button("increment") {
model.increment()
}
}
}一見動くように見えるが、ParentView が再描画されるたびに ChildView も再構築される。@ObservedObject はインスタンスを保持しないため、CounterModel() が毎回新しく生成され、カウンターの値が 0 に戻ってしまう。これが @ObservedObject を誤用したときに起きる典型的なバグだ。
正しい使い分けのルール
使い分けの基準は明確で、「そのビューがインスタンスを生成する側か、受け取る側か」で決まる。
ビュー内で直接インスタンスを作る場合は @StateObject を使う。init 内で生成する場合も同様。そのビューが「オーナー」になる。
親ビューや外部から渡されたインスタンスを参照するだけなら @ObservedObject を使う。所有権は渡す側にある。
実際のコードでこのパターンを見てみよう。
struct ParentView: View {
@StateObject private var model = CounterModel()
var body: some View {
VStack {
Text("parent: \(model.count)")
ChildView(model: model)
}
}
}
struct ChildView: View {
@ObservedObject var model: CounterModel
var body: some View {
Button("increment from child") {
model.increment()
}
}
}ParentView が @StateObject でインスタンスを所有し、ChildView は @ObservedObject でそれを受け取る。ChildView からの変更は ParentView にも反映され、ParentView が再描画されても model は @StateObject によって保持される。
iOS 14 以前との互換性
@StateObject は iOS 14 で導入された。それ以前は @ObservedObject しか存在せず、インスタンスの再生成問題を避けるために親ビューで生成して渡すパターンが必須だった。
ObservableObject を監視する手段は @ObservedObject だけだった。ビュー内でのインスタンス生成は再描画時に消失するリスクがあった。
インスタンスの所有権を明示できるようになり、ビュー内でのオブジェクト生成が安全になった。
現在のプロジェクトで iOS 14 以降をターゲットにしているなら、オブジェクトを生成する場所では必ず @StateObject を使うべきだ。@ObservedObject でのインスタンス生成は、意図しない再初期化の原因になる。
init でのカスタム初期化
@StateObject を init 内で初期化したい場合は、_model に直接 StateObject(wrappedValue:) を渡す。
struct ProfileView: View {
@StateObject private var model: ProfileModel
init(userId: String) {
_model = StateObject(wrappedValue: ProfileModel(userId: userId))
}
var body: some View {
Text(model.name)
}
}この書き方でも @StateObject の恩恵は受けられる。初回の描画時にのみ StateObject のクロージャが評価され、以降の再描画では同じインスタンスが再利用される。
判断に迷ったら
迷ったときは「このオブジェクトは誰が作ったのか」を考えればよい。自分で作ったなら @StateObject、誰かからもらったなら @ObservedObject。この原則を守るだけで、データ消失のバグはほぼ防げる。