← 所有文章

SwiftUI 的構成元素

SwiftUI 建構於三項 Swift 語言特性之上:result builders(結果建構器)、opaque return types(不透明回傳型別)以及值型別的 view 樹。一旦看清這些底層基礎,那些讓開發者感到困惑的 SwiftUI 部分(AnyViewGroupViewBuilder@ViewBuilder 參數,以及令人頭痛的 some View vs any View 錯誤)便不再神祕。

SwiftUI view 是符合單一協定、僅有單一要求的值型別。框架的其餘部分都建構在 SwiftUI 之外的 Swift 語言特性上:result builders、opaque types、帶約束的泛型、property wrappers。如果您理解這些語言特性,整個框架讀起來就像一般的 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 builders 透過編譯器合成的呼叫,將以逗號分隔的表達式轉化為單一回傳值。
  • some View 是不透明回傳型別。編譯器知道具體型別;呼叫端不知道。這個不透明型別正是讓 view body 在編譯與執行時都能保持高效的關鍵;AnyView 則是不透明性無法運作時的型別抹除逃生口。
  • GroupEmptyViewTupleView_ConditionalContent 是 result builders 合成的實作型別。它們有公開文件,但很少由人手動撰寫。

一切的起點:那個協定

View 協定只有一項要求:1

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}

這個協定中有兩個部分對於理解 SwiftUI 的其餘部分至關重要。

關聯型別 Body : View view 的 body 本身也是一個 view。這種遞迴正是讓框架可組合的原因。每個 View 的 body 都會回傳另一個 View,依此類推,直到鏈條終結於框架的某個 primitive view(基礎 view,例如 TextColorImageEmptyView),其 BodyNever。Primitive views 是樹的葉節點;您撰寫的 views 則是分支。

body 上的 @ViewBuilder 屬性。 每個 body 都是一個 result-builder closure。Result builders 是 SE-0289 中規範的 Swift 語言特性(在 Swift 5.4 中以 @resultBuilder 正式化),讓包含一系列表達式的 closure 能由編譯器透過合成的方法呼叫,轉換為單一回傳值。2 這項轉換正是讓 SwiftUI body 內部那種無逗號、語句式的語法得以運作的原因。

這個協定的形狀有兩點不尋常之處。

第一,要求是計算屬性,而非方法。當 SwiftUI 判定 view 的 state 已變更時,會在每次渲染傳遞中重新計算 view 的 body。框架將 body 視為呼叫成本低廉的屬性;在 body 中執行長時間計算是反模式,因為它會在每次渲染時執行。

第二,Self.Body 是關聯的,而非抹除的。view 的具體 body 型別在編譯期就是其簽章的一部分。Text("Hello") 的 body 型別是 Never;自訂 view 的 body 型別則是 @ViewBuilder 為該 body 合成出來的型別。這種關聯型別設計,讓編譯器無需執行期型別檢查就能最佳化 view 樹。同時也是當自訂 view 回傳條件式內容時,會出現 some View 要求的原因。

Result Builders:無逗號的 DSL

Result builder 是 Swift 語言特性,透過插入編譯器合成的方法呼叫,將 closure 轉換為單一回傳值。@ViewBuilder 就是一個 result builder。每個 SwiftUI view 的 body 都是它的 closure。2

考慮以下這個 view:

struct ExampleView: View {
    var body: some View {
        Text("Title")
        Text("Subtitle")
        Image(systemName: "star")
    }
}

這個 body 有三個語句,沒有任何分隔符號。在一般的 Swift 中,這是編譯錯誤:closure 只能回傳一個值。Result builders 在編譯前重寫 closure。經過 @ViewBuilder 展開後,編譯器實際看到的程式碼大致如下:

struct ExampleView: View {
    var body: some View {
        ViewBuilder.buildBlock(
            Text("Title"),
            Text("Subtitle"),
            Image(systemName: "star")
        )
    }
}

