若要在同一個集合中儲存不同的 SwiftUI 形狀,同時強制它們遵循共用介面,請使用具體的 Hashable 包裝型別,並搭配受協定約束的初始化器。本教學會建立一個簡單的 Mac 形狀調色盤,明確定義重複項目規則,並提供可呼叫的形狀操作。
這項任務的範圍很明確:收集合成的圓盤與方磚元件,在編譯時拒絕無關的值、移除重複項目,並在 SwiftUI 視窗中顯示剩餘元件。此集合透過共用協定接受多種元件型別,而該協定繼承自 SwiftUI 的 Shape。
為什麼協定值集合不符合這項需求?
Swift 的 Set 要求其元素型別遵循 Hashable。宣告存在型別中的每個值都遵循 Hashable,並不會讓該存在型別本身取得這項遵循。因此,Set<any PaletteShape> 不會只因為 PaletteShape 繼承了 Hashable 就變成有效型別。Apple 說明了集合的元素約束,而 Swift Evolution 則解釋了存在容器與其底層遵循型別值之間的差異。Set、Implicitly Opened Existentials。
像 any PaletteShape 這樣的存在型別可以容納不同的遵循型別。困難在於相等性:繼承而來的 Equatable 要求會比較兩個相同 Self 型別的值,但兩個存在容器可能容納不同的具體型別。Swift 擴充了對存在值的支援,但並未為這些容器提供通用的相等比較操作。Unlock existentials for all protocols。
以下幾種表示方式分別解決問題的不同部分:
| 表示方式 | 可容納混合的具體型別? | 是否強制共用介面? | 適合作為集合元素? |
|---|---|---|---|
[any PaletteShape] |
可以 | 可以 | 陣列不會提供集合的唯一性 |
Set<S>,其中 S: PaletteShape |
一種選定的 S |
可以 | 可以,但只適用於該單一具體型別 |
Set<AnyHashable> |
可以 | 不限制形狀 | 可以 |
下方的 Set<AnyPaletteShape> |
可以,包裝在包裝型別中 | 建構時會強制 | 可以 |
AnyHashable 提供型別擦除後的相等比較與雜湊功能,但它的初始化器一般接受所有可雜湊值。因此,單獨使用它的集合也可能接受字串或整數。下方的自訂包裝型別會限制只能加入遵循應用程式形狀協定的值,並在保留形狀介面的同時,保留經型別擦除的可雜湊值。AnyHashable。
什麼情況下形狀會被視為重複?
實作包裝型別之前,先決定相等性的定義。在這個調色盤中,兩個元件只有在具體元件型別相同且儲存值相等時才算重複。標示為 Sample 的圓盤與標示為 Sample 的方磚會是不同項目。標籤相同的兩個圓盤則會合併為一個項目。
每個範例元件只儲存標籤這一項屬性,因此自動合成的相等性與雜湊會包含它。這是範例中的模型選擇:外觀相似本身不代表相等。Apple 說明,若結構的儲存屬性都可雜湊,就能自動合成 Hashable;同時也要求相等的值向雜湊器提供相同的組成部分。Hashable。
包裝型別也會在相等性與雜湊中加入型別識別碼,明確落實「型別相同」的規則,而不仰賴對型別擦除比較的假設。AnyHashable 可以使用標準化表示來比較某些底層型別不同的值;Apple 並未將它定義為無條件區分具體型別的機制。ObjectIdentifier 支援 metatype,因此本範例可以識別泛型輸入型別。AnyHashable、ObjectIdentifier。
建立完整的 Mac 調色盤範例
在 Xcode 中使用 macOS SwiftUI App 專案,並搭配 Swift 5.7 或更新版本的編譯器。若要有可重現的基準,請選擇 Swift 5 語言模式,並將 macOS 部署目標設為 13.0 或更新版本。選擇此部署目標是本教學的保守做法,並不表示下方每個 API 都需要 macOS 13。
編譯器最低版本很重要,因為範例會儲存 any PaletteShape,其繼承的協定包含關聯型別與 Self 要求。SE-0309 記錄了這項擴充的存在型別支援已在 Swift 5.7 實作。本範例不需要更新版本才有的廣義存在型別遵循功能。Unlock existentials for all protocols。
將產生的 App 宣告與內容視圖換成以下完整原始碼檔案。App 目標中只保留一個 @main 宣告。本範例包含元件定義、包裝型別、範例值、檢查、視圖與 App 進入點。研究環境未提供 Swift,因此這段程式碼經過檢視,但未在此處編譯或執行。
import SwiftUI
protocol PaletteShape: Shape, Hashable {
var label: String { get }
}
struct Disk: PaletteShape {
let label: String
func path(in rect: CGRect) -> Path {
Path(ellipseIn: rect)
}
}
struct Tile: PaletteShape {
let label: String
func path(in rect: CGRect) -> Path {
var result = Path()
result.addRect(rect)
return result
}
}
struct AnyPaletteShape: Hashable {
private let base: any PaletteShape
private let typeID: ObjectIdentifier
private let key: AnyHashable
init<S: PaletteShape>(_ value: S) {
base = value
typeID = ObjectIdentifier(S.self)
key = AnyHashable(value)
}
var label: String { base.label }
func path(in rect: CGRect) -> Path {
base.path(in: rect)
}
static func == (
lhs: AnyPaletteShape,
rhs: AnyPaletteShape
) -> Bool {
lhs.typeID == rhs.typeID && lhs.key == rhs.key
}
func hash(into hasher: inout Hasher) {
hasher.combine(typeID)
hasher.combine(key)
}
}
func makePalette() -> Set<AnyPaletteShape> {
var result: Set<AnyPaletteShape> = [
AnyPaletteShape(Disk(label: "Disk")),
AnyPaletteShape(Tile(label: "Tile"))
]
let duplicate = result.insert(
AnyPaletteShape(Disk(label: "Disk"))
)
precondition(!duplicate.inserted)
precondition(result.count == 2)
precondition(result.contains(
AnyPaletteShape(Tile(label: "Tile"))
))
let sameLabel: Set<AnyPaletteShape> = [
AnyPaletteShape(Disk(label: "Sample")),
AnyPaletteShape(Tile(label: "Sample"))
]
precondition(sameLabel.count == 2)
return result
}
struct ContentView: View {
private let shapes = makePalette()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Shape Palette")
.font(.title)
ForEach(
shapes.sorted { $0.label < $1.label },
id: \.self
) { item in
HStack(spacing: 16) {
GeometryReader { geometry in
item.path(in: CGRect(
origin: .zero,
size: geometry.size
))
.fill(Color.blue)
}
.frame(width: 56, height: 56)
Text(item.label)
}
}
}
.padding(24)
.frame(minWidth: 300, minHeight: 220)
}
}
@main
struct ShapePaletteApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
PaletteShape 繼承 SwiftUI 實際的 Shape 協定。圓盤與方磚會提供 path(in:),這是 Apple 文件所述的繪圖要求。它們的實作使用有文件說明的 Path 操作建立橢圓與矩形。方形顯示框會讓範例中的橢圓看起來像圓形。Shape、Path。
受約束的初始化器是加入集合的規則:init<S: PaletteShape> 只接受遵循共用協定的型別。儲存屬性是私有的,因此呼叫端無法分別替換保留的形狀或其相等性索引鍵。AnyPaletteShape("Disk") 應該會產生編譯時期的遵循錯誤,因為 String 不遵循 PaletteShape;請勿將這項刻意拒絕的程式碼放入可執行檔案。
雖然集合只有一種具體元素型別,但每個包裝型別都可以保留不同的具體形狀。它的 label 與 path(in:) 成員會轉送此調色盤所需的操作。包裝型別本身宣告遵循 Hashable,但不遵循 Shape。
如何驗證插入、成員資格與顯示順序?
以除錯組建執行 App。內含的檢查表達了三項預期結果:插入第二個相等圓盤時,inserted 應為 false;成員查詢會找到相等的方磚;即使標籤相同,不同元件型別仍會保留。Apple 文件說明,insert(_:) 會同時回傳插入旗標與結果成員。Set。
預期視窗會顯示兩列,依序為 Disk 與 Tile,並呈現藍色圖形。排序是獨立的顯示操作,因為集合沒有定義元素順序。產生的陣列也符合 ForEach 對隨機存取集合的要求;id: \.self 使用包裝型別的可雜湊值作為資料列識別碼。Set、ForEach。
由於提供的範例值標籤各不相同,這種排序方式已足夠。若日後元件使用相同標籤,請新增一個確定性的次要排序欄位。若資料列變成可編輯項目,且標籤變更時仍須保留其識別,請加入穩定識別碼,並另外決定該識別碼是否參與集合相等性。
不要預期集合插入會取代已存在且相等元件的內容。若預期操作是取代,Apple 提供了 update(with:)。如果日後模型不把呈現屬性納入相等性,這項差異就很重要:插入相等值時,集合可能保留既有成員,而不是更新其外觀。Set。
包裝型別的界線在哪裡?
可雜湊儲存不會保留所有 SwiftUI 功能
此包裝型別會轉送靜態調色盤所需的路徑生成操作。它不會轉送底層形狀的可動畫資料、內縮操作,或繼承自 View 的所有成員。因此,繪製回傳的路徑並不代表這是能保留動畫能力的形狀型別擦除器。SwiftUI 的 Shape 也繼承 Animatable 與 View;只實作一個轉送的繪圖方法,並不會重建整份契約。Shape。
若其他 API 需要型別已擦除的具體形狀,SwiftUI 提供了 macOS 13 以上版本可用的 AnyShape。文件將它描述為擦除形狀具體型別的工具,其列出的遵循項目不包含 Hashable。它可以處理繪製邊界,但不會提供此處建立的相等性策略或受協定約束的集合元素。AnyShape。
新的編譯器支援不會取消集合元素約束
在 Swift 5.7 實作的 SE-0352,允許在適當的泛型呼叫中開啟存在型別底層型別。現行文本也記錄了 Swift 6 語言模式中的行為。為呼叫開啟值,並不會讓 any PaletteShape 本身成為適合作為集合元素參數的 Hashable 型別。在不同編譯器世代中使用這種設計時,請保留具體的包裝型別。Implicitly Opened Existentials。
擴充模型時需要重新決定相等性
範例使用不可變結構。新增尺寸或其他儲存設定時,請決定變更這些屬性是否會建立不同的集合成員。自動合成的雜湊會包含新增的可雜湊儲存屬性。自訂相等性時,請依相同順序雜湊完全相同的關鍵組成部分;只有雜湊值相同,永遠不足以證明相等。Hashable。
若應用程式的元件變體永久固定,使用帶有可雜湊關聯值的列舉也是合理的設計:它能集中列出支援的案例。當希望透過相同初始化器加入其他遵循型別,而不必擴充中央案例清單時,包裝型別就很實用。這項選擇應依應用程式的擴充需求而定。
對這個 Mac 調色盤而言,實作為每項需求都提供了明確位置:協定定義可接受的形狀,初始化器強制加入規則,相等性定義重複項目,而路徑轉送則負責繪製。新增不可變的遵循元件時,請在插入時將它們包裝起來;每當重複項目的定義改變,就擴充檢查內容。
來源
- Set — Apple
- AnyHashable — Apple
- Hashable — Apple
- ObjectIdentifier — Apple
- Shape — Apple
- Path — Apple
- ForEach — Apple
- AnyShape — Apple
- Unlock existentials for all protocols — Swift Evolution
- Implicitly Opened Existentials — Swift Evolution