SwiftUI 由什麼構成
類型: 框架解析。本文說明 SwiftUI 所立基的底層結構:result builder、不透明回傳型別,以及值型別的 view 樹。一旦看清底層結構,那些讓開發者驚訝的 SwiftUI 環節(AnyView、Group、ViewBuilder、@ViewBuilder 參數,以及那令人頭痛的 some View vs any View 錯誤)便不再神秘。
SwiftUI 的 view 是一個遵守單一協定(該協定僅有單一要求)的值型別。框架的其餘部分皆建構於 SwiftUI 之外的 Swift 語言特性:result builder、不透明型別、帶約束的泛型、屬性包裝器。若您理解這些語言特性,框架讀起來就像一般的 Swift API。若不理解,框架讀起來則像偶爾會反咬一口的魔法。
本文會逐步走過這些底層結構。這裡沒有 LiveActivityManager,也沒有 Get Bananas 的截圖。重點在於框架本身,而非某個專案;一旦框架變得清晰可讀,整個系列中每一篇實作程式碼的文章都會讀得更順。
TL;DR
- SwiftUI 的 view 是遵守
View協定的 Swift 值型別。該協定只有一項要求:var body: some View { get }。其餘一切都建構於 Swift 語言特性之上。 @ViewBuilder是一種 result builder。每個View的 body 都是一個 result builder。Result builder 透過編譯器合成的呼叫,將以逗號分隔的表達式轉換成單一回傳值。some View是不透明回傳型別。編譯器知道具體型別;呼叫端不知道。這種不透明性正是讓 view body 在編譯期與執行期都很快的關鍵;AnyView則是當不透明型別不適用時,作為型別抹除的逃生出口。Group、EmptyView、TupleView、_ConditionalContent是 result builder 合成出的實作型別。它們有官方文件,但很少需要手動撰寫。
一切的起點:那個協定
View 協定只有一項要求:1
public protocol View {
associatedtype Body : View
@ViewBuilder var body: Self.Body { get }
}
這個協定中有兩個部分對於理解 SwiftUI 的其餘部分至關重要。
關聯型別 Body : View。 一個 view 的 body 本身也是一個 view。這種遞迴正是讓框架可組合的關鍵。每個 View 都會從其 body 回傳另一個 View,依此類推,直到鏈條終止於框架的某個基本 view(例如 Text、Color、Image、EmptyView),其 Body 為 Never。基本 view 是樹的葉子;您所撰寫的 view 則是分枝。
body 上的 @ViewBuilder 屬性。 每個 body 都是一個 result-builder 閉包。Result builder 是一種 Swift 語言特性,記錄於 SE-0289(在 Swift 5.4 中以 @resultBuilder 形式正式化),它讓含有一連串表達式的閉包能透過編譯器合成的方法呼叫,被轉換為單一回傳值。2 正是這項轉換,才讓 SwiftUI body 內那種無逗號、語句形式的語法得以運作。
這個協定的形狀之所以特別,有兩個原因。
第一,要求的是計算屬性,而非方法。當 SwiftUI 判斷 view 的狀態已變更時,每次渲染都會重新計算 view 的 body。框架將 body 視為呼叫成本低廉的東西;在 body 內進行長時間運算是反模式,因為每次渲染都會執行。
第二,Self.Body 是關聯型別,並非被抹除。在編譯期,view 的具體 body 型別屬於其簽章的一部分。Text("Hello") 的 body 型別是 Never;自訂 view 的 body 型別則是 @ViewBuilder 為該 body 所合成的型別。這種關聯型別的設計,正是讓編譯器能在不需執行期型別檢查的情況下,最佳化 view 樹的關鍵。當自訂 view 回傳條件式內容時,會出現 some View 的要求,也是源於此處。
Result Builder:無逗號的 DSL
Result builder 是一種 Swift 語言特性,透過插入編譯器合成的方法呼叫,將閉包轉換為單一回傳值。@ViewBuilder 即是一種 result builder。每個 SwiftUI view 的 body 都是它的閉包。2
請看這個 view:
struct ExampleView: View {
var body: some View {
Text("Title")
Text("Subtitle")
Image(systemName: "star")
}
}
這個 body 有三個語句,彼此之間沒有分隔符。在一般的 Swift 中,這會是編譯錯誤:閉包只能回傳一個值。Result builder 會在編譯前重寫該閉包。經 @ViewBuilder 展開後,編譯器實際看到的程式碼大致如下:
struct ExampleView: View {
var body: some View {
ViewBuilder.buildBlock(
Text("Title"),
Text("Subtitle"),
Image(systemName: "star")
)
}
}
ViewBuilder.buildBlock(_:_:_:) 是一個靜態方法,接受三個 view 並回傳一個 TupleView<(Text, Text, Image)>。Body 回傳的就是這個單一的 tuple-view 值。早期的 SwiftUI 為 1、2、3…一直到 10 個子元素提供一組固定的 buildBlock 多載;目前的 SwiftUI 則使用 Swift 的可變參數泛型支援(buildBlock<each Content>),因此擁有 11 個或更多同層 view 的 body 不再是特例。
控制流程也用同樣的模式處理。一個含有 if 語句的 view body 看起來像這樣:
struct ConditionalView: View {
let isActive: Bool
var body: some View {
if isActive {
Text("Active")
} else {
Text("Inactive")
}
}
}
@ViewBuilder 透過 buildEither(first:) / buildEither(second:) 呼叫將其重寫,產生一個 _ConditionalContent<Text, Text>。即使在任何一次渲染中只有一個分支會執行,編譯器在編譯期就已知道結果型別。
if let、switch、選擇性解包以及其他幾個結構,都由 result builder 的各種 buildXxx 靜態方法處理。3 重複內容是該語言特性透過 buildArray 支援、但 @ViewBuilder 並不支援的一個值得注意的例子:在 body 內直接使用 for 迴圈會失敗,並出現 “closure containing control flow statement cannot be used with result builder ‘ViewBuilder’.” 符合 SwiftUI 風格的解法是 ForEach,它接受一個 RandomAccessCollection 與一個內容閉包,並將迭代合成為單一的值型別 view。這套 DSL 並非客製化的;它就是 Swift 的 result builder,為 view 而設定。
some View 與不透明性的問題
自訂 view 的 body 通常會回傳 some View。這個關鍵字代表不透明回傳型別,於 Swift 5.1 引入。4
some View 的意思是:「我會回傳一個遵守 View 的特定型別,但我不告訴您是哪一個。」編譯器會在內部追蹤具體型別以進行最佳化;您的 view 的呼叫端只看得到協定見證。正是這種模式,讓 view 的 body 能回傳像 VStack<TupleView<(Text, Image, Spacer)>> 這樣的複雜型別,而不必在原始碼中親自寫出該型別。
關於 some View,有兩件事讓 SwiftUI 新手感到困惑:
some View 是一個特定型別,即使您回傳的是不同的東西。 表達式 if condition { Text("A") } else { Image("b") } 在 @ViewBuilder body 內是允許的,因為 result builder 將兩個分支都包進了 _ConditionalContent,產生一個單一的具體型別。但表達式 if condition { return Text("A") } else { return Image("b") } 在 result builder 之外則是編譯錯誤:兩個分支回傳不同的具體型別,而 some View 要求只能有一個。Result builder 才是讓條件式回傳形狀得以運作的關鍵;明確的 return 語句會失去 result-builder 的轉換。
some View 不等同於 any View。 some View 是不透明的(一個特定型別,被隱藏起來);any View 是存在型別(一個能容納任何遵守者的盒子,並帶有執行期額外開銷)。自由函式或屬性可以合法回傳 any View。然而,View 協定的 body 不行:協定要求 associatedtype Body: View,而 any View 本身並不遵守 View,因此 var body: any View 無法滿足協定,編譯器會建議改用 some View。實務原則為:view body 用 some View;對於執行期會變動的 view 型別,則改用 AnyView(型別抹除的包裝器)。當您看到錯誤訊息 “function declares an opaque return type but the return statements in its body do not have matching underlying types”,幾乎都意味著您試圖從一個 some View 函式回傳不同的具體型別,而您需要的是 result-builder 的分支處理或 AnyView。
AnyView:逃生出口
AnyView 是一個型別抹除的 view 包裝器。建構方式為 AnyView(myView)。該包裝器可以容納任何遵守 view 的型別,並且 SwiftUI 會在預期 View 之處接受它。5
逃生出口的使用情境是:某個函式根據執行期資料回傳不同的具體型別,且無法透過 result-builder 的分支處理來表達:
func viewForKind(_ kind: Kind) -> AnyView {
switch kind {
case .text: return AnyView(Text("hello"))
case .image: return AnyView(Image("photo"))
case .custom: return AnyView(MyCustomView())
}
}
AnyView 的代價是:底層型別不再屬於 view 靜態身分的一部分。Apple 文件直接描述了其後果:當 AnyView 內部所包裹的型別在不同次渲染間發生變化時,現有的 view 階層會被銷毀,並在原處建立一個新的階層,這意味著狀態遺失、動畫重新開始、身分遺失。重新包裹相同的具體型別不會觸發那種銷毀,但無論如何,框架所偏好的、由靜態型別驅動的 diff 機制都已不再可用。
正確的原則是:條件式 view(if、switch、for)優先使用 @ViewBuilder 的 result-builder 分支;型別變動的情境優先使用參數化的 view;只有兩者都行不通時,才動用 AnyView。一個回傳 AnyView 的 func viewForKind,通常意味著您應該讓 viewForKind 回傳 some View,並把 switch 放進 result-builder 閉包內。
Group、EmptyView、TupleView:實作型別
Result builder 會合成特定的具體 view 型別。其中三個值得認識:6
Group 是一個透明的容器。它最多接受十個 view 作為內容,並將它們以同層的方式呈現給父層的 layout。容器本身不增加任何視覺結構;其內容的渲染結果與個別獨立呈現完全相同。其使用情境為:在預期單一 view 的情境中(如 .if 修飾器、條件式回傳、產生「單一 view」的函式)包裹多個 view。Group { Text("A"); Text("B") } 是一個包含兩個 view 的單一 view;它是 result builder 隱式所做之事的顯式形式。
EmptyView 是一個不渲染任何內容的 view。當 if 沒有 else 時,result builder 會將它作為條件式為 false 的分支。從您自己的程式碼回傳 EmptyView() 是一種在不改變函式回傳型別的前提下,選擇不渲染的方式。
TupleView 是當 body 含有多個同層 view 時,result builder 所產生的具體型別。本文開頭那個回傳三個同層 view 的表達式,實際上回傳的是 TupleView<(Text, Text, Image)>。您幾乎不會直接撰寫 TupleView;您會在錯誤訊息中讀到它。
_ConditionalContent(前面有底線)是處理 if/else 分支的型別。該型別出現在 ViewBuilder 的公開介面上,但底線開頭的命名暗示著「不要隨便對它撰寫程式碼」;請讓 result builder 從 if/else 合成它,而不要手動建構。
在自己的函式上使用 @ViewBuilder
Result builder 不僅適用於 body。任何函式或閉包參數都可以加上 @ViewBuilder 標註,於是相同的 DSL 語法便可在其中合法使用。2
struct Card<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
content
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// Usage: callers get the result-builder DSL inside the closure.
Card {
Text("Title")
Text("Subtitle")
Image(systemName: "star")
}
這個模式正是 SwiftUI 自家的 VStack、HStack、ZStack、List、Form、Section、Group 與 NavigationStack 接受多個子元素的方式。上述每一個型別都接受一個 @ViewBuilder content: () -> Content 參數。認識這個模式,意味著您可以撰寫自己的容器 view,並擁有與框架相同的人因設計,無需任何特殊的編譯器支援。
您之所以寫 init(@ViewBuilder content:) 而非僅 init(content:),是因為參數上的這個屬性,正是用來啟動呼叫端傳入的閉包 body 內 result-builder 轉換的關鍵。少了這個屬性,Card { Text("A"); Text("B") } 就會是編譯錯誤,因為閉包有兩個語句,卻沒有 @ViewBuilder 來轉換它們。
狀態、繫結,以及屬性包裝器這一層
以上內容都關乎 view 樹的形狀。SwiftUI 的另一半是狀態,而這一半建構於 Swift 的屬性包裝器之上。7
與 view 撰寫最相關的屬性包裝器:
@State 在單一 view 內部擁有一塊值型別的狀態。讀取該屬性即讀取底層儲存;指派給它則觸發 view 重新渲染。這個包裝器適用於簡單、僅限該 view 內部的狀態(一個切換按鈕的開/關狀態、一個文字欄位的草稿字串)。
@Binding 是對另一個 view 狀態的雙向參考。需要讀寫父層狀態的子 view 會接受一個 Binding<T> 參數。父層透過 $state(@State 上的錢字符投影)建構繫結。
@Observable(iOS 17+)是一個巨集,取代了較舊的 ObservableObject 遵守模式。將該巨集套用於類別時,會產生 Observation 框架的追蹤機制,使得當該類別的屬性在 body 內被讀取、之後又被變更時,會觸發 view 重新渲染。在 view 端,對 @Observable 類別的擁有方式從 @StateObject 改為單純的 @State;下游 view 若需要雙向控制,則改用 @Bindable 而非 @ObservedObject。
@Environment 從環境鏈中讀取以依賴注入方式提供的值。SwiftUI 提供了內建的環境鍵(locale、color scheme、dismiss action);應用程式可加入自訂鍵以實現特定領域的依賴注入。
屬性包裝器這一層,正是讓 view 的 body 在狀態變更時得以重新執行的關鍵。SwiftUI 透過兩種不同的機制追蹤 body 內部的讀取:較舊的屬性包裝器路徑使用 AttributeGraph(Apple 私有的依賴圖,支援 @State、@Binding 與 @Environment),而 @Observable 型別則使用標準函式庫的 Observation 框架(withObservationTracking,於 iOS 17+ 公開)。8 當被追蹤的屬性發生變動時,相應的 body 會重新執行,diff 機制則計算出 view 樹最小的變更。
這兩半(view 樹層與狀態層)是鬆散耦合的。View 樹是值型別,重新計算速度很快。狀態層則是參考型別(對 @Observable 而言)或帶儲存指標的值型別(對 @State 而言),並追蹤讀取行為。兩者結合,便產生了該框架「將畫面上應該呈現的內容描述為狀態的函式,框架自會計算 diff」的模型。
您現在能在錯誤訊息中辨識出什麼
當底層結構變得清晰後,閱讀 SwiftUI 編譯器錯誤訊息時:
“Function declares an opaque return type, but the return statements in its body do not have matching underlying types.” 在一個 some View 函式中,兩個 return 語句帶有不同的具體型別。修正方式:使用 @ViewBuilder 讓 result builder 將兩者包進 _ConditionalContent,或將兩個 return 都包進 AnyView。
“The compiler is unable to type-check this expression in reasonable time.” 帶有大量修飾器的長 body 鏈耗盡了型別檢查器。修正方式:將 body 拆分為較小的計算屬性或子 view;每個回傳 some View 的部分都會簡化推論工作。
“Cannot convert value of type ‘TupleView<…>’ to expected type ‘some View’.” 一個預期收到單一 view 的函式,收到了沒有 @ViewBuilder 的多語句 body 結果。修正方式:在接受多語句內容的閉包參數上加上 @ViewBuilder。
“Generic parameter ‘Content’ could not be inferred.” 自訂容器接受 @ViewBuilder content: () -> Content,但呼叫端傳入了空閉包。修正方式:result builder 至少需要一個表達式才能推論出 Content;若呼叫端明確提供,空閉包會回退為 EmptyView()。
這些錯誤訊息之所以不友善,是因為底層結構是看不見的。當底層結構變得清晰後再閱讀它們,大部分都會變成「啊,原來 result builder 無法轉換這個」或「啊,我需要分支處理或 AnyView」。
何時該跳出底層結構
有少數模式是底層結構無法乾淨處理的:
可變的具體型別。 一個函式若要為每個分支回傳不同的 View 型別,且無法包入 result-builder 的分支處理,就需要 AnyView。請接受其代價(失去 diff、無動畫),並在呼叫處留下文件說明。
跨平台條件式 view。 編譯期的 #if os(iOS) 在 @ViewBuilder body 內可運作,但會限制 result builder 的分支數量;多 OS 條件式的 body 有時會撞上「expression too complex」的限制。修正方式是將各平台的子 view 抽取成獨立的函式,每個函式回傳 some View。
指令式的 view 建構。 框架預期 view 是表達式,而非「先建立、再變更」的物件。UIKit 風格的「建立 label、設定文字、加入到 subview」無法直接轉譯;SwiftUI 的對應做法是從 body 回傳一個值型別的 Text("...")。需要指令式建構的模式,通常意味著該工作應該歸屬於通往 UIKit 的 UIViewRepresentable 橋接器。
這個模式對於在 iOS 26+ 出貨的 App 意味著什麼
三個重點。
-
SwiftUI 是 Swift,而非魔法。 Result builder、不透明回傳型別與屬性包裝器,都記載於 Swift 語言參考。將框架作為 Swift 程式碼閱讀,而非當成特殊 DSL,那些令人意外的環節就會變得可預測。
-
some View與AnyView解決的是不同的問題。 不透明回傳型別是預設選擇;型別抹除是逃生出口。動用AnyView應該是罕見的情況;動用some View加上 result-builder 分支才應該是常態。 -
Result builder 就是整套 DSL。 任何函式或參數只要標上
@ViewBuilder,無逗號、語句形式的語法就可使用。撰寫您自己的容器 view,使其擁有與VStack相同的人因設計,只需要一個屬性與一個閉包參數。
請將本文與本系列實作程式碼的文章一併閱讀:跨平台的 SwiftUI 出貨(Return 以單一共享的 SwiftUI 核心,運行於五個平台上)、Liquid Glass 視覺層、iOS 上的 Live Activities 狀態機,以及 Apple Watch 上的 watchOS 執行期 契約。系列入口在 Apple 生態系列。若要了解更廣泛的 iOS 與 AI 代理結合脈絡,請參閱 iOS 代理開發指南。
常見問題
SwiftUI 的 View 協定是什麼?
View 協定只有一項要求:var body: some View { get }。每個 SwiftUI view 都是一個遵守 View 的 Swift 值型別,並擁有一個 body 計算屬性,回傳另一個 view(或 Never,對 Text、Color、Image、EmptyView 等基本 view 而言)。Body 上會標註 @ViewBuilder,使其能使用 SwiftUI 那種無逗號的 DSL 語法。
some View 是什麼意思?
some View 是不透明回傳型別(Swift 5.1+)。編譯器知道具體型別;呼叫端只看得到協定見證。不透明型別讓 view body 能回傳像 VStack<TupleView<(Text, Image, Spacer)>> 這樣的複雜型別而無需明確寫出,同時保留編譯期的最佳化。some View 是一個特定型別,儘管在呼叫端看不見該型別。
何時該使用 AnyView?
只有當 @ViewBuilder 的 result-builder 分支處理(if、switch、for)與參數化泛型都無法解決問題時,才使用 AnyView。當所包裹的具體型別在不同次渲染間發生變化時,現有的 view 階層會被銷毀,並在原處建立一個新的階層;那一刻,動畫會重新開始,view 狀態會被重設。重新包裹相同的具體型別不會觸發那種銷毀,但無論如何,框架偏好的、由靜態型別驅動的 diff 機制已不再可用。如果您發現自己經常動用 AnyView,需要改變的模式其實在上游:請改用參數化的 view,或將條件式推進 result-builder 的 body。
@ViewBuilder 是什麼,可以用在哪裡?
@ViewBuilder 是一種 result builder(Swift 語言特性)。它透過插入編譯器合成的 buildBlock、buildEither、buildOptional 等呼叫,將含有多個表達式的閉包轉換為單一回傳值。每個 SwiftUI view 的 body 預設都是 @ViewBuilder。您可以將 @ViewBuilder 套用在任何函式或閉包參數上,讓呼叫端享有相同的 DSL 語法;VStack、Card 與 Section 都採用同樣的模式來接受多個子元素。
為什麼 view 的 body 在我沒預期到的情況下重新渲染?
SwiftUI 會在 body 所讀取的任何狀態屬性發生變動時,重新執行 body。屬性包裝器(@State、@Binding、@Observable、@Environment)會追蹤讀取,並在寫入時觸發重新渲染。意料之外的重新渲染,通常可追溯到父層 view 的狀態變更、環境值變更,或是某個 @Observable 物件被讀取的屬性遭到修改。框架的 diff 機制隨後會計算出 view 樹最小的變更。
參考資料
-
Apple Developer,「View」與「Configuring views」。
View協定、Body關聯型別,以及body上的@ViewBuilder屬性。 ↩ -
Swift Evolution,「SE-0289: Result builders」。將 result builder 正式化的語言提案(於 5.1 以
_functionBuilder形式引入,於 5.4 以@resultBuilder形式正式化)。定義了buildBlock、buildEither、buildOptional、buildArray、buildExpression、buildFinalResult等。 ↩↩↩ -
Apple Developer,「ViewBuilder」與「ForEach」。SwiftUI 用於 view body 的 result-builder 型別(可變參數泛型
buildBlock、buildEither、選擇性解包)。ViewBuilder並未公開buildArray,因此ForEach是用於對集合重複建立 view 的迭代基本工具。 ↩ -
Swift Evolution,「SE-0244: Opaque result types」。用於不透明回傳型別的
some關鍵字,於 Swift 5.1 加入。 ↩ -
Apple Developer,「AnyView」。型別抹除的 view 包裝器、建構方式,以及 diff 上的取捨。 ↩
-
Apple Developer,「Group」、「EmptyView」與「TupleView」。Result builder 所合成的實作型別。 ↩
-
Apple Developer,「State and Data Flow」。屬性包裝器這一層:
@State、@Binding、@Observable、@Environment。SwiftUI 的觀察系統與 iOS 17+ 的@Observable巨集。 ↩ -
Apple Developer,「Observation」與「Migrating from the Observable Object protocol to the Observable macro」。標準函式庫的 Observation 框架,包含
withObservationTracking(_:onChange:),以及從ObservableObject遷移至@Observable的 iOS 17 路徑。 ↩