Communitygithub.com

SwiftUI 渲染指南

解釋 SwiftUI 如何決定重新渲染內容——包含識別、生命週期和依賴項,並指導如何撰寫高效更新的視圖,特別適用於 SwiftUI 嵌入 UIKit Cell (UIHostingConfiguration) 和從串流或生成內容重建的視圖。

SwiftUI 渲染指南 是什麼?

此技能深入探討 SwiftUI 的渲染機制,幫助開發者理解為何視圖會重新渲染、遺失狀態、閃爍或過度更新。它基於 WWDC21 和 WWDC23 的「Demystify SwiftUI」講座以及實務驗證,提供了一套明確的規則和檢查清單。內容涵蓋識別 (Identity)、生命週期 (Lifetime) 和依賴項 (Dependencies) 三大核心概念,並針對 ForEach 的穩定 ID、避免 AnyView、依賴範圍最小化、以及 UIHostingConfiguration 在 UIKit Cell 中的正確使用提供了具體指導。此技能支援所有使用 SwiftUI 的 Apple 平台 (iOS, macOS, watchOS, tvOS),特別適合用於檢視效能、除錯佈局變更或最佳化串流內容渲染的開發者。文件中包含一個詳細的審查檢查表和驗證方法,例如使用 `Self._printChanges()` 和 Instruments,協助開發者確保其 SwiftUI 程式碼符合高效渲染的最佳實務。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/gtokman/skills/tree/HEAD/swiftui-rendering

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

SwiftUI 渲染

SwiftUI 會檢視你程式碼中的三件事:識別 (identity)(這是否為與之前相同的元素?)、生命週期 (lifetime)(該識別持續多久——以及隨之而來的 @State)、以及依賴項 (dependencies)(哪些輸入在變更時會導致 body 再次執行)。幾乎所有「為什麼會重新渲染 / 遺失狀態 / 動畫異常 / 卡頓」的問題,都與其中一項有關。

模型

  • 視圖值(View values)是短暫的;識別則不是。 視圖結構體(view struct)被建立,與先前的值比較,然後被丟棄。持續存在的是它的識別,而 @State / @StateObject 的儲存空間與該識別綁定:當識別變更時,狀態會被取代,onAppear/task 會再次執行,圖片會重新載入。
  • 兩種識別(Two kinds of identity)。 明確(Explicit):ForEach 的 ID、Identifiable、.id(_:)。結構性(Structural):視圖在層級結構中的型別和位置——if/switch 的每個分支都是一個不同的識別。
  • 依賴圖(The dependency graph)。 視圖的依賴項來源是其儲存屬性(來自父視圖的輸入)、@State、@Environment、@Binding,以及其 body 所讀取的 @Observable 屬性。當其中一項變更時,只有依賴它的視圖會獲得新的 body。SwiftUI 接著將新的子視圖值與舊值進行比較,並在沒有任何變更的地方停止。
  • 無效化視圖的更新順序:新值 → 動態屬性更新 → body 執行 → 子視圖比較 → 遞迴。

規則

