若要在一个集合中存储不同的 SwiftUI 形状,同时强制它们遵循共同接口,可以使用具体的 Hashable 包装类型,并为它提供受协议约束的初始化器。本教程将构建一个小型 Mac 形状调色板,明确规定重复项规则,并提供可调用的形状操作。
任务很具体:收集虚构的圆盘和方块组件,在编译时拒绝无关类型,删除重复项,并在 SwiftUI 窗口中显示剩余组件。集合通过一个共同协议接受多种组件类型,该协议进一步遵循 SwiftUI 的 Shape。
为什么协议值集合不符合这个要求?
Swift 的 Set 要求其元素类型遵循 Hashable。仅仅声明存在类型中的每个值都遵循 Hashable,并不会让这个存在类型本身获得该遵循。因此,即使 PaletteShape 继承了 Hashable,Set<any PaletteShape> 也不会因此自动变得有效。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 支持元类型,因此此示例可以识别泛型输入类型。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 提供了 AnyShape,macOS 13 及更高版本可用。其文档说明的作用是擦除形状的具体类型,而列出的协议遵循中不包括 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