ViewBuilder.buildBlock(_:_:_:) 是一個靜態方法,接收三個 views 並回傳 TupleView<(Text, Text, Image)>。Body 回傳的就是這單一的 tuple-view 值。較舊的 SwiftUI 為 1、2、3、…直到 10 個子元素提供固定的 buildBlock 多載;目前的 SwiftUI 採用 Swift 的 variadic-generics 支援(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 letswitch、optional 解開以及其他幾種建構,都由 result builder 的各種 buildXxx 靜態方法處理。3 重複內容是該語言特性透過 buildArray 支援、但 @ViewBuilder 不支援的一個顯著案例:在 body 中直接寫 for 迴圈會失敗並出現錯誤訊息 「closure containing control flow statement cannot be used with result builder ‘ViewBuilder’.」。SwiftUI 風格的解法是 ForEach,它接收一個 RandomAccessCollection 與內容 closure,並將迭代合成為單一的值型別 view。這個 DSL 並非客製化的東西;它就是 Swift 的 result builders,為 view 量身設定。

some View 與不透明性問題

自訂 view 的 body 通常回傳 some View。這個關鍵字是 opaque return type(不透明回傳型別),於 Swift 5.1 加入。4

some View 表示:「我回傳一個符合 View 的特定型別,但我不告訴您是哪一個。」編譯器內部追蹤具體型別以進行最佳化;您的 view 的呼叫端只看見協定的 witness。這個模式讓 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 builders 才是讓條件式回傳形狀運作的關鍵;明確的 return 會喪失 result-builder 轉換。

some Viewany View 不同。 some View 是不透明的(一個特定型別,被隱藏);any View 是 existential(一個能容納任何符合型別的盒子,帶有執行期開銷)。獨立函式或屬性 可以 合法地回傳 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 階層會被銷毀,並在原處建立新的階層,這意味著遺失 state、動畫重新開始、身分喪失。重新包裹相同的具體型別不會觸發這種銷毀,但無論哪種情況,框架偏好的靜態型別驅動的 diffing 都不再可用。

正確的原則是:對於條件式 views(ifswitchfor),優先採用 @ViewBuilder 的 result-builder 分支;對於變動型別,優先採用參數化 views;只有在兩者都無法解決時,才動用 AnyView。如果 func viewForKind 回傳 AnyView,通常代表您應該讓 viewForKind 回傳 some View,並將 switch 放進 result-builder closure 中。

GroupEmptyViewTupleView:實作型別

Result builder 會合成出特定的具體 view 型別。其中三個值得辨識:6

Group 是透明的容器。它最多接受十個 views 作為內容,並將它們作為兄弟元素呈現給父層 layout。容器本身不增加任何視覺結構;內容的渲染與單獨呈現時完全相同。使用情境是:將多個 views 包進預期單一 view 的上下文中(如 .if modifier、條件式 return、產生「一個 view」的函式)。Group { Text("A"); Text("B") } 是包含兩個元素的單一 view;它是 result builders 隱含執行的明確形式。

EmptyView 是不渲染任何內容的 view。當 if 沒有 else 時,result builder 會將其作為條件 false 分支。從您自己的程式碼回傳 EmptyView(),是在不改變函式回傳型別的情況下選擇不渲染的方式。

TupleView 是當 body 包含多個兄弟 views 時,result builders 產生的具體型別。本文開頭那個回傳三個兄弟 views 的表達式,實際回傳的是 TupleView<(Text, Text, Image)>。您幾乎不會直接撰寫 TupleView;通常只在錯誤訊息中讀到它。

_ConditionalContent(前置底線)是處理 if/else 分支的型別。這個型別出現在 ViewBuilder 的公開介面中,但底線開頭的命名暗示「不要隨意撰寫此型別」;讓 result builder 從 if/else 合成它,而非手動建構。

在您自己的函式上使用 @ViewBuilder

Result builders 並非僅供 body 使用。任何函式或 closure 參數都可標註 @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 自家的 VStackHStackZStackListFormSectionGroupNavigationStack 接受多個子元素的方式。這些型別每一個都接收 @ViewBuilder content: () -> Content 參數。認識此模式意味著:您可以撰寫具備與框架相同人體工學的自訂容器 view,無需任何特殊的編譯器支援。

之所以寫 init(@ViewBuilder content:) 而非單純 init(content:),是因為標註在參數上的這個屬性,正是啟動呼叫端傳入的 closure body 內 result-builder 轉換的關鍵。如果沒有此屬性,Card { Text("A"); Text("B") } 會是編譯錯誤,因為 closure 有兩個語句,卻沒有 @ViewBuilder 來轉換它們。

State、Bindings 與 Property Wrapper 層

以上所有內容都關乎 view 樹的 形狀。SwiftUI 的另一半是 state,而那一半建構在 Swift property wrappers 之上。7

對 view 撰寫最相關的 property wrappers 包括:

@State 在單一 view 內擁有一份值型別 state。讀取屬性即讀取底層儲存;對其賦值會觸發 view 重新渲染。此 wrapper 適用於簡單、view 本地的 state(例如切換按鈕的開關、文字欄位的草稿字串)。

@Binding 是對另一個 view state 的雙向參考。需要讀寫父層 state 的子 view 接收一個 Binding<T> 參數。父層透過 $state@State 上的錢字符投影)建構這個 binding。

@Observable(iOS 17+)是一個 macro,取代了較舊的 ObservableObject 一致性模式。將此 macro 套用於 class,會產生 Observation 框架的追蹤功能,使 class 的屬性在 body 內被讀取後再被變更時,能夠觸發 view 重新渲染。對於 @Observable class 的 view 端擁有權,從 @StateObject 改為單純的 @State;需要雙向控制權的下游 views 則使用 @Bindable,而非 @ObservedObject

