若要建構泛型 SwiftUI 元件,請在協定中要求其初始化器,或注入一個會回傳該元件的閉包。本教學會用這兩種方式,在 macOS 視窗中顯示相同的合成項目,並明確說明編譯器與 SDK 的界線。

泛型元件必須承諾什麼?

假設 macOS App 要顯示一張小型項目卡片。你想要一個可重複使用的面板,能從項目建構卡片,同時讓呼叫端選擇呈現方式。核心問題是由誰負責建構:元件宣告的介面,還是負責提供元件的呼叫端?

泛型限制會讓實作能使用該限制所列出的功能。撰寫 Component: View 可確立此元件是 SwiftUI 檢視,但不表示該元件有名為 init(item:) 的初始化器。Swift 的泛型章節說明了協定限制,而 Apple 的 View 文件則說明檢視必須具備的 body 屬性。

因此,以下程式片段刻意設計為無效:

// Invalid fragment: View does not require init(item:).
@MainActor
func construct<Component: View>(item: Item) -> Component {
    Component(item: item)
}

在某個具體元件中加入該初始化器,不代表它也能透過每個僅受 View 限制的泛型參數使用。可重複使用的 API 需要明確的建構契約。修改元件前,請先參考這份決策表:

建構需求 適合的介面 原因
所有參與的元件都接受相同的項目 要求 init(item:) 的協定 建構屬於元件家族介面的一部分。
呼叫端選擇其他設定或修飾符 回傳泛型元件的工廠閉包 建構保留在呼叫端。
呼叫端已經有完成的元件 儲存泛型元件值 建構已經完成。
呈現方式在有限的版面中切換 在 SwiftUI 檢視內組合各種替代方案 外層 API 可保留具體的檢視結構。

這份表格是根據已記錄的語言規則整理出的設計參考。下文會一併實作前兩種選項,方便直接比較其行為。

建立完整的 macOS 範例

使用採用 SwiftUI 的 Xcode macOS App 目標,並將 macOS 部署目標設為 11 或更新版本。本範例使用 Swift 6 編譯器及相符的 Apple SDK 撰寫。明確標註 @MainActor 需要 Swift 5.5 或更新版本,因此這份程式碼不適用於更早的編譯器。Apple 文件指出,WindowGroup 從 macOS 11 起提供,並可作為 App 檢視階層的容器。請參閱 WindowGroup。

將產生的 App 進入點檔案替換為以下完整程式。請確保目標中只有一個 @main App 宣告。產生的 ContentView 檔案可以保留但不使用。無須套件、外部素材或下載的範例資料。

import SwiftUI

struct Item {
    let title: String
    let detail: String
}

@MainActor
protocol ItemComponent: View {
    init(item: Item)
}

@MainActor
struct ItemCard: ItemComponent {
    let item: Item

    init(item: Item) {
        self.item = item
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(item.title)
                .font(.headline)
            Text(item.detail)
        }
        .padding()
        .background(Color.gray.opacity(0.12))
        .cornerRadius(8)
    }
}

@MainActor
struct ContractPanel<Component: ItemComponent>: View {
    let item: Item

    init(item: Item, component: Component.Type) {
        self.item = item
    }

    var body: some View {
        Component(item: item)
    }
}

@MainActor
struct FactoryPanel<Component: View>: View {
    private let makeComponent: @MainActor () -> Component

    init(make: @escaping @MainActor () -> Component) {
        self.makeComponent = make
    }

    var body: some View {
        makeComponent()
    }
}

@MainActor
struct DemoView: View {
    private let item = Item(
        title: "Sample tile",
        detail: "Blue circle on a neutral background"
    )

    var body: some View {
        VStack(alignment: .leading, spacing: 18) {
            Text("Initializer contract")
            ContractPanel(item: item, component: ItemCard.self)

            Text("Injected factory")
            FactoryPanel {
                ItemCard(item: item)
                    .padding(.leading, 12)
            }

            Text("Alternative factory output")
            FactoryPanel {
                Text(item.title + ": " + item.detail)
            }
        }
        .padding(24)
        .frame(width: 420)
    }
}

@main
@MainActor
struct ComponentDemoApp: App {
    var body: some Scene {
        WindowGroup {
            DemoView()
        }
    }
}

執行 macOS App 目標。預期結果是一個視窗,內含兩種卡片呈現方式,以及一種純文字呈現方式。第二張卡片有呼叫端提供的額外前置間距。這些是用來展示 API 行為的合成範例,並非測量結果。研究環境中未安裝 Swift 編譯器,因此這份程式碼經過檢視,但未在此處編譯或執行。

主執行緒隔離標註讓 UI 建構邊界一目了然。Apple 目前的 View 宣告標註了 @MainActor 和 @preconcurrency;其文件也說明,當遵循宣告出現在型別的原始宣告中時,隔離會如何繼承。請參閱 View。

初始化器契約如何運作?

ItemComponent 擴充了 View,並新增一項建構需求:init(item:)。Swift 支援直接在協定中要求初始化器。遵循該協定的型別會提供實作;協定則只宣告簽章,不包含初始化器本體。請參閱協定:初始化器需求。

有了這項需求,ContractPanel 內的 Component(item: item) 就有效。component: Component.Type 引數為泛型推斷提供具體型別:傳入 ItemCard.self,即可判定此面板的元件型別是 ItemCard。面板不必儲存該型別中繼資料,因為其 body 會透過泛型型別參數建構元件。