識別 (Identity)

  1. 絕不使用索引作為識別 (ForEach(Array(x.enumerated()), id: \.offset), 0..<count 搭配動態資料)。任何在索引之前插入的內容都會使後續索引位移:它們的狀態會重置、圖片會重新載入、動畫會跑到錯誤的位置。請使用穩定且唯一的 ID。
  2. ID 必須是穩定且唯一的。 不要使用計算屬性中的 UUID()(每次渲染都會產生新識別)。名稱不能重複(例如兩個 "Dog biscuit" 零食)。如果資料無法保證唯一性,請對重複項進行唯一化處理(例如 key, key#2)——ForEach 中的重複 ID 會導致異常行為。
  3. 對於生成或串流內容,應從不會移動的結構中推導識別:元素在原始文字中的起始位置(串流只會附加內容),或對於程式輸出的範本位置 + 每次迭代的鍵值(React 的模型:範本:鍵值)。為未鍵值的項目(鬆散文字、段落)以它們前面的鍵值元素加一個計數器來命名(例如 e812+2),這樣結尾的新內容就不會重新命名先前的項目。
  4. 優先使用帶有變更修飾詞(modifier)的單一視圖,而不是使用分支。 if expired { v.opacity(0.3) } else { v } 是兩種識別;v.opacity(expired ? 0.3 : 1) 則是一種。"惰性" 值(不透明度 1、間距 0、無尺寸的 frame、layoutPriority(0)、transformEnvironment)幾乎沒有成本。僅當確實需要不同視圖時才使用分支。
  5. 使用 AnyLayout 切換佈局,而不是 if wrap { FlowLayout { … } } else { HStack { … } }——這樣子視圖可以保持其識別和狀態。
  6. 避免使用 AnyView。 它會對 SwiftUI 隱藏型別結構(導致更差的差異比較,並且在 List/Table 中會強制建立每一行來計數)。請改用 @ViewBuilder 函數、switch、泛型。
  7. 將元素種類建模為列舉(enum)並在調度視圖中對其進行切換,不要使用 default:這樣新的案例就不會在被忽略。

依賴項 (Dependencies)

  1. 只傳遞視圖實際使用的內容。 一個接收整個模型的視圖會在任一欄位變更時重新執行 body;請傳遞圖片,而不是整個 Dog 物件。提取子視圖來限定依賴範圍——要有判斷力,不要每一行程式碼都這麼做。
  2. @Observable 會追蹤 body 讀取了什麼。 在動作閉包 (action closure) 中讀取屬性不會建立依賴關係;在 body 中讀取則會。將視圖顯示的狀態放在模型中,並且只在需要的地方於 body 中讀取。
  3. 絕對不要在每次渲染時將新的閉包(或其他不可比較的值)放入環境中——例如在 body 中的 .environment(\.actions, Actions { … })。因為它無法被比較,所以每當父視圖更新時,所有讀取者都會被無效化。請改為將一個穩定的物件放入環境中(例如使用 @Observable 的 .environment(model),並以 @Environment(Model.self) var model: Model? 讀取),然後從動作中呼叫它。
  4. 沒有變更時不要通知。 @Observable 屬性會在每次設定時發出通知。請加上保護條件:if new != value { value = new }——這在每次區塊或 tick 時重建衍生的狀態時很重要。
  5. 使用 Equatable 視圖來跳過未變更的子樹。 讓視圖遵從 Equatable(僅當它沒有屬性包裝器時可能辦到)並在父視圖經常重新渲染且子視圖大多不變時(例如串流內容時的一系列區塊)套用 .equatable()。撰寫一個自訂的 == 來僅比較視圖實際顯示的內容。 ⚠️ .equatable() 會對其容器隱藏子視圖的 layoutPriority。 在 HStack/行內,一個包裝在 EquatableView 中且帶有 layoutPriority(1) 的子視圖將無法再爭取剩餘空間,導致行的佈局不同。請在垂直的區塊列表中使用 .equatable(),但不要用於依賴優先級的行/堆疊的子視圖上。

高效的 body

  1. 不要在 body 中進行解析、解碼、過濾、排序或大量分配的工作。 body 會在每次無效化時執行。請在模型中預先計算、使用快取(例如以輸入為鍵值的 NSCache),或將其提升到輸入實際上會變更的視圖中進行。
  2. 將昂貴的輸入與快速變化的狀態分開。 如果一個視圖需要解碼資料同時又持有頻繁變化的 @State(例如拖曳、滾輪滑動、懸停),那麼每次變化時解碼都會重新執行。請在父視圖中解碼,然後將結果傳遞給擁有 @State 的子視圖。
  3. 慢速工作應放在 .task 中,而不是 init 或 body。 使其成為 async;切勿在動態屬性的初始值中阻塞執行緒。
  4. 快取成本較高的格式化操作——例如 AttributedString(markdown:)、熱點路徑上的字串插值、Bundle 查找。

Lists

  1. List/Table 會預先收集所有 ID。 保持 ID 生成的成本低廉,並且為每個 ForEach 元素提供固定數量的視圖——不要在行閉包內使用 if 過濾或 AnyView(請在模型中過濾資料,而不是在 body 中內聯處理)。巢狀 ForEach 用於區段(sections)是沒問題的。

SwiftUI 在 UIKit Cells (UIHostingConfiguration) 中的應用

  • Cell 會被重用,SwiftUI 視圖狀態可能會帶入該 Cell 顯示的下一個項目。請為根視圖賦予 .id(item.id),以便不同的項目能獲得一個全新的視圖樹——並將使用者可見的狀態(已勾選、已展開、選取狀態)存放在模型中並以穩定 ID 作為鍵值,而不是放在 @State 中,否則狀態會在重用和滾動時丟失或誤用。
  • 重新配置 Cell 會重建根視圖值。 如果內容由可觀察模型 (observed model) 驅動,請使根視圖在其顯示的內容上遵從 Equatable(而不是在僅用於餵養模型的原始輸入上),並套用 .equatable(),這樣每次更新時的重新配置就不會重新執行整個視圖樹——實際的更新是由觀察機制完成的。
  • 自適應大小(Self-sizing)會測量開放高度。 沒有理想大小的視圖在這種情況下會縮小為零:例如 Canvas、形狀、GeometryReader。.aspectRatio(r, contentMode: .fit) 是不夠的——請使用一個小型自訂 Layout 來給予它們一個尺寸(寬度來自建議值,高度 = 寬度 / 比例;忽略探測高度)。
  • .aspectRatio(nil, contentMode:) 會將視圖鎖定在其內容的理想大小——導致卡片無法填滿所在行。請只在有比例時才套用 .aspectRatio。

審查檢查清單

  • 每個 ForEach 都有穩定且唯一的 ID——沒有 \.offset,沒有計算出的 UUID()。
  • 沒有 AnyView;僅在確實需要不同視圖時才使用分支;使用 AnyLayout 進行佈局切換。
  • 視圖只接收它們要渲染的資料。
  • 環境中沒有在每次渲染時建立的不可比較物件。
  • body 不做任何解析/解碼/過濾;昂貴的輸入與快速的 @State 已被分開。
  • 可觀察屬性不會被重新設定為相等的值。
  • 在能節省工作的地方使用了 .equatable()——並且沒有用在使用了 layoutPriority 的子視圖上。
  • 託管的 Cell:根視圖使用 .id(item.id),可見狀態放在模型中,根視圖是 Equatable。
  • 沒有理想大小的視圖在放入自適應大小的 Cell 之前已被賦予尺寸。

驗證

  • 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 和遠端圖片無法離螢幕渲染;比較其他所有內容。)透過一次切換一個已變更的檔案來二分法查找差異的來源。

參考資料

  • references/case-study.md — AddToChatMenu 回覆渲染器(串流、模型生成的標記語言,放在 collection view cell 中):根據這些規則找到的每個問題及其修復方法。
  • WWDC21 Demystify SwiftUI (session 10022);WWDC23 Demystify SwiftUI performance (session 10160)。

Individual skills in this repo

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

相關技能