Communitygithub.com

SwiftUI レンダリング

SwiftUI が何を再レンダリングするかを決定する仕組み(同一性、ライフタイム、依存関係)と、効率的に更新されるビューの書き方、特に UIKit セル内でホストされた SwiftUI(UIHostingConfiguration)や、ストリーミング / 生成コンテンツから再構築されるビューのパフォーマンス最適化について解説します。

SwiftUI レンダリング とは?

このスキルは、SwiftUI のレンダリングにおける3つの核となる概念「同一性」「ライフタイム」「依存関係」を深く理解し、それを応用して効率的なビューを記述するための包括的なガイドです。特に、`UIHostingConfiguration` を用いて UIKit のセル内で SwiftUI をホストする場合や、ストリーミングや生成されたコンテンツから動的にビューを構築する際のパフォーマンス問題に対処します。具体的なルールとして、ForEach での安定したIDの使用、AnyView の回避、body 内での重い処理の禁止、Observable プロパティの適切な管理など、実践的なアドバイスが豊富に含まれています。また、`_printChanges()` や Instruments を用いたパフォーマンス検証方法、オフスクリーンレンダリングによるリグレッション検出手法も紹介されており、デベロッパーが実際に直面する「なぜ再レンダリングされるのか」「なぜ状態が失われるのか」といった疑問に答えます。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/gtokman/skills/tree/HEAD/swiftui-rendering

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

SwiftUI レンダリング は何をしますか?

SwiftUI はコード内の3つの要素をチェックします:同一性(これは以前と同じ要素か?)、ライフタイム(その同一性がどの程度持続するか、そしてそれに伴う @State)、依存関係(どの入力が変更されたときに body が再実行されるか)。"なぜ再レンダリングされたのか / 状態が失われたのか / アニメーションがおかしいのか / ひっかかりが生じたのか" という質問のほとんどは、これらのいずれかに該当します。

モデル

  • ビューの値は一時的なものです。永続的なのは同一性です。 ビュー構造体は作成され、以前の値と比較され、破棄されます。永続化されるのはその同一性であり、@State / @StateObject のストレージはその同一性に結びついています。同一性が変わると、状態は置き換えられ、onAppear / task が再実行され、画像も再読み込みされます。
  • 2種類の同一性があります。 明示的: ForEach のID、Identifiable、.id(_:)。構造的: ビューの型と階層内での位置 — if / switch の各ブランチは異なる同一性を持ちます。
  • 依存関係グラフ。 ビューの依存関係は、その格納プロパティ(親からの入力)、@State、@Environment、@Binding、および body が読み取る @Observable プロパティです。いずれかが変更されると、それに依存するビューのみが新しい body を取得します。SwiftUI は新しい子の値を古いものと比較し、変更がない場合はそこで処理を停止します。
  • 無効化されたビューの更新順序:新しい値 → 動的プロパティの更新 → body の実行 → 子の比較 → 再帰。

ルール

