对于显示异构 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 项目中,将生成的应用声明替换为以下完整程序,并确保恰好保留一个 @main 声明。
显式的 @MainActor 注解可确保提供者创建和渲染位于同一个执行器上。Apple 当前的 View 声明隔离于主执行器。其文档还说明,自定义视图通过 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]。将应用的初始数组替换为以下表达式:
[
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。当下游接口需要统一类型时,再将擦除移入具体的提供者包装器。这样一来,无论集合、渲染契约还是编译器配置发生变化,都能明确检查类型擦除边界。
来源
- 不透明类型和装箱协议类型 — Swift 编程语言
- 不透明结果类型 — Swift 演进
- 解锁所有协议的存在类型 — Swift 演进
- 隐式开放的存在类型 — Swift 演进
- View — Apple 开发者文档
- AnyView — Apple 开发者文档
- init(_:) — Apple 开发者文档
- ForEach — Apple 开发者文档
- ViewBuilder — Apple 开发者文档
- ContentBuilder — Apple 开发者文档