對於顯示異質 SwiftUI 元件的 macOS 視窗,讓每個提供者的輸出維持強型別,並在共用的呈現介面需要單一具體視圖型別時再擦除型別。若集合本身需要統一的包裝型別,則提早擦除提供者型別。

本文會建立一個小型元件展示集,其中包含合成文字與藍色色樣。目標是將不同的提供者實作存放在一起,並透過單一 SwiftUI 介面呈現。設計上的決策在於:在哪裡停止公開提供者與其輸出之間的關係。

提供者契約需要保留什麼?

具有關聯輸出型別的提供者表示,每個遵循該協定的型別都會選擇自己的視圖型別。具體實作可以用 some View 隱藏這項選擇,而泛型消費端仍保留提供者與其輸出之間的關係。Swift 的不透明型別會保留型別識別;裝箱協定型別則允許不同的底層型別共用一個介面。請參閱不透明與裝箱協定型別。

以下是契約片段,並非完整應用程式:

import SwiftUI

@MainActor
protocol ViewProvider {
    associatedtype Content: View
    var id: String { get }
    func makeView() -> Content
}

@MainActor
struct TitleProvider: ViewProvider {
    let id: String
    let title: String

    func makeView() -> some View {
        Text(title).font(.headline)
    }
}

此處的 TitleProvider.Content 是從 makeView() 的不透明回傳型別推斷而來。協定要求一個關聯型別,而實作透過推斷提供該型別。SE-0244 明確記載了這種受支援的模式,以及不得直接將不透明回傳型別宣告為協定需求的限制。因此,將需求替換為 func makeView() -> some View 並非該提案所描述的受支援契約。請參閱不透明結果型別。

any ViewProvider 陣列可解決異質儲存問題。Swift 5.7 的 SE-0309 允許使用具有關聯型別的協定作為存在型別,但個別成員操作仍受限制。透過該存在型別讀取輸出時,其關聯型別可以被擦除為協定的上限型別;在此例中是 any View。請參閱解鎖所有協定的存在型別。

因此,你需要分別做出兩項決策:如何儲存混合的提供者,以及呈現器要回傳哪種具體型別。Apple 將 AnyView 說明為一種遵循 View 的具體型別擦除視圖,因此可明確用於第二項決策。請參閱 AnyView。

根據消費端的需求選擇邊界

變更協定前,請先參考這張決策表。這些建議是將已記載的型別契約套用至展示集任務的結果,屬於設計指引,而非量測所得的效能結果。

消費端需求 適用設計 隱藏型別資訊的位置
透過可重複使用的程式碼呈現單一具體提供者 受限於 ViewProvider 的泛型消費端 保留提供者與輸出之間的關係
儲存不同的提供者並保留其共用中繼資料 [any ViewProvider],搭配回傳 AnyView 的呈現輔助函式 儲存時擦除提供者型別;呈現時擦除輸出型別
向下游程式碼公開單一具體提供者包裝型別 [AnyViewProvider] 建立包裝型別時
要求每個實作都回傳已擦除型別的視圖 回傳 AnyView 的協定需求 在每個實作內
顯示一組固定且精簡的元件選項 列舉型別與使用建構器的 switch 選項在列舉型別中明確保留

對於這個展示集,請選擇存在型別儲存,再加上輸出型別擦除。提供者保留各自的輸出型別;集合公開穩定的字串識別碼;而單一輔助函式負責轉換成 AnyView。如此一來,變更元件的內部版面配置時,就不必在該元件中加入新的型別擦除程式碼。

泛型集合參數適用於不同的任務。元素型別為單一泛型提供者型別的陣列,無法自動表示多種互不相關的提供者型別。Swift 的隱式存在型別開啟適用於單一存在型別引數;它不會讓異質陣列擁有一個共用的底層遵循型別。請參閱隱式開啟的存在型別。

建立完整的 macOS 元件展示集

此範例需要 Swift 5.7 或更新版本的編譯器,以及 macOS SwiftUI 應用程式目標。將範例的部署目標設為 macOS 12 或更新版本。在使用 SwiftUI 的 Xcode macOS App 專案中,將產生的 app 宣告替換為下列完整程式,並確保只保留一個 @main 宣告。

明確的 @MainActor 註記可讓提供者建立與呈現都在相同的 actor 上執行。Apple 目前的 View 宣告隔離於主 actor。其文件也說明,自訂視圖透過 body 提供內容。請參閱 View。

import SwiftUI

@MainActor
protocol ViewProvider {
    associatedtype Content: View
    var id: String { get }
    func makeView() -> Content
}

@MainActor
struct TitleProvider: ViewProvider {
    let id: String
    let title: String

    func makeView() -> some View {
        Text(title)
            .font(.headline)
    }
}

@MainActor
struct SwatchProvider: ViewProvider {
    let id: String

    func makeView() -> some View {
        RoundedRectangle(cornerRadius: 8)
            .fill(Color.blue)
            .frame(width: 96, height: 48)
    }
}

@MainActor
func erasedView<P: ViewProvider>(for provider: P) -> AnyView {
    AnyView(provider.makeView())
}

@MainActor
struct GalleryView: View {
    let providers: [any ViewProvider]

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            ForEach(providers, id: \.id) { provider in
                erasedView(for: provider)
            }
        }
        .padding(24)
        .frame(minWidth: 280, minHeight: 180)
    }
}

@main
@MainActor
struct ProviderGalleryApp: App {
    private let providers: [any ViewProvider] = [
        TitleProvider(id: "title", title: "Component gallery"),
        SwatchProvider(id: "swatch")
    ]

    var body: some Scene {
        WindowGroup {
            GalleryView(providers: providers)
        }
    }
}