@Environment 從環境鏈中讀取依賴注入的值。SwiftUI 提供內建的 environment keys(locale、color scheme、dismiss action);應用程式可加入自訂 keys,用於領域特定的依賴注入。

Property-wrapper 層正是讓 view 的 body 能在 state 變更時重新執行的關鍵。SwiftUI 透過兩種不同機制追蹤 body 內部的讀取:AttributeGraph(Apple 的私有依賴圖,支援 @State@Binding@Environment)負責較舊的 property-wrapper 路徑;標準函式庫的 Observation 框架(withObservationTracking,自 iOS 17+ 公開)則負責 @Observable 型別。8 當被追蹤的屬性發生變動時,對應的 bodies 會重新執行,diffing 機制會計算最小的 view 樹變更。

這兩半(view 樹層與 state 層)為鬆耦合關係。View 樹是值型別,重新計算成本低。State 層則是參考型別(針對 @Observable)或附帶儲存指標的值型別(針對 @State),並追蹤讀取。兩者結合,產生了框架那種「將畫面應呈現的內容描述為 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.」 帶有許多 modifier 的長 body 鏈耗盡了型別檢查器。修正方式:將 body 拆分為較小的計算屬性或子 views;每個回傳 some View 的片段都能簡化推論工作。

「Cannot convert value of type ‘TupleView<…>’ to expected type ‘some View’.」 一個預期單一 view 的函式收到了缺少 @ViewBuilder 的多語句 body 結果。修正方式:在接受多語句內容的 closure 參數上加上 @ViewBuilder

「Generic parameter ‘Content’ could not be inferred.」 一個自訂容器接收 @ViewBuilder content: () -> Content,呼叫端傳入空 closure。修正方式:result builders 至少需要一個表達式才能推論 Content;空 closure 僅在呼叫端明確提供 EmptyView() 時才能 fallback。

這些錯誤訊息之所以不友善,正因為底層基礎是不可見的。帶著對底層基礎的認識去讀,多數訊息便轉化為「啊,result builder 無法轉換這個」或「啊,我需要分支或 AnyView」。

何時應跳脫底層基礎之外

少數模式是底層基礎無法乾淨處理的:

多型具體型別。 當函式為每個分支回傳不同的 View 型別,且您無法將其包裹在 result-builder 分支中時,需要 AnyView。接受其代價(喪失 diffing、無動畫),並在呼叫端記錄清楚。

跨平台條件式 views。 編譯期的 #if os(iOS)@ViewBuilder body 內可運作,但會限制 result builder 的分支數量;多 OS 條件式 bodies 有時會撞上「expression too complex」上限。修正方式是將每個平台的子 views 抽取到各自的函式中,每個函式回傳 some View

命令式 view 建構。 框架預期 views 是表達式,而非建構後再變動的物件。UIKit 風格的「建立 label、設定 text、加入 subview」並不適用;SwiftUI 的對應做法是從 body 回傳值型別的 Text("...")。需要命令式建構的模式通常意味著該工作屬於透過 UIViewRepresentable 橋接到 UIKit 的範疇。

此模式對於在 iOS 26+ 上出貨的 App 意味著什麼

三個重點。

  1. SwiftUI 是 Swift,不是魔法。 Result builders、不透明回傳型別與 property wrappers 全都記載於 Swift 語言參考中。將框架當作 Swift 程式碼閱讀,而非當作特殊 DSL,能讓那些令人意外的部分變得可預期。

  2. some ViewAnyView 解決不同的問題。 不透明回傳型別是預設選項;型別抹除是逃生口。動用 AnyView 應為罕見情境;採用 some View 加上 result-builder 分支應為常態。

  3. Result builders 就是整個 DSL。 任何標註 @ViewBuilder 的函式或參數,都能使用這套無逗號、語句式的語法。撰寫具備與 VStack 相同人體工學的自訂容器 view,只需要一個屬性與一個 closure 參數。

請將本文與本系列的實際出貨程式碼系列搭配閱讀:跨平台的 SwiftUI 出貨(Return 以單一共享的 SwiftUI 核心在五個平台上運行);Liquid Glass 視覺層;iOS 上的 Live Activities 狀態機;以及 Apple Watch 上的 watchOS 執行期合約。系列總覽請見 Apple 生態系列。若需更廣泛的 iOS 與 AI agents 結合的脈絡,請參閱 iOS Agent 開發指南

FAQ

SwiftUI 中的 View 協定是什麼?

