SwiftUIの状態管理とデータフロー
画面が増えても状態のバグを追わずに済む。SwiftUIの4つの状態プロパティラッパーと単方向データフローの原理を、diffingの仕組みから正確に理解できる。
- 1.@State/@Binding/@ObservedObject/@EnvironmentObjectは所有者・共有範囲・生存期間で役割が分かれ、取り違えるとビューが再生成されるたびに状態が消える。
- 2.SwiftUIのViewは値型の宣言的な設計図であり、実DOM相当のツリーとの差分計算(diffing)が変化した部分だけを再描画する。
- 3.データは親から子へ一方向に流れ、子からの変更はBindingや通知を介して親の単一の真実の源(Source of Truth)に集約される。
宣言的UIが解いている問題
命令的UIプログラミングでは、状態が変わるたびに「どのビューをどう更新するか」を開発者が手続きとして書きます。更新漏れや二重更新はここから生まれます。SwiftUIはこの手続きを排除し、任意の時点の状態からビュー全体を再計算する関数として画面を定義します。開発者は「今の状態ならUIはこう見える」という宣言だけを書き、実際に何を再描画するかはフレームワークがdiffingで決めます。この設計を支えているのが、値型のView、状態のプロパティラッパー群、そして単方向データフローという3本柱です。
Viewは値型であるという前提
SwiftUIのViewプロトコルに準拠する型は構造体(struct)です。ボタンひとつ、リストひとつがそれぞれ軽量な値であり、参照ではなくコピーで受け渡されます。これが持つ意味は大きく、Viewそのものは状態を保持できません。構造体のプロパティは再生成のたびに初期値へ戻るため、ボタンを押した回数のような状態をView自身の格納プロパティとして持たせても、再描画のたびに消えてしまいます。
そこでSwiftUIは、Viewの外側(フレームワークが管理する場所)に状態を退避させる仕組みとしてプロパティラッパーを用意しました。@Stateなどの属性が付いた値は、View構造体が再生成されても同じ実体を参照し続けます。つまり見た目は構造体のプロパティですが、実体はSwiftUIランタイムが管理する外部ストレージへのハンドルです。
4つのプロパティラッパーの役割分担
| ラッパー | 所有者 | 対象の型 | 生存期間・共有範囲 |
|---|---|---|---|
| @State | 宣言したView自身 | 値型(struct/enum) | そのView階層内に閉じる。ローカルなUI状態専用 |
| @Binding | 参照元のプロパティへの導線 | 任意(実体はどこか別の@State等) | 所有権を持たず、親の状態への双方向の参照を子に渡す |
| @ObservedObject | 外部から注入されるクラスインスタンス | 参照型(ObservableObject準拠) | Viewは監視するだけで生成・破棄の責任を持たない |
| @EnvironmentObject | 祖先Viewが環境に注入したインスタンス | 参照型(ObservableObject準拠) | View階層のどこからでも暗黙的に取得。受け渡しの配線が不要 |
@Stateは「このViewだけが知っていればよい」ローカル状態のためのものです。トグルのON/OFFやテキストフィールドの入力途中の文字列がその典型で、値型を保持し、変更されるとそのViewとその子孫の再計算をトリガーします。
@Bindingはデータを所有せず、親の@Stateなど別の場所にある値への参照付きの導線です。子Viewが$isOnのようにドル記号でBindingを受け取り書き換えると、実際には親の状態が書き換わり、親からの再レンダリングが子に伝播します。子は「自分が状態を持っている」かのように振る舞えますが、単一の真実の源はあくまで親にあります。
@ObservedObjectはクラス(参照型)をViewに接続するためのラッパーで、対象はObservableObjectプロトコルに準拠し、変更を@Publishedプロパティで通知します。重要なのは、@ObservedObjectはインスタンスの所有者ではないという点です。Viewが再生成されるたびに同じインスタンスを外部から再注入してもらう前提であり、もしView内で@ObservedObject var model = Model()のように初期化してしまうと、親の再描画のたびにモデルが作り直され、保持していたはずの状態が失われます。インスタンスの生成・保持を担うのは@StateObjectで、これは@Stateのクラス版として、View階層の生存期間を通じて同じインスタンスを保証します。
@EnvironmentObjectは@ObservedObjectと似た監視の仕組みを持ちますが、受け渡し方が異なります。祖先Viewが.environmentObject(model)で環境に注入すると、その配下のどのViewも、初期化パラメータとして明示的に渡されることなく@EnvironmentObjectで取得できます。画面数十階層にわたって同じ設定情報やログイン中ユーザーを配るような場面で、各階層に逐一プロパティを引き回す手間を省きます。ただし注入し忘れると実行時にクラッシュするため、コンパイル時の安全性はBindingより弱くなります。
@ObservedObjectを使うべき場所に誤って新規インスタンス生成を書くと、親の再描画のたびに状態がリセットされるバグになります。「このViewがインスタンスの生成・破棄に責任を持つか」で@StateObject(所有)と@ObservedObject(監視のみ、注入される)を切り分けるのが原則です。
iOS 17以降では、ObservableObjectプロトコルと@Published/@ObservedObject/@StateObjectの組み合わせに代わり、@Observableマクロ(Observation framework)が使えます。クラスに@Observableを付けるだけでプロパティ変更の通知が自動生成され、Viewが実際に読み取ったプロパティが変化したときだけ再評価される点が特徴で、オブジェクト全体の変更単位でしか判定できなかったObservableObjectより無駄な再描画が減ります。所有するViewは@StateObjectではなく@Stateで保持し、単に参照して渡すだけの子Viewはプロパティラッパーなしで受け取れます(暗黙的に監視対象になる)。ただし@EnvironmentObjectの代替は@Environment+@Observable型の組み合わせになるなど互換ではないAPIもあり、既存コードとの混在期には両方の仕組みを理解しておく必要があります。
diffing:値の比較から再描画範囲を決める
SwiftUIの内部では、Viewのbodyが返す構造体ツリーを**軽量な記述(View Tree)**として保持し、状態が変化するたびに新しいツリーを計算します。新旧のツリーは型と位置(同じ場所に同じ型のViewがあるか)で突き合わされ、一致すればそのViewは「同一の存在」として扱われ、状態やアニメーションの連続性が保たれたまま子孫の評価に進みます。ここで注意したいのは、通常のView(struct)は既定ではEquatableではないため、bodyの再評価そのものは値が変わっていなくても親の再評価に連動して起こり得るという点です。実際に「値が変化した部分だけ」に絞り込まれるのは、bodyが生成した末端の描画情報(文字列やレイアウト値など)を比較する、より低レベルの描画エンジンの段階です。この比較コストを明示的に減らしたい場合は、ViewをEquatableに準拠させてEquatableViewとして扱う、あるいは@Observableマクロ(Observation framework)のように参照したプロパティ単位で変更を追跡する仕組みを使うことで、bodyの再評価自体を回避できます。
ForEachやListのような可変長のコレクションでは、この照合に識別子(id)が必要です。Identifiable準拠の型やキーパスで安定したIDを与えないと、要素の並び替えや挿入時にSwiftUIが「どの要素とどの要素が対応するか」を誤り、不要な再生成やアニメーションの破綻を招きます。差分計算は配列のインデックスではなくIDで要素の同一性を判定するため、IDが実行のたびに変わる実装(配列のインデックスをそのままIDに流用するなど)は典型的な不具合の原因になります。
状態変化はbodyの再評価(差分計算のための新しいツリー生成)を必ず引き起こしますが、これは画面への再描画そのものとは別工程です。diffingの結果、値に変化がないと判定された部分は、実際のレイアウト計算・描画パスまでは到達しません。「bodyが呼ばれる回数が多い=重い」と早合点せず、実際に描画コストが発生しているかは計測で確認する必要があります。
単方向データフロー
SwiftUIのデータは、親から子へ値渡しで流れ、子から親へは直接書き換えではなくBindingや通知を介してしか戻りません。状態の書き込み口は常にひとつの場所(単一の真実の源)にまとめ、それを読む側は末端のViewまで純粋な射影として流れていく、という一方向の循環を作ります。
状態 (State / StateObject)
│ 値として渡す
▼
子View(表示に専念、Bindingで書き戻し口だけ受け取る)
│ ユーザー操作で $binding.wrappedValue = 新しい値
▼
親の状態が更新される
│ 変更が通知される(Combineのpublisher経由)
▼
SwiftUIが影響範囲を再計算し、diffingで再描画箇所を決定
│
└─→ 状態 (State / StateObject) に戻る
この規律により、「どこかのViewが勝手に別のViewの内部状態を書き換える」というUIKitのdelegateパターンで起きがちな副作用の混線を避けられます。状態の変更経路が一箇所に集約されているため、バグの発生源を「その状態を所有している場所」まで機械的に絞り込めます。
UIKitとの共存パターン
既存のUIKitベースの資産をSwiftUIから使う、またはその逆の要求は実務で頻繁に発生します。ブリッジには2種類のプロトコルが用意されています。
| 方向 | プロトコル | 役割 |
|---|---|---|
| UIKit部品をSwiftUIで使う | UIViewRepresentable | UIViewのインスタンスを生成・更新する2つのメソッドをSwiftUI側に提供する |
| UIKit画面部品をSwiftUIで使う | UIViewControllerRepresentable | UIViewControllerのライフサイクルをSwiftUIの再描画に橋渡しする |
| SwiftUI画面をUIKitに埋め込む | UIHostingController | SwiftUIのView階層をUIViewControllerとしてラップし、既存のUIKitのナビゲーションスタックに載せる |
UIViewRepresentableの実装では、makeUIViewでUIKit側のインスタンスを一度だけ生成し、updateUIViewでSwiftUI側の状態変化のたびに呼ばれてUIKit側へ値を反映します。ここでの注意点は、updateUIViewはこのRepresentableの入力値が実際に変わったときだけ呼ばれるとは限らないということです。前述の通り通常のViewは値の変化を厳密には比較しないため、祖先の状態変化で親のbodyが再評価されれば、Representableへの入力が見た目上同じでもupdateUIViewは呼ばれ得ます。したがってupdateUIViewの中身は「毎回呼ばれても副作用なく同じ結果になる」ように冪等に書き、UIKit側の状態はSwiftUI側の@StateやBindingとして表現し、UIKit側のdelegateコールバックはBindingへの書き戻しやCombineのpublisherを介してSwiftUI側の単方向フローに合流させるのが定石です。UIKitのdelegateパターンをそのままViewの中に持ち込むと、単方向データフローの原則が崩れ、どちらが真実の状態かが曖昧になります。
「@ObservedObjectと@StateObjectの違い」は所有権(生成・保持の責任がどちらにあるか)で説明します。「@Bindingと@EnvironmentObjectの使い分け」は、親子関係が明示的で配線したい場合はBinding、階層を跨いで暗黙的に配りたい場合はEnvironmentObjectと整理できます。diffingについては「型と位置の一致で同一性を保ちつつ、実際の描画反映は末端の値比較で絞り込む」という二段構えの仕組みと、IDが不安定だとForEachで誤った対応付けが起きる点を押さえておくと説明に厚みが出ます。
まとめ
SwiftUIの状態管理は、Viewを値型の使い捨てな設計図として扱い、状態だけを外部ストレージに退避させることで成立しています。@Stateはローカル所有、@Bindingは所有権のない参照、@ObservedObjectは外部から注入される監視、@EnvironmentObjectは階層を跨いだ暗黙の共有と、役割は所有権と共有範囲で明確に切り分けられます。差分計算は型・位置の一致で同一性を判定しつつ、実際の再描画範囲は末端の値比較で絞り込み、コレクションでは安定したIDが同一性判定の鍵を握ります。そしてデータは常に親から子へ流れ、書き戻しはBindingや通知を介して単一の真実の源に集約される単方向データフローが、UIKitとの共存も含めた設計全体を貫く原則です。
モバイル開発 Article
SwiftUIの状態管理とデータフローを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
SwiftUI
比較で見る軸
難易度: advanced / カテゴリ: モバイル開発 / タグ数: 6
導入後に効く点
SwiftUIのViewは値型の宣言的な設計図であり、実DOM相当のツリーとの差分計算(diffing)が変化した部分だけを再描画する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- モバイル開発
- タグ数
- 6
判断チェックリスト
- 自社の用途が「SwiftUI / iOS」に近いか確認する。
- 強みである「@State/@Binding/@ObservedObject/@EnvironmentObjectは所有者・共有範囲・生存期間で役割が分かれ、取り違えるとビューが再生成されるたびに状態が消える。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。