預期結果是一個視窗,在藍色圓角矩形上方顯示 Component gallery。所有輸入值都是本機合成資料。此範例已經過檢視,但此處未進行編譯,因為可用環境沒有 Swift 編譯器或 Apple SDK。

無論提供者的關聯輸出型別為何,輔助函式的結果一律是 AnyView。在輔助函式內,提供者是泛型型別,其 Content 關係仍可使用。Apple 說明此處使用的初始化器會接受受限於 View 的泛型值,並擦除該值。請參閱 AnyView.init(_:)。

此集合使用 id: \.id,因為協定將 id 固定為 String;它不需要還原另一個未知的關聯型別。Apple 的 ForEach 文件允許元素具備可識別性,或明確傳入識別碼鍵路徑。請讓識別碼在此集合中保持唯一,並在更新期間對應到同一邏輯元件時保持穩定。請參閱 ForEach。

為什麼泛型輔助函式可行,泛型視圖包裝型別卻不行?

erasedView 的關鍵特性是其具體回傳型別。Swift 5.7 引入了存在型別引數的隱式開啟,讓編譯器能在呼叫時,將泛型參數綁定到存在型別值的底層型別。若結果會透過不變的泛型容器暴露該開啟後的型別,仍會受到限制。SE-0352 也記載了 Swift 6 語言模式啟用的更廣泛開啟行為。請參閱隱式開啟的存在型別。

例如,以下診斷片段會在上述程式中加入一個泛型包裝型別。註解中的呼叫展示了有問題的邊界;它不是額外的應用程式進入點:

@MainActor
struct ProviderRow<P: ViewProvider>: View {
    let provider: P

    var body: some View {
        provider.makeView()
    }
}

@MainActor
func inspectBoundary(_ provider: any ViewProvider) {
    // Problematic: the constructed result would retain the opened P.
    // let row = ProviderRow(provider: provider)

    // Supported boundary: the result is the concrete type AnyView.
    let row = erasedView(for: provider)
    _ = row
}

這說明為何單純加入泛型層可能無法解決錯誤。請同時檢查它的參數與結果。回傳具體擦除視圖的輔助函式會在呼叫中完成轉換;像 ProviderRow<P> 這樣的結果則會在其結構中保留提供者型別。

同樣地,some View 並不是用來表示底層型別可在執行階段任意變動的回傳值。實作會選擇一致的底層型別,而該型別可能取決於泛型引數。建構器可以將分支編碼成組合結果,但僅靠不透明結果宣告本身,無法統一互不相關的回傳型別。請參閱不透明與裝箱協定型別。

何時應該擦除提供者本身?

若下游介面應接收單一提供者型別,或你希望呼叫端在儲存提供者之前先將它們正規化,請選擇具體包裝型別。以下可新增的宣告可搭配範例中既有的協定與提供者使用。它會捕捉提供者,並在呼叫 makeView() 時建立其視圖。

@MainActor
struct AnyViewProvider: ViewProvider {
    let id: String
    private let build: @MainActor () -> AnyView

    init<P: ViewProvider>(_ provider: P) {
        id = provider.id
        build = { AnyView(provider.makeView()) }
    }

    func makeView() -> AnyView {
        build()
    }
}

若要使用此替代方案,請將完整程式中兩個已儲存的 providers 屬性都改為 [AnyViewProvider]。再將 app 的初始陣列替換為以下運算式:

[
    AnyViewProvider(
        TitleProvider(id: "title", title: "Component gallery")
    ),
    AnyViewProvider(SwatchProvider(id: "swatch"))
]

接著,在展示集的 ForEach 閉包中,將 erasedView(for: provider) 替換為 provider.makeView()。包裝型別已經回傳 AnyView,因此該呼叫不需要額外轉換。此實作會複製識別碼並捕捉所提供的提供者值;它無法用來替換之後捕捉的值。

若每個消費端都需要擦除型別的輸出,較簡單的協定可以直接要求 makeView() -> AnyView。如此能減少轉接程式碼,但每個遵循型別都必須負責擦除型別。若擦除型別的輸出是刻意承諾的介面,請優先採用此做法。若希望泛型消費端仍能使用每個提供者的輸出關係,則應優先採用關聯型別契約。

保留編譯器限制與視圖識別

請將語言功能與建構器行為分開看待。此範例依賴 Swift 5.7 的存在型別功能,以及明確的 AnyView 結果。Apple 目前的文件區分 Xcode 26 及更早版本與 Xcode 27 及更新版本:後者會建構與型別無關的建構器內容,並建議使用 ContentBuilder 實現該行為。其文件仍以條件式遵循來說明型別安全。不要將較寬鬆的建構器輸入檢查解讀為通用承諾,以為存在型別本身符合所有泛型 View 限制。請參閱 ViewBuilder 和 ContentBuilder。

對於固定的一組元件,可考慮使用列舉型別,讓各案例攜帶元件資料,並以切換這些案例的呈現屬性來產生視圖。這樣能明確列出支援的選項。若新增元件時需要更新一個集中式呈現器,這種設計很實用;若希望遵循介面的實作各自提供呈現方式,則適合採用提供者模式。

型別擦除也有已記載的識別後果。Apple 指出,變更 AnyView 包裝的型別會銷毀先前的階層並建立新的階層。穩定的集合識別碼不會改變這項行為。如果元件可能在不同的包裝視圖型別之間切換,請在實際應用程式中檢查其狀態與轉場行為。請參閱 AnyView。

對於這個展示集,請保留關聯型別提供者契約,以存在型別儲存混合值,並由單一呈現輔助函式回傳 AnyView。若下游介面需要統一型別,則將型別擦除移至具體提供者包裝型別。如此一來,當集合、呈現契約或編譯器設定變更時,就有明確的邊界可供檢視。

來源