View 協定有單一要求:var body: some View { get }。每個 SwiftUI view 都是符合 View 的 Swift 值型別,具有一個 body 計算屬性,回傳另一個 view(對於 TextColorImageEmptyView 這類 primitive views,則回傳 Never)。Body 標註了 @ViewBuilder,因此可以使用 SwiftUI 那種無逗號的 DSL 語法。

some View 是什麼意思?

some View 是不透明回傳型別(Swift 5.1+)。編譯器知道具體型別;呼叫端只看見協定的 witness。不透明型別讓 view body 能回傳如 VStack<TupleView<(Text, Image, Spacer)>> 這類複雜型別,而無需明確寫出,同時保留編譯期最佳化。some View一個特定型別,儘管該型別在呼叫端不可見。

我何時應使用 AnyView?

只有當 @ViewBuilder 的 result-builder 分支(ifswitchfor)以及參數化泛型都無法解決問題時,才使用 AnyView。當被包裹的具體型別在多次渲染之間改變時,現有的 view 階層會被銷毀並在原處建立新的階層;那一刻動畫會重新開始、view state 會被重設。重新包裹相同的具體型別不會觸發這種銷毀,但無論哪種情況,框架偏好的靜態型別驅動的 diffing 都不再可用。如果您發現自己經常動用 AnyView,需要改變的模式其實在更上游:優先使用參數化 views,或將條件式推進 result-builder body 中。

@ViewBuilder 是什麼?我可以在哪裡使用它?

@ViewBuilder 是一個 result builder(Swift 語言特性)。它透過插入編譯器合成的 buildBlockbuildEitherbuildOptional 等呼叫,將包含多個表達式的 closure 轉換為單一回傳值。每個 SwiftUI view 的 body 預設就是 @ViewBuilder。您可以將 @ViewBuilder 套用到任何函式或 closure 參數,讓呼叫端獲得相同的 DSL 語法;VStackCardSection 都使用相同模式來接受多個子元素。

為什麼我的 view body 會在我沒預期的情況下重新渲染?

每當 body 讀取的任何 state 屬性發生變動時,SwiftUI 就會重新執行 body。Property wrappers(@State@Binding@Observable@Environment)追蹤讀取,並在寫入時觸發重新渲染。意料之外的重新渲染通常可追溯到父 view 的 state 變更、environment 值變更,或某個 @Observable 物件被讀取的屬性遭到修改。框架的 diffing 隨後計算最小的樹變更。

參考資料


  1. Apple Developer,「View」「Configuring views」View 協定、Body 關聯型別,以及 body 上的 @ViewBuilder 屬性。 

  2. Swift Evolution,「SE-0289: Result builders」。將 result builders 正式化的語言提案(在 5.1 中以 _functionBuilder 引入,在 5.4 中以 @resultBuilder 正式化)。定義了 buildBlockbuildEitherbuildOptionalbuildArraybuildExpressionbuildFinalResult 等。 

  3. Apple Developer,「ViewBuilder」「ForEach」。SwiftUI 用於 view body 的 result-builder 型別(variadic-generic 的 buildBlockbuildEither、optional 解開)。ViewBuilder 並未公開 buildArray,因此 ForEach 是用於在集合上重複 view 的迭代基本單元。 

  4. Swift Evolution,「SE-0244: Opaque result types」。用於不透明回傳型別的 some 關鍵字,於 Swift 5.1 加入。 

  5. Apple Developer,「AnyView」。型別抹除的 view 包裹器、其建構方式,以及 diffing 上的取捨。 

  6. Apple Developer,「Group」「EmptyView」「TupleView」。Result builders 合成的實作型別。 

  7. Apple Developer,「State and Data Flow」。Property-wrapper 層:@State@Binding@Observable@Environment。SwiftUI 的觀察系統與 iOS 17+ 的 @Observable macro。 

  8. Apple Developer,「Observation」「Migrating from the Observable Object protocol to the Observable macro」。標準函式庫的 Observation 框架,包含 withObservationTracking(_:onChange:),以及從 ObservableObject 遷移至 @Observable 的 iOS 17 路徑。 

相關文章

watchOS 執行階段是契約,而非背景任務

watchOS 沒有 iOS 那種背景模式。WKExtendedRuntimeSession 才是契約;少了它,App 在使用者放下手腕時就會暫停。Return 已實作此模式。

4 分鐘閱讀

三個介面:人類、Apple Intelligence、代理

iOS應用程式的每項功能都面對三個介面:人類、Apple Intelligence、代理。每個介面都有不同的義務、渲染方式、延遲要求與信任姿態。

2 分鐘閱讀

清理層才是真正的 AI 代理市場

Charlie Labs 從建構代理轉向清理代理留下的爛攤子。AI 代理市場正從生成轉向證明。清理才是耐久的那一層。

2 分鐘閱讀