在 Mac 应用中创建可复用的 SwiftUI 面板时,应将主要内容构建器放在首位,为其余构建器添加标签,并使用受约束的初始化器处理省略的区域。这样可以让调用保持清晰,并为缺省的页眉和页脚指定明确的类型。
本教程将构建一个包含内容、页眉和页脚区域的小型组件。目标是支持四种实用调用形式——仅有内容、内容加任一附属区域,以及内容加两个附属区域——而不依赖相互竞争的默认闭包。所有显示的值都是合成的本地文本。
什么样的多构建器初始化器才没有歧义?
Swift 的多个尾随闭包语法会省略第一个尾随闭包的参数标签,并要求后续尾随闭包使用标签。因此,组件应让第一个构建器的用途从组件名称中一目了然,并为后续构建器设置不同的标签。该语法在 Swift 5.3 中实现。SE-0279:多个尾随闭包
对于此面板,完整初始化器是 init(content:header:footer:)。在调用处,第一个代码块提供内容;header: 和 footer: 用于标识其他代码块。声明顺序决定参数顺序,而组件的 body 决定视觉顺序。因此,即使页眉显示在内容上方,内容仍然可以作为第一个参数。
Apple 当前的 Section 接口采用了这种内容优先的排列方式。其完整初始化器接受三个构建器闭包,并通过单独的初始化器支持省略区域的各种组合。这是一个有参考价值的接口范例;下方的自定义面板有自己的布局和行为。Apple:Section,Apple:init(content:header:footer:)
构建器属性和参数标签解决的是不同问题。结果构建器属性会转换所提供闭包的主体;参数标签则决定调用者如何标识参数。添加 @ViewBuilder 并不会在尾随闭包语法省略第一个标签时恢复该标签。SE-0289:结果构建器
由此可以得出一条实用的设计规则:避免创建多个同级初始化器,因为一旦省略第一个尾随闭包的标签,它们唯一有用的区别就消失了。例如,两个其他方面完全相同、分别名为 init(content:header:) 和 init(body:header:) 的初始化器,都会呈现为一个无标签的首个代码块,后接 header:。这是尾随闭包规则带来的设计后果,并不意味着应该添加更多构建器注解。
哪些编译器和 SwiftUI 边界需要注意?
需要关注三个方面:编译器、所选的 Swift 语言模式,以及 SwiftUI SDK。多个尾随闭包在 Swift 5.3 中推出,而正式的结果构建器功能在 Swift 5.4 中实现。这些里程碑描述的是语言支持,并不代表每个框架初始化器都已可用。SE-0279,SE-0289
Apple 当前的文档还划定了工具链边界:Xcode 26 及更早版本使用既有的视图构建行为,而 Xcode 27 及更高版本会从这些构建器构造类型无关的内容。对于较新的行为,Apple 建议使用 @ContentBuilder 这一写法。ContentBuilder 是 ViewBuilder 的类型别名;可接受的内容仍由外围 API 的约束决定。Apple:ViewBuilder,Apple:ContentBuilder
下面的完整示例使用 @ViewBuilder 写法,以 Xcode 26 为基准,并面向 macOS 11 或更高版本。采用文档所述的 Xcode 27 接口风格时,请将初始化器参数注解替换为 @ContentBuilder。保留三个泛型 View 约束:该组件会将所有三个区域都放入视图布局中。
避免在组件契约中指定构建器生成的元组类型。Apple 记录了采用较新构建器实现时,显式 TupleView 约束会导致源代码不兼容。通过泛型区域类型,此面板可以避免依赖该类型。Apple:TN3211,为 State 和 ContentBuilder 解决 SwiftUI 源代码不兼容问题
为面板构建四种明确的初始化器形式
为每个区域使用独立的泛型类型。复用同一个泛型参数会要求这些区域具有相同的具体类型;当一个区域包含单个文本视图,而另一个区域包含多个视图时,这种限制没有必要。
对于缺省区域,将其泛型参数特化为 EmptyView。Apple 明确说明了 EmptyView 的这一用途:在泛型视图类型中表示缺省的子视图。下面受约束的初始化器会明确指定这一选择,而不是让调用者推断未指定的附属区域类型。Apple:EmptyView
本节中的两个 Swift 代码块共同组成一个完整的应用文件。在 macOS SwiftUI 应用目标中,用这两个代码块替换自动生成的应用和内容视图声明,并且只保留一个 @main 声明。第一个代码块定义组件和全部四种初始化器形式。
import SwiftUI
struct BuilderPanel<Content: View, Header: View, Footer: View>: View {
private let content: Content
private let header: Header
private let footer: Footer
init(
@ViewBuilder content: () -> Content,
@ViewBuilder header: () -> Header,
@ViewBuilder footer: () -> Footer
) {
self.content = content()
self.header = header()
self.footer = footer()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
content
footer
}
.padding()
}
}
extension BuilderPanel where Footer == EmptyView {
init(
@ViewBuilder content: () -> Content,
@ViewBuilder header: () -> Header
) {
self.init(
content: content,
header: header,
footer: { EmptyView() }
)
}
}
extension BuilderPanel where Header == EmptyView {
init(
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.init(
content: content,
header: { EmptyView() },
footer: footer
)
}
}
extension BuilderPanel where Header == EmptyView, Footer == EmptyView {
init(@ViewBuilder content: () -> Content) {
self.init(
content: content,
header: { EmptyView() },
footer: { EmptyView() }
)
}
}
完整初始化器会在面板值初始化时对每个构建器调用一次,并存储返回值。便捷初始化器会委托给它。这是此实现的调用时机特性:它并不保证构建器在界面的整个生命周期中只运行一次,因为再次创建面板值时会重新调用其初始化器。
将第二个代码块追加到同一个文件中。它演示所有受支持的形式,包括在一个构建器中使用多条语句。
struct PanelDemo: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
BuilderPanel {
Text("Canvas A")
}
BuilderPanel {
Text("Canvas B")
Text("Grid: 12 columns")
} header: {
Text("Layout")
}
BuilderPanel {
Text("Canvas C")
} footer: {
Text("Units: points")
}
BuilderPanel {
Text("Canvas D")
} header: {
Text("Preview")
} footer: {
Text("Scale: 100 percent")
}
}
.padding()
}
.frame(minWidth: 360, minHeight: 440)
}
}
@main
struct BuilderPanelDemoApp: App {
var body: some Scene {
WindowGroup {
PanelDemo()
}
}
}
在 Xcode 中构建并运行应用,以便根据所选 SDK 验证契约。此处检查了代码清单,但未进行编译或执行;研究环境既没有 Swift 编译器,也没有 macOS SwiftUI SDK。
将每种调用形式对应到具体类型契约
实用的决策辅助工具是调用形式表。它说明应选择哪个初始化器,以及缺省区域会获得什么具体类型。这些是根据示例声明推导出的预期,可在项目中检查。
| 提供的区域 | 初始化器标签 | 缺省区域约束 |
|---|---|---|
| 仅内容 | content: |
Header == EmptyView、Footer == EmptyView |
| 内容和页眉 | content:header: |
Footer == EmptyView |
| 内容和页脚 | content:footer: |
Header == EmptyView |
| 全部三个区域 | content:header:footer: |
无 |
每个较短的初始化器都要求提供其声明的所有闭包。没有默认参数会让三构建器初始化器与仅内容调用发生冲突。仅页眉和仅页脚的附属区域形式通过第二个标签区分,即使两个闭包都返回 Text 也是如此。
修改组件时可使用此表:如果新增的初始化器接受某种已有调用形式,请检查第一个尾随标签省略后,新区别是否仍然可见。这样可以在接口重叠演变成大型视图中难以诊断的问题之前发现它。
为什么要避免多个带默认值的构建器闭包?
默认参数很有用,但多个带默认值的闭包参数会引入依赖 Swift 语言模式的匹配规则。SE-0286 记录了向前匹配、用于跳过默认参数的兼容性启发式规则,以及旧模式中的向后匹配回退行为。该提案还记录了 ForwardTrailingClosures 未来功能标志,它在 Swift 5.8 中实现。SE-0286:尾随闭包的向前扫描匹配
这里的实用建议有意比这些规则范围更窄:小型可复用面板可以避免依赖这些规则。让主要构建器保持必需,并通过受约束的初始化器表达受支持的省略形式。这样,声明中会明确展示受支持的组合,也避免将依赖编译器版本的匹配行为作为组件预期含义的一部分。
省略附属区域和提供空的附属区域闭包,也是两种不同的接口选择。仅内容初始化器会为两个附属区域明确指定 EmptyView。提供空构建器意味着由 SDK 的构建器实现来确定闭包的结果。对于此组件,请省略未使用的区域,并让便捷初始化器提供文档规定的缺省类型。
这种做法也有规模限制。当内容始终必需时,三个区域会产生四种实用组合。拥有许多彼此独立的可选区域的组件则需要数量多得多的重载。在添加这些重载之前,应先确定读者实际需要哪些组合;较少但有意义的组件变体可能更易使用和维护。
如何诊断有歧义的调用?
先从能复现问题的最简单面板调用开始。然后按以下顺序检查,将参数匹配问题与构建器内部的错误区分开来。
- 将所有闭包参数写在括号内。 按声明顺序写出
content:、header:和footer:。如果这样能解决调用问题,就检查尾随闭包语法省略了哪处区别。 - 暂时将每个区域替换成一个
Text表达式。 如果此时调用成功,就逐个恢复原始表达式,以定位出错的构建器主体。 - 比较初始化器形式。 检查是否存在相互竞争的默认值、顺序相反的闭包,以及仅通过第一个构建器标签区分的重载。
- 检查缺省区域约束。 每个省略的泛型区域在选定的初始化器中都应有预期的具体类型。
- 检查工具链边界。 升级到 Xcode 27 后,检查显式构建器结果约束和冲突的类型名称,然后再重新设计初始化器系列。
在括号内书写标签可以保留尾随闭包语法省略的参数名称。Swift 同时支持两种形式,因此完全带标签的形式既适合用于诊断,也适合作为最终写法,尤其是在它能让特定调用更易读时。《Swift 编程语言》:闭包
对于较新的 SDK 诊断,Apple 特别建议为跨模块冲突的类型添加模块限定,并更新依赖旧版元组输出类型的约束。这些问题可能出现在其他方面设计良好的初始化器调用内部;增加更多重载无法解决根本问题。Apple:TN3211
清晰的初始化器契约始于组件必须支持的调用形式。将主要内容放在首位,为附属构建器添加有意义的标签,并为缺省区域指定明确类型。然后使用所选的编译器、语言模式和 SDK 构建每种受支持的调用形式,再扩展接口。
来源
- 多个尾随闭包 — SE-0279 — Swift 项目。
- 尾随闭包的向前扫描匹配 — SE-0286 — Swift 项目。
- 结果构建器 — SE-0289 — Swift 项目。
- 闭包 —《Swift 编程语言》 — Swift 项目。
- Section — Apple。
- init(content:header:footer:) — Apple。
- ViewBuilder — Apple。
- ContentBuilder — Apple。
- EmptyView — Apple。
- TN3211:为 State 和 ContentBuilder 解决 SwiftUI 源代码不兼容问题 — Apple。