當所有元件都應能從相同輸入建構時,這項契約就很實用。例如,由 App 擁有的一組精簡與展開項目呈現方式,可以共用這項需求。接著,呼叫端便能切換所選的元件型別,而不必改寫面板的建構運算式。

當實作需要額外設定時,取捨就會顯現。要求 init(item:) 代表每個遵循協定的元件都必須提供該建構方式,即使它偏好的初始化器還接受說明文字或版面設定。如果這些額外資訊由呼叫端負責,通常以下的工廠方式會更直接地表達需求。

對於以類別為基礎的實作,除非類別是 final,否則符合協定需求的初始化器必須是 required。若子類別也覆寫了符合條件的父類別指定初始化器,可能需要同時標註 required 和 override。像 ItemCard 這樣的結構不使用 required。這些差異記載於協定。

何時應將建構移至工廠?

FactoryPanel 接受一個型別為 @MainActor () -> Component 的函式。它的承諾很簡單:呼叫該函式就會產生所選的元件型別。閉包可以呼叫初始化器、捕捉設定,或回傳已套用修飾符的檢視。

程式展示了實際差異:工廠可以回傳已加上間距的 ItemCard,也可以回傳 Text,而不必要求這兩種結果型別遵循 ItemComponent。Apple 說明,檢視修飾符可能會將輸入包裝在另一個檢視中,因此在此讓編譯器推斷工廠的結果型別很有幫助。請參閱 View。

初始化器會將閉包標註為 @escaping,因為面板會儲存它,以便稍後呼叫。Swift 的閉包章節說明了逃逸閉包及其捕捉周遭值的能力。在此範例中,閉包會捕捉外層檢視使用的合成項目。

請謹慎選擇工廠的輸入。零參數工廠表示呼叫端會捕捉建構所需的一切資訊。如果由面板選擇項目,帶參數的工廠可以更清楚地表達這項責任。以下是可搭配現有 Item 使用的替代元件定義;它不是第二個完整 App:

@MainActor
struct ItemFactoryPanel<Component: View>: View {
    let item: Item
    private let makeComponent: @MainActor (Item) -> Component

    init(
        item: Item,
        make: @escaping @MainActor (Item) -> Component
    ) {
        self.item = item
        self.makeComponent = make
    }

    var body: some View {
        makeComponent(item)
    }
}

使用這項定義後,面板會提供項目,而呼叫端提供呈現方式。請將工廠的工作限制在產生檢視值:範例中的建構契約不保證只執行一次、持續儲存或快取。

工廠需要結果建構器嗎?

完整程式中的工廠都是一般閉包。每個閉包都只回傳一個運算式,因此不需要自訂建構器標註。如果需要多個子檢視,回傳單一 VStack 就足以支援此 API;該堆疊會包含那些子檢視。

當工廠參數本身需要接受宣告式運算式序列或條件內容時,結果建構器就很實用。Swift Evolution 記錄指出,標準化的結果建構器功能已在 Swift 5.4 實作。請參閱 SE-0289:結果建構器。

採用建構器語法時,請留意 SDK 的版本界線。Apple 目前的文件指出,在 Xcode 26 及更早版本中,可對產生檢視的參數使用 @ViewBuilder。對 Xcode 27 及更新版本,文件則說明了不受型別限制的建構方式,並建議使用 ContentBuilder 別名來表示該行為。請參閱 ViewBuilder 和 ContentBuilder。此處使用的一般閉包不需要較新的別名。

為什麼不以不透明型別或存在型別取代契約?

some View 會隱藏特定的底層結果型別,同時保留其型別識別。它很適合用於本程式中的 body 屬性,但不會將 init(item:) 加入 View 介面。以 any View 書寫的裝箱值會以不同方式隱藏底層型別,也不會保留相同的靜態型別識別。請參閱不透明與裝箱協定型別。

不要因此就一概聲稱存在型別值永遠無法傳入泛型函式。SE-0352 在 Swift 5.7 引入了隱式開啟的存在型別,允許符合條件的呼叫將泛型參數綁定至存在型別的底層型別。它也記載了限制,包括結果若在泛型包裝型別中包含已開啟的型別,可能會在型別抹除後無法表示。請參閱隱式開啟的存在型別。

對這個面板來說,透過泛型參數保留具體元件型別就足夠了。無須只為了讓呼叫端選擇如何建構檢視,就引入存在型別儲存。

如何診斷建構錯誤?

沿著發生錯誤的運算式檢查,並查看該位置可用的契約:

  1. 如果 Component(item:) 發生錯誤,請確認泛型限制包含要求該確切初始化器簽章的協定。
  2. 如果編譯器無法推斷 Component,請提供具體的型別中繼資料,例如 ItemCard.self,或提供具有具體回傳運算式的工廠。
  3. 如果元件需要呼叫端專屬的設定,請將建構移到工廠,而不是對協定新增無關的需求。
  4. 如果儲存閉包時觸發逃逸閉包診斷,請檢查初始化器的 @escaping 標註。
  5. 如果建構觸發執行緒隔離診斷,請確認呼叫端和工廠的函式型別都表達了預期的主執行緒隔離界線。
  6. 如果存在型別造成泛型結果錯誤,請先檢查 SE-0352 中對結果型別的限制,再判斷升級編譯器是否能移除這項限制。

對於共用的 macOS 元件家族,若統一建構屬於介面的一部分,就要求提供該初始化器。若呼叫端應負責組合和設定呈現方式,則使用注入的工廠。兩種方式都會明確指出缺少的建構承諾,同時保留面板所需的具體元件型別。

來源