若要讓單一委派處理多種 SwiftUI 元件類型,請將回呼方法設為泛型,並讓元件不依賴委派的具體型別。本指南會建構一個小型 macOS 磚塊選擇器,在不需要遞迴 Self 要求的情況下保留傳送者的內容型別。
先選擇回呼契約,再決定語法
讀者要完成的任務很明確:建構一個可重複使用的 SwiftUI 元件,其內容可以變化,同時由單一委派接收所有受支援內容特化版本的選取回呼。範例使用兩個合成磚塊——文字和圓形——並顯示所選磚塊的識別碼。
Swift 將委派定義為一種以協定為基礎的方式,讓類別或結構將職責交給另一個實例。委派可以接收動作及其傳送者,如語言指南中的委派範例所示。Protocols
對這個元件而言,預期的契約是:「對於每一種符合 View 的內容型別,委派都能接收包含該內容的磚塊。」將型別參數放在回呼方法上。Swift 的泛型函式會在每次呼叫時選定具體型別引數,而關聯型別則由協定遵循決定。這兩者代表不同的承諾。Generics
| 需求 | 適合的介面形式 | 結果 |
|---|---|---|
| 一個委派處理所有受支援的內容型別 | 泛型回呼方法 | 每次回呼都可以有不同的內容型別。 |
| 每個委派只處理一種固定的內容型別 | 關聯型別 | 每個遵循都會提供一種內容型別。 |
| 回呼只需要磚塊識別碼 | 接收識別碼的回呼 | 回呼不依賴呈現型別。 |
這張表是根據上述語言規則整理出的設計參考。若保留具型別的傳送者是 API 的一部分,請選第一列。若傳送者識別碼已提供接收方所需的一切資訊,請選第三列;此時傳遞整個元件會造成不必要的依賴。
移除對委派自身型別的依賴
緊密耦合的設計可能會讓元件以委派為泛型,接著在委派協定中以 Component<Self> 引用該元件。此處的 Self 指最終遵循協定的委派型別。關聯型別同樣屬於該遵循型別。Declarations
這種形式會讓回呼的檢查依賴某個元件特化版本,而該特化版本本身又依賴委派。某個遞迴宣告是否獲接受,必須檢視其完整限制條件與編譯器診斷才能判定。若說 Swift 拒絕所有遞迴協定關係,並不準確:SE-0157 在 Swift 4.1 加入了關聯型別上的遞迴限制條件。Support recursive constraints on associated types
在此任務中,委派的具體型別完全不必出現在元件型別中。以下設計讓 Tile 只有一個泛型參數 Content。它儲存的委派為 any TileDelegate,而協定需求則引入自己的方法層級 Content 參數。此回呼保留傳送者的內容型別,無須透過委派的 Self 來表達。
any 這種寫法會明確表示一個存在型別值:儲存的值可以持有符合協定之型別的實例。SE-0335 在 Swift 5.6 引入了這種寫法。將委派設為存在型別,不代表必須把磚塊的內容抹除為 AnyView。Introduce existential any
建構完整的 macOS 磚塊選擇器
請使用 macOS SwiftUI App 目標,並搭配 Swift 6 編譯器,例如 Xcode 16 或更新版本所附的編譯器。將 macOS 部署目標設為 11.0 或更新版本,以便在此範例中使用 @StateObject 儲存狀態。Apple 文件指出,StateObject 從 macOS 11.0 起提供。StateObject
將產生的 App 宣告和起始視圖替換為這個單一原始碼檔。請確認目標中只有一個 @main App 宣告;Swift 只允許一種程式進入點方式。Declarations
import SwiftUI
import Combine
@MainActor
protocol TileDelegate: AnyObject {
func didChoose<Content: View>(_ tile: Tile<Content>)
}
@MainActor
struct Tile<Content: View>: View {
let id: String
let delegate: any TileDelegate
let content: Content
init(
id: String,
delegate: any TileDelegate,
@ViewBuilder content: () -> Content
) {
self.id = id
self.delegate = delegate
self.content = content()
}
var body: some View {
Button(action: {
delegate.didChoose(self)
}) {
VStack(spacing: 8) {
content
.frame(width: 100, height: 50)
Text("Choose \(id)")
}
.padding(8)
}
}
}
@MainActor
final class SelectionModel: ObservableObject, TileDelegate {
@Published private(set) var selection = "No tile selected"
func didChoose<Content: View>(_ tile: Tile<Content>) {
selection = "Selected: \(tile.id)"
}
}
@MainActor
struct ChooserView: View {
@StateObject private var model = SelectionModel()
var body: some View {
VStack(spacing: 16) {
HStack(spacing: 16) {
Tile(id: "text", delegate: model) {
Text("Sample")
}
Tile(id: "circle", delegate: model) {
Circle()
}
}
Text(model.selection)
}
.padding(24)
.frame(minWidth: 340, minHeight: 180)
}
}
@main
@MainActor
struct DelegateDemoApp: App {
var body: some Scene {
WindowGroup {
ChooserView()
}
}
}
這是完整的 App 範例,包含匯入、委派、元件、模型和進入點。此處僅檢視範例,並未編譯或執行,因為目前環境沒有 Swift 編譯器或 macOS SDK。
Apple 的 Button API 接受一個動作和一個產生視圖的標籤。在此範例中,點擊磚塊會在按鈕動作中呼叫委派;計算 body 則會建構介面,但不會傳送選取事件。Button
請在 Mac 上建置並執行 App。初始狀態應顯示 No tile selected。點擊 Choose text 應顯示 Selected: text;點擊 Choose circle 應顯示 Selected: circle。這些是範例預期的結果,並非實際執行的回報。
了解範例保留了哪些資訊
第一個磚塊的型別是 Tile<Text>,第二個則是 Tile<Circle>。兩者使用相同的 SelectionModel 實例。其泛型回呼對任一內容型別都符合協定需求,並讀取共用的 id 屬性。這就是將泛型參數放在方法上的實際好處。
接收方也可以將 tile.content 以 Content 存取。不過,在泛型實作內,宣告的限制只保證 View 所提供的能力。需要文字專屬操作的程式碼,必須採用不同的契約或額外資訊;回呼不能假設每個內容值都是 Text。這是 Swift 以限制條件為基礎的泛型模型所帶來的結果。Generics
元件遵循 Apple 文件所述的自訂視圖模式,以 some View 實作 body。目前的 View 宣告也帶有主執行器隔離。範例明確將面向 UI 的協定和型別標記為 @MainActor,讓回呼與模型更新共用此隔離區域。View
@StateObject 會在外層視圖的識別值存續期間,提供穩定的模型儲存空間。模型已發布的選取狀態變更會更新相依的狀態視圖。若將模型建構移到一般的計算運算式中,就無法再透過 StateObject 表達這項所有權。StateObject
範例會強參照必要的委派。其模型只儲存選取字串,因此範例中的物件圖沒有任何參照回到磚塊。若之後讓模型保留傳送者,請重新檢視所有權。Apple 的語言指南示範了在這種關係需要時,使用僅限類別的委派搭配弱參照儲存。Protocols
何時關聯型別才是更好的契約
假設接收方只能接受文字磚塊。另一種協定可以宣告 associatedtype Content: View,並要求 func didChoose(_ tile: Tile<Content>)。這表示每個遵循只對應一種內容型別。將 Content 設為主要關聯型別能提供方便的限制語法,但不會使這項需求變成可接受所有內容型別的方法。SE-0346 在 Swift 5.7 引入主要關聯型別。Lightweight same-type requirements for primary associated types
這個替代方案會是不同版本的 API。你也需要將元件的委派屬性和初始化參數改為相符的受限制存在型別,例如 any FixedTileDelegate<Content>,並讓每個接收方提供其固定關聯型別和對應的非泛型回呼。文字專用的遵循就無法透過同一個遵循來處理圓形磚塊。
SE-0353 在 Swift 5.7 加入受限制的存在型別,讓存在型別的主要關聯型別可以綁定至指定型別。綁定一個關聯型別仍表示固定關係,不會提供一組能處理任意內容型別的回呼實作。Constrained Existential Types
請將編譯器支援與部署支援分開考量。SE-0353 明確指出,其中有些執行階段變更無法回溯部署到既有作業系統版本。在較舊的部署目標上採用此替代方案前,請檢查實際目標中的可用性診斷。上方的完整範例使用一般委派存在型別,不需要以受限制存在型別來儲存委派。Constrained Existential Types
依實用順序診斷失敗的改寫
- 檢查回呼預期的適用範圍。 若同一個委派必須處理文字和圓形磚塊,其實作方法必須保留泛型方法簽章。只接受
Tile<Text>的方法無法滿足此需求。 - 檢查元件的泛型參數。 在此設計中,
Tile依賴Content;其型別不包含委派的具體型別。 - 檢查執行器隔離。 UI 回呼和範例中的可觀察模型都應留在主執行器上。來自其他隔離環境的呼叫必須遵守此界線。
- 檢查事件發生的位置。 在按鈕動作中傳送選取事件,而不是在計算
body或初始化視圖時傳送。 - 檢查模型所有權。 在外層視圖中保留
@StateObject,並避免為回呼引入第二個模型實例。
若改寫後仍然失敗,請縮減成一個磚塊和一個委派實作,再加回第二種內容型別。這個步驟能區分是協定實作問題還是視圖組合問題。範例中的兩種不同內容型別可用來檢查契約:兩者都必須能在不轉型或抹除內容型別的情況下,進入同一個泛型回呼。
核心的設計決策在於型別變化應放在哪裡。若委派要處理所有元件特化版本,請將變化放在回呼方法上,並讓元件型別不受委派具體身分影響。這樣得到的 API 能保留具型別的傳送者,同時讓 macOS 選取流程保持簡潔,易於檢視和擴充。
來源
- Generics — Swift 程式語言
- Protocols — Swift 程式語言
- Declarations — Swift 程式語言
- Support recursive constraints on associated types — Swift 演進提案
- Introduce existential any — Swift 演進提案
- Lightweight same-type requirements for primary associated types — Swift 演進提案
- Constrained Existential Types — Swift 演進提案
- View — Apple 開發者文件
- StateObject — Apple 開發者文件
- Button — Apple 開發者文件