同一性

  1. インデックスで識別しないでください(ForEach(Array(x.enumerated()), id: \.offset)、動的データに対する 0..<count)。何かが上に挿入されると、後続のすべてのインデックスがずれます:状態がリセットされ、画像が再読み込みされ、アニメーションが間違った場所に移動します。安定した一意のIDを使用してください。
  2. ID は安定していて一意でなければなりません。 計算された id での UUID() は避けてください(レンダリングのたびに新しい同一性になります)。繰り返し可能な名前(2つの "Dog biscuit" トリートなど)も避けてください。データが保証できない場合は、重複を一意にしてください(key、key#2)。ForEach 内の重複IDは誤動作を引き起こします。
  3. 生成またはストリーミングされたコンテンツの場合、移動しない構造から同一性を導き出します:ソーステキスト内での要素の開始位置(ストリーミングは追加のみ)、またはプログラム出力のテンプレート位置 + イテレーションごとのキー(React のモデル:template:key)。キーなしのアイテム(ルーズテキスト、段落)には、その前のキー付き要素にカウンターを加えた名前を付けます(e812+2)。これにより、末尾の新しいコンテンツが以前のアイテムの名前を変更することはありません。
  4. 変更する修飾子を持つ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)はコストがほとんどかかりません。真に異なるビューの場合にのみブランチを使用してください。
  5. レイアウトの切り替えには AnyLayout を使用してください。 if wrap { FlowLayout { … } } else { HStack { … } } とはせずに — 子は同一性と状態を維持します。
  6. AnyView を避けてください。 SwiftUI から型構造が隠蔽されます(差分処理が悪化し、List / Table では行をカウントするためにすべての行を構築する必要が生じます)。@ViewBuilder 関数、switch、ジェネリクスを使用してください。
  7. モデル要素の種類を enum で表現し、それを switch で分岐させる ビューでは、default を使用しないでください。新しいケースが黙って未処理のままになるのを防げます。

依存関係

  1. ビューには、使用するものだけを渡してください。 モデル全体を受け取るビューは、どのフィールドが変更されても body を再実行します。犬ではなく画像を渡してください。依存関係をスコープするためにサブビューを抽出します — ただし、すべての行に対してではなく、適切に判断して行ってください。
  2. @Observable は body が何を読み取るかを追跡します。 アクションクロージャ内でプロパティを読み取っても依存関係は作成されません。body 内で読み取ると作成されます。ビューが表示する状態をモデルに配置し、必要な場所の body でのみ読み取ってください。
  3. レンダリングごとに新しいクロージャ(または他の比較不可能な値)を環境に絶対に配置しないでください — 例えば、body 内の .environment(\.actions, Actions { … }) など。比較できないため、すべての読み取り元が親の更新のたびに無効化されます。代わりに、安定したオブジェクトを環境に配置し(.environment(model) と @Observable、@Environment(Model.self) var model: Model? として読み取り)、アクションからそれを呼び出してください。
  4. 何も変更されていないときに通知しないでください。 @Observable プロパティは、設定のたびに通知します。ガードしてください:if new != value { value = new } — これは、すべてのチャンクやティックで派生状態を再構築する場合に重要です。
  5. Equatable ビューを使用して、変更されていないサブツリーをスキップします。 ビューを準拠させ(プロパティラッパーがない場合のみ可能)、.equatable() を適用します。これは、親が頻繁に再レンダリングされ、子がほとんど変更されない場合(コンテンツがストリーミングされるブロックのリストなど)に有効です。ビューが表示するものだけを比較するカスタム == を記述してください。 ⚠️ .equatable() は、子の layoutPriority をそのコンテナから隠します。 HStack / 行の中で、EquatableView でラップされた layoutPriority(1) の子は、余分なスペースを獲得できなくなり、行のレイアウトが変わります。.equatable() はブロックの垂直リストで使用し、優先度に依存する行/スタックの子には使用しないでください。

軽量な body

  1. body 内で解析、デコード、フィルタリング、ソート、または割り当ての多い処理を行わないでください。 無効化のたびに実行されます。モデルで事前計算するか、キャッシュするか(入力でキー付けされた NSCache)、または実際に入力が変更されるビューに処理を移動させてください。
  2. 高コストな入力と、頻繁に変更される状態を分割してください。 ビューがデータをデコードし、さらに頻繁に変更される @State(ドラッグ、スクラブ、ホバー)を保持している場合、変更のたびにデコードが再実行されます。親でデコードし、結果を @State を所有する子に渡してください。
  3. 遅い処理は init や body ではなく .task に配置してください。 async にし、動的プロパティの初期値でブロックしないでください。
  4. コストのかかるフォーマットをキャッシュしてください — AttributedString(markdown:)、ホットパス上の文字列補間、バンドルルックアップなど。

リスト

  1. 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)。

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

関連スキル