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)
- 絕不使用索引作為識別 (
ForEach(Array(x.enumerated()), id: \.offset),0..<count搭配動態資料)。任何在索引之前插入的內容都會使後續索引位移:它們的狀態會重置、圖片會重新載入、動畫會跑到錯誤的位置。請使用穩定且唯一的 ID。 - ID 必須是穩定且唯一的。 不要使用計算屬性中的
UUID()(每次渲染都會產生新識別)。名稱不能重複(例如兩個 "Dog biscuit" 零食)。如果資料無法保證唯一性,請對重複項進行唯一化處理(例如key,key#2)——ForEach中的重複 ID 會導致異常行為。 - 對於生成或串流內容,應從不會移動的結構中推導識別:元素在原始文字中的起始位置(串流只會附加內容),或對於程式輸出的範本位置 + 每次迭代的鍵值(React 的模型:
範本:鍵值)。為未鍵值的項目(鬆散文字、段落)以它們前面的鍵值元素加一個計數器來命名(例如e812+2),這樣結尾的新內容就不會重新命名先前的項目。 - 優先使用帶有變更修飾詞(modifier)的單一視圖,而不是使用分支。
if expired { v.opacity(0.3) } else { v }是兩種識別;v.opacity(expired ? 0.3 : 1)則是一種。"惰性" 值(不透明度 1、間距 0、無尺寸的frame、layoutPriority(0)、transformEnvironment)幾乎沒有成本。僅當確實需要不同視圖時才使用分支。 - 使用
AnyLayout切換佈局,而不是if wrap { FlowLayout { … } } else { HStack { … } }——這樣子視圖可以保持其識別和狀態。 - 避免使用
AnyView。 它會對 SwiftUI 隱藏型別結構(導致更差的差異比較,並且在List/Table中會強制建立每一行來計數)。請改用@ViewBuilder函數、switch、泛型。 - 將元素種類建模為列舉(enum)並在調度視圖中對其進行切換,不要使用
default:這樣新的案例就不會在被忽略。
依賴項 (Dependencies)
- 只傳遞視圖實際使用的內容。 一個接收整個模型的視圖會在任一欄位變更時重新執行
body;請傳遞圖片,而不是整個Dog物件。提取子視圖來限定依賴範圍——要有判斷力,不要每一行程式碼都這麼做。 @Observable會追蹤body讀取了什麼。 在動作閉包 (action closure) 中讀取屬性不會建立依賴關係;在body中讀取則會。將視圖顯示的狀態放在模型中,並且只在需要的地方於body中讀取。- 絕對不要在每次渲染時將新的閉包(或其他不可比較的值)放入環境中——例如在
body中的.environment(\.actions, Actions { … })。因為它無法被比較,所以每當父視圖更新時,所有讀取者都會被無效化。請改為將一個穩定的物件放入環境中(例如使用@Observable的.environment(model),並以@Environment(Model.self) var model: Model?讀取),然後從動作中呼叫它。 - 沒有變更時不要通知。
@Observable屬性會在每次設定時發出通知。請加上保護條件:if new != value { value = new }——這在每次區塊或 tick 時重建衍生的狀態時很重要。 - 使用
Equatable視圖來跳過未變更的子樹。 讓視圖遵從Equatable(僅當它沒有屬性包裝器時可能辦到)並在父視圖經常重新渲染且子視圖大多不變時(例如串流內容時的一系列區塊)套用.equatable()。撰寫一個自訂的==來僅比較視圖實際顯示的內容。 ⚠️.equatable()會對其容器隱藏子視圖的layoutPriority。 在HStack/行內,一個包裝在EquatableView中且帶有layoutPriority(1)的子視圖將無法再爭取剩餘空間,導致行的佈局不同。請在垂直的區塊列表中使用.equatable(),但不要用於依賴優先級的行/堆疊的子視圖上。
高效的 body
- 不要在
body中進行解析、解碼、過濾、排序或大量分配的工作。body會在每次無效化時執行。請在模型中預先計算、使用快取(例如以輸入為鍵值的NSCache),或將其提升到輸入實際上會變更的視圖中進行。 - 將昂貴的輸入與快速變化的狀態分開。 如果一個視圖需要解碼資料同時又持有頻繁變化的
@State(例如拖曳、滾輪滑動、懸停),那麼每次變化時解碼都會重新執行。請在父視圖中解碼,然後將結果傳遞給擁有@State的子視圖。 - 慢速工作應放在
.task中,而不是init或body。 使其成為async;切勿在動態屬性的初始值中阻塞執行緒。 - 快取成本較高的格式化操作——例如
AttributedString(markdown:)、熱點路徑上的字串插值、Bundle 查找。
Lists
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)。