SwiftUI レンダリング は何をしますか?
SwiftUI はコード内の3つの要素をチェックします:同一性(これは以前と同じ要素か?)、ライフタイム(その同一性がどの程度持続するか、そしてそれに伴う @State)、依存関係(どの入力が変更されたときに body が再実行されるか)。"なぜ再レンダリングされたのか / 状態が失われたのか / アニメーションがおかしいのか / ひっかかりが生じたのか" という質問のほとんどは、これらのいずれかに該当します。
モデル
- ビューの値は一時的なものです。永続的なのは同一性です。 ビュー構造体は作成され、以前の値と比較され、破棄されます。永続化されるのはその同一性であり、
@State/@StateObjectのストレージはその同一性に結びついています。同一性が変わると、状態は置き換えられ、onAppear/taskが再実行され、画像も再読み込みされます。 - 2種類の同一性があります。 明示的:
ForEachのID、Identifiable、.id(_:)。構造的: ビューの型と階層内での位置 —if/switchの各ブランチは異なる同一性を持ちます。 - 依存関係グラフ。 ビューの依存関係は、その格納プロパティ(親からの入力)、
@State、@Environment、@Binding、およびbodyが読み取る@Observableプロパティです。いずれかが変更されると、それに依存するビューのみが新しいbodyを取得します。SwiftUI は新しい子の値を古いものと比較し、変更がない場合はそこで処理を停止します。 - 無効化されたビューの更新順序:新しい値 → 動的プロパティの更新 →
bodyの実行 → 子の比較 → 再帰。
ルール
同一性
- インデックスで識別しないでください(
ForEach(Array(x.enumerated()), id: \.offset)、動的データに対する0..<count)。何かが上に挿入されると、後続のすべてのインデックスがずれます:状態がリセットされ、画像が再読み込みされ、アニメーションが間違った場所に移動します。安定した一意のIDを使用してください。 - ID は安定していて一意でなければなりません。 計算された
idでのUUID()は避けてください(レンダリングのたびに新しい同一性になります)。繰り返し可能な名前(2つの "Dog biscuit" トリートなど)も避けてください。データが保証できない場合は、重複を一意にしてください(key、key#2)。ForEach内の重複IDは誤動作を引き起こします。 - 生成またはストリーミングされたコンテンツの場合、移動しない構造から同一性を導き出します:ソーステキスト内での要素の開始位置(ストリーミングは追加のみ)、またはプログラム出力のテンプレート位置 + イテレーションごとのキー(React のモデル:
template:key)。キーなしのアイテム(ルーズテキスト、段落)には、その前のキー付き要素にカウンターを加えた名前を付けます(e812+2)。これにより、末尾の新しいコンテンツが以前のアイテムの名前を変更することはありません。 - 変更する修飾子を持つ1つのビューを、ブランチよりも優先します。
if expired { v.opacity(0.3) } else { v }は2つの同一性です。v.opacity(expired ? 0.3 : 1)は1つです。「不活性」な値(opacity 1、padding 0、サイズなしのframe、layoutPriority(0)、transformEnvironment)はコストがほとんどかかりません。真に異なるビューの場合にのみブランチを使用してください。 - レイアウトの切り替えには
AnyLayoutを使用してください。if wrap { FlowLayout { … } } else { HStack { … } }とはせずに — 子は同一性と状態を維持します。 AnyViewを避けてください。 SwiftUI から型構造が隠蔽されます(差分処理が悪化し、List/Tableでは行をカウントするためにすべての行を構築する必要が生じます)。@ViewBuilder関数、switch、ジェネリクスを使用してください。- モデル要素の種類を enum で表現し、それを switch で分岐させる ビューでは、
defaultを使用しないでください。新しいケースが黙って未処理のままになるのを防げます。
依存関係
- ビューには、使用するものだけを渡してください。 モデル全体を受け取るビューは、どのフィールドが変更されても
bodyを再実行します。犬ではなく画像を渡してください。依存関係をスコープするためにサブビューを抽出します — ただし、すべての行に対してではなく、適切に判断して行ってください。 @Observableはbodyが何を読み取るかを追跡します。 アクションクロージャ内でプロパティを読み取っても依存関係は作成されません。body内で読み取ると作成されます。ビューが表示する状態をモデルに配置し、必要な場所のbodyでのみ読み取ってください。- レンダリングごとに新しいクロージャ(または他の比較不可能な値)を環境に絶対に配置しないでください — 例えば、
body内の.environment(\.actions, Actions { … })など。比較できないため、すべての読み取り元が親の更新のたびに無効化されます。代わりに、安定したオブジェクトを環境に配置し(.environment(model)と@Observable、@Environment(Model.self) var model: Model?として読み取り)、アクションからそれを呼び出してください。 - 何も変更されていないときに通知しないでください。
@Observableプロパティは、設定のたびに通知します。ガードしてください:if new != value { value = new }— これは、すべてのチャンクやティックで派生状態を再構築する場合に重要です。 Equatableビューを使用して、変更されていないサブツリーをスキップします。 ビューを準拠させ(プロパティラッパーがない場合のみ可能)、.equatable()を適用します。これは、親が頻繁に再レンダリングされ、子がほとんど変更されない場合(コンテンツがストリーミングされるブロックのリストなど)に有効です。ビューが表示するものだけを比較するカスタム==を記述してください。 ⚠️.equatable()は、子のlayoutPriorityをそのコンテナから隠します。HStack/ 行の中で、EquatableViewでラップされたlayoutPriority(1)の子は、余分なスペースを獲得できなくなり、行のレイアウトが変わります。.equatable()はブロックの垂直リストで使用し、優先度に依存する行/スタックの子には使用しないでください。
軽量な body
body内で解析、デコード、フィルタリング、ソート、または割り当ての多い処理を行わないでください。 無効化のたびに実行されます。モデルで事前計算するか、キャッシュするか(入力でキー付けされたNSCache)、または実際に入力が変更されるビューに処理を移動させてください。- 高コストな入力と、頻繁に変更される状態を分割してください。 ビューがデータをデコードし、さらに頻繁に変更される
@State(ドラッグ、スクラブ、ホバー)を保持している場合、変更のたびにデコードが再実行されます。親でデコードし、結果を@Stateを所有する子に渡してください。 - 遅い処理は
initやbodyではなく.taskに配置してください。asyncにし、動的プロパティの初期値でブロックしないでください。 - コストのかかるフォーマットをキャッシュしてください —
AttributedString(markdown:)、ホットパス上の文字列補間、バンドルルックアップなど。
リスト
List/Tableは、すべてのIDを事前に収集します。 ID生成は軽量に保ち、各ForEach要素には一定数のビューを割り当ててください — 行クロージャ内にifフィルタやAnyViewを入れないでください(データはモデル内でフィルタリングし、body内でインラインで行わないでください)。セクションのためのネストされたForEachは問題ありません。
UIKit セル内の SwiftUI(UIHostingConfiguration)
- セルは再利用され、SwiftUI のビューステートがそのセルに次に表示されるアイテムに引き継がれる可能性があります。ルートに
.id(item.id)を付与して、異なるアイテムが新しいツリーを取得するようにします。また、ユーザーから見える状態(チェック済み、展開済み、選択)は、@Stateではなく、安定したIDでキー付けされたモデルに保持してください。そうしないと、再利用やスクロール時に状態が失われたり、誤って適用されたりします。 - セルを再構成すると、ルートビューの値が再構築されます。 コンテンツが監視対象のモデルによって駆動されている場合は、ルートビューを表示内容(モデルにのみ供給される生の入力ではない)に対して
Equatableにし、.equatable()を適用します。これにより、更新ごとの再構成がツリー全体を再実行するのを防ぎます — 実際の更新は監視によって行われます。 - 自己サイズ調整は、オープンな高さで測定します。 理想的なサイズがないビューは、そこでゼロに潰れます:
Canvas、シェイプ、GeometryReader。.aspectRatio(r, contentMode: .fit)だけでは不十分です — 小さなカスタムLayoutでサイズを与えてください(幅は提案から、高さ = 幅 / 比率。高さのプロービングは無視します)。 .aspectRatio(nil, contentMode:)は、ビューをコンテンツの理想的なサイズに固定します — カードが行を埋めなくなります。比率がある場合にのみ.aspectRatioを適用してください。
レビューチェックリスト
- すべての
ForEachに安定した一意のIDがある —\.offsetや計算されたUUID()は使用しない。 -
AnyViewがない。ブランチは真に異なるビューのみ。レイアウトの切り替えにはAnyLayout。 - ビューはレンダリングに必要なデータのみを受け取る。
- レンダリングごとに環境に比較不可能なものが作成されていない。
-
bodyは解析/デコード/フィルタリングを行わない。高コストな入力は高速な@Stateから分離されている。 - Observable プロパティが等しい値に再設定されていない。
-
.equatable()を作業を節約できる場所で使用している — そしてlayoutPriorityを使用する子では使用していない。 - ホストされたセル:ルート
.id(item.id)、可視状態はモデル内、Equatable なルート。 - 理想的なサイズがないビューには、自己サイズ調整セルに入る前にサイズが与えられている。
検証
let _ = Self._printChanges()をbodyの先頭に置くと、実行理由がログに出力されます(@self、@identity、または変更されたプロパティ)。デバッグ専用 — 出荷前に削除してください。- Instruments → SwiftUI テンプレート は、body のカウントや遅い更新を確認するのに役立ちます。Hangs/Hitches はその影響を確認するのに役立ちます。
- 同一性テスト:ストリーミングコンテンツのすべてのプレフィックスをレンダリングし、完了したすべてのアイテムのIDが最終レンダリングでも変更されていないことをアサートします。インタラクションの後、生存している要素がIDを保持していることをアサートします。重複する兄弟IDがないことをアサートします。
- レンダリングリファクタリングの前後でのピクセル差分:macOS 上で
ImageRendererを使用してオフスクリーンでビューをレンダリングし(同じコード、.frame(width:)、高さが nil のproposedSize)、PNG を比較します。これにより、EquatableView/layoutPriorityのようなレイアウトのリグレッションを検出できます。(Map、ScrollView、リモート画像はオフスクリーンではレンダリングされません。その他すべてを比較してください)。変更されたファイルを一度に1つずつ切り替えることで、差異を二分探索できます。
参考資料
references/case-study.md— AddToChatMenu 返信レンダラー(ストリーミング、モデル生成されたマークアップをコレクションビューセル内で表示):これらのルールに照らして見つかった各問題とその修正。- WWDC21 Demystify SwiftUI(セッション 10022);WWDC23 Demystify SwiftUI performance(セッション 10160)。