让 SwiftUI 组件以符合该协议的类型为泛型,然后将其存储的视图声明为 Provider.Content。这样可以在整个组件中保留提供者与其关联视图类型之间的关系。
本教程将构建一个小型 macOS 窗口,其中包含两个合成图块。任务是在不将关联类型替换为 any View 或 AnyView 的情况下,存储一个提供者及其生成的视图。当可复用的桌面组件接受自定义标签、附件视图或内容提供者时,也可以采用相同模式。
存储属性必须保留什么关系?
关联类型用于命名由各协议遵循类型自行选择的类型。如果协议声明了 associatedtype Content: View,那么符合该协议的提供者必须提供一种特定的内容类型。即使实现被不透明结果隐藏,泛型代码仍可通过 Provider.Content 引用该类型。Swift 在泛型章节中介绍了关联类型和泛型约束。
因此,该组件需要两个彼此关联的声明:一个受提供者协议约束的泛型参数,以及一个类型为该参数关联类型的内容存储属性。仅仅声明两个各自符合协议的独立属性,并不能表达这种关联。
SwiftUI 本身也采用了相关约定:View 声明了 associatedtype Body: View,Swift 会根据自定义视图的 body 实现推断出该关联类型。提供者的 Content 和组件的 Body 是两个不同的关联类型,不必相同。请参阅 Apple 的 Body 文档。
构建完整的 macOS 示例
本示例使用一个 Xcode macOS App 项目,搭配 SwiftUI,并将部署目标设为 macOS 13 或更高版本。将自动生成的 App 和内容视图声明替换为下面这个单文件,并确保目标中恰好只有一个 @main 声明。Apple 在 App 文档中介绍了这种 App 入口结构以及 WindowGroup 的用法。
此示例特意采用了长期使用的关联类型和命名泛型语法。不透明结果于 Swift 5.1 中实现;后文讨论的可选主关联类型语法则需要 Swift 5.7。完整文件适用于当前的 Swift 6 工具链。它已依据获取到的文档进行检查,但此处未编译,因为当前环境没有 Swift 编译器。所选 macOS 部署目标是示例的前提条件,并不表示每项底层功能最早都由该操作系统版本支持。请参阅 SE-0244 和 SE-0346。
import SwiftUI
@MainActor
protocol TileProvider {
associatedtype Content: View
var title: String { get }
func makeContent() -> Content
}
@MainActor
struct TextTile: TileProvider {
let title: String
let message: String
func makeContent() -> Text {
Text(message)
}
}
@MainActor
struct CircleTile: TileProvider {
let title: String
let caption: String
func makeContent() -> some View {
HStack(spacing: 8) {
Circle()
.fill(Color.blue)
.frame(width: 16, height: 16)
Text(caption)
}
}
}
@MainActor
struct TileCard<Provider: TileProvider>: View {
private let provider: Provider
private let content: Provider.Content
init(provider: Provider) {
self.provider = provider
self.content = provider.makeContent()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(provider.title)
.font(.headline)
content
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.12))
}
}
@MainActor
struct ContentView: View {
var body: some View {
VStack(spacing: 12) {
TileCard(provider: TextTile(
title: "Text tile",
message: "Blue square"
))
TileCard(provider: CircleTile(
title: "Shape tile",
caption: "Blue circle"
))
}
.padding()
.frame(width: 360)
}
}
@main
@MainActor
struct AssociatedViewDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
构建并运行 macOS 目标。预期结果是一个窗口,里面有一个文本图块,以及一个蓝色圆形与说明文字并排显示的图块。两个图块使用相同的泛型组件,且各自调用位置会独立推断提供者类型。
显式添加 @MainActor 注解,可确保这些生成 UI 的声明处于相同的 actor 上下文中。Apple 当前的 View 文档将该协议声明为隔离于主 actor。这里的注解也明确说明了自定义提供者协议预期所处的上下文。
为什么泛型组件能保留类型?
对于第一个卡片,Provider 是 TextTile,因此 Provider.Content 是 Text。对于第二个卡片,Provider 是 CircleTile,其关联内容类型是 CircleTile.makeContent() 的不透明结果。你可以通过 CircleTile.Content 命名该类型,而不必写出嵌套的 SwiftUI 布局类型。
关键赋值是 self.content = provider.makeContent()。协议保证此方法恰好返回 Provider.Content,也就是存储属性声明的类型。这是由泛型约束推导出的编译时关系,而不是运行时类型比较。Swift 的泛型章节解释了如何通过受约束的泛型参数访问关联类型。
在符合协议的实现中使用 some View,不会抹去这种关联。不透明结果会隐藏具体实现,同时保留其类型标识,因此适合作为关联类型见证。Swift 在不透明类型和装箱协议类型中解释了两者的区别。
这些卡片是不同的特化类型:TileCard<TextTile> 和 TileCard<CircleTile>。复用同一个源代码声明,并不意味着每个卡片都必须具有统一的存储内容类型。
为什么独立的不透明属性或存在类型属性无法解决问题?
以下是用于诊断的代码片段,并非完整程序的替代品:
// Diagnostic fragment: no initializer establishes the opaque type.
let content: some View
// Diagnostic fragment: hides which conforming provider is stored.
let provider: any TileProvider
// Diagnostic fragment: stores an existential view value.
let content: any View
不透明存储属性需要一个声明初始化器来确定其底层类型。写上 some View 并不会创建一个由调用方选择、供任意组件初始化器稍后填充的泛型参数。例如,let content: some View = Text("Sample") 会为该声明确定一种类型,却不会让该属性与另一个属性的关联类型产生联系。SE-0244:不透明结果类型记录了这项存储属性规则。
any TileProvider 这样的存在类型允许存储不同的协议符合类型。它会隐藏具体的符合类型,因此声明不再以 Provider.Content 的形式公开两者之间的关系。同样,any View 是一个存在类型容器,并不等同于命名泛型类型符合 View 的约定。请参阅 Swift 对装箱协议类型的说明。
对于某些泛型调用,Swift 可以隐式打开存在类型实参。但这并不意味着每个存在类型都能用于任意泛型初始化器。具体而言,SE-0352 限制了这样一种打开操作:当未知的底层类型会在 X<T> 这样的泛型结果中逃逸时。泛型组件的特化可能恰好会遇到这个限制。构造卡片之前,应先保留提供者的泛型类型,不要假设存在类型总能转回可持久化的特化类型。请参阅 SE-0352:隐式打开存在类型。
根据内容的生成时机选择存储方式
此示例会在每次初始化 TileCard 时调用 makeContent(),并存储返回的视图值。这不会在后续组件初始化之间建立持久缓存。保留这两个属性可以展示所需的类型关系,但如果组件只需要生成的视图,可以省略提供者属性。
如果组件应在每次计算其 body 时请求内容,只需保留提供者。下面的组件声明可替换上方示例中的声明,并与上述协议和 App 配合使用:
@MainActor
struct TileCard<Provider: TileProvider>: View {
private let provider: Provider
init(provider: Provider) {
self.provider = provider
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(provider.title)
.font(.headline)
provider.makeContent()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.12))
}
}
两种设计都能保留关联类型。它们的区别直接取决于方法调用的位置:第一种设计在初始化时调用,第二种设计则在计算 body 时调用。请有意选择调用时机,并确保内容生成逻辑不依赖方法恰好只被调用一次。
调整此示例时,可以参考以下决策指南:
| 组件需求 | 声明策略 | 类型关系 |
|---|---|---|
| 存储提供者及其生成的视图 | 泛型提供者加 Provider.Content |
同时保留提供者标识及其内容类型 |
| 在计算 body 时生成内容 | 存储泛型提供者 | 保留关联类型,但不单独存储内容值 |
| 存储内容类型已知且相同的提供者 | 考虑使用受约束的存在类型 | 保留指定的内容类型相等关系,同时隐藏提供者标识 |
| 有意擦除不同的视图实现 | 在选定的边界使用 AnyView |
存储包装类型,而不是公开原始视图类型 |
主关联类型能否免去泛型?
主关联类型提供了一种较简洁的方式来表达选定的同类型约束。如果你拥有该协议,可以用下面的替代声明头将 Content 设为主关联类型;其余协议要求保持不变:
// Alternative protocol header, requiring Swift 5.7 or later.
@MainActor
protocol TileProvider<Content> {
associatedtype Content: View
var title: String { get }
func makeContent() -> Content
}
使用此声明后,any TileProvider<Text> 表示一种关联内容类型已知为 Text 的存在类型。只有当不同的提供者实现所公开的内容类型完全相等时,它们才能共用这种存储类型。此语法用于约束关联类型,并不会将协议变成泛型结构体。请参阅 SE-0346 和 SE-0353。
这并不能将任意未知的视图类型合并成一种保留具体类型。具体来说,CircleTile 公开的是一个不透明内容结果,因此调用方不能直接将该结果视为 Text。如果希望提供者的关联内容保持不受限制,请使用原来的泛型组件。
受约束的存在类型于 Swift 5.7 中实现,且 SE-0353 明确指出了运行时和向后部署方面的限制。因此,仅有编译器支持不足以证明可选设计兼容目标部署环境。采用该设计前,请检查目标工具链给出的可用性诊断。主示例不依赖受约束的存在类型存储。请参阅 SE-0353 的 ABI 讨论。
编译失败时应检查什么?
在更改布局之前,先从协议要求追踪类型到存储属性:
- 确认协议声明了一个受
View约束的关联类型。 - 确认组件有一个受该协议约束的命名泛型提供者参数。
- 将存储的内容声明为
Provider.Content,使其与方法的返回类型一致。 - 检查父级是否已将提供者转换为
any TileProvider。 - 如果在存储之前添加了修饰符,请检查其结果类型:修改后的视图可能不再是
Provider.Content。 - 将 actor 隔离诊断与关联类型诊断分开检查。
Apple 说明,视图修饰符会将一个视图包装成另一个视图。因此,将 provider.makeContent().padding() 赋给声明为 Provider.Content 的属性,会改变赋值需要满足的约定。可以存储原始内容,并在 body 中应用外观修饰符;也可以重新设计存储类型,使其描述修改后的结果。请参阅 View。
视图构建器也存在版本差异。Apple 当前的文档说明了 Xcode 26 及更早版本中的 ViewBuilder 行为,并指出从 Xcode 27 开始,内容构建行为将有所不同。此示例不要求自定义构建器:每个提供者都会返回一个显式表达式。如果要扩展示例并加入由构建器驱动的提供者 API,请遵循实际用于构建的 Xcode 版本对应的文档。请参阅 ViewBuilder。
如果你有意选择 AnyView,请考虑其文档所述的行为:更改包装视图的类型会销毁旧层级并创建新层级。这是与在存储中保留关联类型不同的设计决策。Apple 在 AnyView 中对此作了说明。
对于此组件,应在存储边界明确保留这种关系:泛型 Provider、类型为 Provider.Content 的结果,以及同样类型的存储属性。这样一来,你可以更改提供者的内部布局,同时让编译器在组件中传递其关联视图类型。
来源
- 泛型 — Swift.org
- 不透明类型和装箱协议类型 — Swift.org
- View — Apple
- Body — Apple
- App — Apple
- ViewBuilder — Apple
- AnyView — Apple
- SE-0244:不透明结果类型 — Swift Evolution
- SE-0346:主关联类型的轻量级同类型要求 — Swift Evolution
- SE-0352:隐式打开存在类型 — Swift Evolution
- SE-0353:受约束的存在类型 — Swift Evolution