← 所有文章

SwiftUI 由什麼構成

類型: 框架解析。本文說明 SwiftUI 所立基的底層結構:result builder、不透明回傳型別,以及值型別的 view 樹。一旦看清底層結構,那些讓開發者驚訝的 SwiftUI 環節(AnyViewGroupViewBuilder@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 則是當不透明型別不適用時,作為型別抹除的逃生出口。
  • GroupEmptyViewTupleView_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(例如 TextColorImageEmptyView),其 BodyNever。基本 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 letswitch、選擇性解包以及其他幾個結構,都由 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(ifswitchfor)優先使用 @ViewBuilder 的 result-builder 分支;型別變動的情境優先使用參數化的 view;只有兩者都行不通時,才動用 AnyView。一個回傳 AnyViewfunc viewForKind,通常意味著您應該讓 viewForKind 回傳 some View,並把 switch 放進 result-builder 閉包內。

GroupEmptyViewTupleView:實作型別

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 自家的 VStackHStackZStackListFormSectionGroupNavigationStack 接受多個子元素的方式。上述每一個型別都接受一個 @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 意味著什麼

三個重點。

  1. SwiftUI 是 Swift,而非魔法。 Result builder、不透明回傳型別與屬性包裝器,都記載於 Swift 語言參考。將框架作為 Swift 程式碼閱讀,而非當成特殊 DSL,那些令人意外的環節就會變得可預測。

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

  3. 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,對 TextColorImageEmptyView 等基本 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 分支處理(ifswitchfor)與參數化泛型都無法解決問題時,才使用 AnyView。當所包裹的具體型別在不同次渲染間發生變化時,現有的 view 階層會被銷毀,並在原處建立一個新的階層;那一刻,動畫會重新開始,view 狀態會被重設。重新包裹相同的具體型別不會觸發那種銷毀,但無論如何,框架偏好的、由靜態型別驅動的 diff 機制已不再可用。如果您發現自己經常動用 AnyView,需要改變的模式其實在上游:請改用參數化的 view,或將條件式推進 result-builder 的 body。

@ViewBuilder 是什麼,可以用在哪裡?

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

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

SwiftUI 會在 body 所讀取的任何狀態屬性發生變動時,重新執行 body。屬性包裝器(@State@Binding@Observable@Environment)會追蹤讀取,並在寫入時觸發重新渲染。意料之外的重新渲染,通常可追溯到父層 view 的狀態變更、環境值變更,或是某個 @Observable 物件被讀取的屬性遭到修改。框架的 diff 機制隨後會計算出 view 樹最小的變更。

參考資料


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

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

  3. Apple Developer,「ViewBuilder」「ForEach」。SwiftUI 用於 view body 的 result-builder 型別(可變參數泛型 buildBlockbuildEither、選擇性解包)。ViewBuilder 並未公開 buildArray,因此 ForEach 是用於對集合重複建立 view 的迭代基本工具。 

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

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

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

  7. Apple Developer,「State and Data Flow」。屬性包裝器這一層:@State@Binding@Observable@Environment。SwiftUI 的觀察系統與 iOS 17+ 的 @Observable 巨集。 

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

相關文章

watchOS Runtime Is a Contract, Not a Background Task

watchOS does not have iOS's background. WKExtendedRuntimeSession is a contract you sign with the system, broken on wrist…

15 分鐘閱讀

RealityKit And The Spatial Mental Model

RealityKit is an entity-component-system, not SwiftUI in 3D. Anchors place entities in real space. Five ways the model d…

16 分鐘閱讀

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分鐘閱讀