在 WWDC 2026 的 SwiftUI 新功能 Session 中,苹果工程师介绍了 SwiftUI 的一个新特性——ContentBuilder。从使用方式看,它似乎只是一个覆盖范围更广的 ViewBuilder:过去分别接受 ViewBuilder、ToolbarContentBuilder、CommandsBuilder 的 API,如今可以共享同一个 builder。苹果同时宣称,这项调整能够显著改善类型检查性能。本文将解析 ContentBuilder 的本质,探索性能提升背后的奥妙。
本文对公开接口和实现细节的观察基于 Xcode 27 beta 4。正式版发布后,仍需重新核对
.swiftinterface中的兼容路径。
变了,又好像什么都没变
苹果说,ContentBuilder 对开发者来说是无感的。当我查看 Xcode 27 的公开接口后,首先看到的是:
public typealias ContentBuilder = ViewBuilder
ContentBuilder 甚至不是一个新的结果构造器(result builder),而只是 ViewBuilder 的类型别名。这让我十分困惑:难道这就是所谓的“无感”?性能提升究竟从何而来?
共享组件中的类型检查陷阱
结果构造器不是运行时的容器,而是一套编译期的语法转换规则。下面的代码:
@ViewBuilder
func content() -> some View {
Text("Hello")
Image(systemName: "star")
}
可以粗略理解为:
func content() -> some View {
let v0 = Text("Hello")
let v1 = Image(systemName: "star")
return ViewBuilder.buildBlock(v0, v1)
}
其余的控制流语句同样由一组约定俗成的方法承接:
if、if let、#available 和循环则分别由 buildIf(或 buildOptional)、buildEither、buildLimitedAvailability、buildArray 等方法转换。关于这些转换规则,我已在此前的 ViewBuilder 研究(上) 与 ViewBuilder 研究(下) 中作过详细讨论。
随着 SwiftUI 应用的复杂度提升,苹果逐年为它增加了 Scene、Toolbar、Commands、Table 等内容领域,并为每个领域配备了专用的结果构造器。与此同时,Group、ForEach、Section 这类共享组件也获得了面向不同 builder 的初始化器——它们外形一模一样,只有协议约束不同。
这些共享组件有一个共同特点:它们能跨多个 DSL 使用,并且可以反复嵌套。但问题并不在它们的运行效率上,而在于这一组组“长得一样”的初始化器,会在每一层嵌套上都摆出一道选择题。
以 Xcode 26 中的 Group 为例:
extension Group: View where Content: View {
init(@ViewBuilder content: () -> Content)
}
extension Group: ToolbarContent
where Content: ToolbarContent {
init(@ToolbarContentBuilder content: () -> Content)
}
extension Group: Commands where Content: Commands {
init(@CommandsBuilder content: () -> Content)
}
实际候选远不止这三组。按平台和可用性不同,Xcode 26.6 的 Group 还公开了使用 SceneBuilder、AccessibilityRotorContentBuilder、TableRowBuilder、TableColumnBuilder、TabContentBuilder 等 builder 的初始化器。Section 同时存在 ViewBuilder 与 TableRowBuilder 路径,ForEach 也有 ViewBuilder、TableRowBuilder、TabContentBuilder 等入口。
这种声明方式给编译器造成了极大的困扰:
Group {
Group {
Text("Hello")
}
}
这段代码对人类开发者毫无难度,一眼便知闭包的最终结果。但编译器要考虑的更多。
刚看到最外层的 Group 时,编译器并不知道闭包最终会生成什么。上面列出的三个初始化器都可能成立:它可能是一个 View,也可能是 ToolbarContent 或 Commands。为了选出正确的那一个,编译器中负责求解类型的模块——约束求解器(constraint solver)——必须继续向闭包内部推进。可闭包里又出现了一个 Group,同样的三个候选再次摆在面前。只有走到最深处的 Text,编译器才获得足够的判断依据。
外层 Group
├─ ViewBuilder
│ └─ 内层 Group:View / Toolbar / Commands
├─ ToolbarContentBuilder
│ └─ 内层 Group:View / Toolbar / Commands
└─ CommandsBuilder
└─ 内层 Group:View / Toolbar / Commands
上图只是示意。真实的搜索空间还要叠加
buildBlock自身的多组重载,比图示更为复杂。
这个问题几年前就有人在 Swift 论坛中指出过。它意味着编译器每处理一层嵌套,都要重新做一次入口选择——每深一层,候选数量便乘上一轮。而类似的嵌套结构在真实的 SwiftUI 应用中随处可见,且往往远比示例复杂。
当候选无法被及早排除、失败又发生在嵌套深处时,编译效率会被明显拖累。这正是长期潜伏在 SwiftUI 共享组件 API 中的类型检查风险。
仅靠编译器优化还不够
或许有开发者会提出疑问:这几年苹果常在 WWDC 上宣布通过优化编译器改善了结果构造器的编译性能,难道是假的吗?
当然不是。
Swift 编译器近年针对这一场景确实做了不少工作:让 builder 中的各条语句更多地进行单向、独立的推断,避免把整个闭包变成一个巨大的双向约束系统;以及对多候选的求解路径进行更激进的剪枝。这些改进都实实在在地降低了成本。
但它们无法根治问题。因为旧版共享组件所暴露的多组领域初始化器,才是“每层嵌套都要重新做一次入口选择”的源头。编译器再聪明,也只是在一道本不该出现的选择题上答得更快。
ContentBuilder 究竟做了什么
除了 typealias 指向 ViewBuilder 外,真正带来性能变化的是 API 声明方式的调整——收益来自 SwiftUI 对共享组件初始化器和内容产物的重构。
在 Xcode 27 中,新的核心方法大致具有如下形状:
public static func buildBlock<Content>(
_ content: Content
) -> Content
@available(iOS 27.0, macOS 27.0, *)
public static func buildBlock<each Content>(
_ content: repeat each Content
) -> TupleContent<repeat each Content>
public static func buildIf<Content>(
_ content: Content?
) -> Content?
public static func buildEither<TrueContent, FalseContent>(
first: TrueContent
) -> _ConditionalContent<TrueContent, FalseContent>
请注意:这些签名中没有 Content: View、Content: ToolbarContent 或 Content: Commands 约束。builder 的职责变得纯粹——保存单个内容、组合多个内容、记录可选分支与二选一分支。它不再负责回答“这些内容最终属于哪个 SwiftUI 领域”。
新引入的 TupleContent 则是这套设计的关键。它最大的特点是按元素的能力获得不同的身份:
extension TupleContent: View
where repeat each Content: View {}
extension TupleContent: ToolbarContent
where repeat each Content: ToolbarContent {}
extension TupleContent: Commands
where repeat each Content: Commands {}
换句话说,TupleContent 起初不属于任何领域。只有当它装载的每个元素都是 View 时,它才是一个 View;元素都是 ToolbarContent 时,它就是工具栏内容。Optional、_ConditionalContent、Group 和 ForEach 也都改用了这一思路。
于是,builder 可以先得到唯一且具体的结构:
TupleContent<Text, Image>
当函数声明要求 some View 时,编译器才回过头验证 Text 与 Image 是否都遵循 View。如果同一个结构被工具栏上下文要求遵循 ToolbarContent,编译器就沿另一组条件检查。
旧设计把“构造结构”和“证明身份”交织在每层调用中;新设计则是先构造,后证明。
当然,仅仅放宽 builder 还不够。只要组件仍暴露多组公开初始化器,调用点的选择题就依然存在。因此 SwiftUI 同时改造了共享容器。
以 Group 为例,新接口可以概括为:
public struct Group<Content> { ... }
extension Group {
public init(@ContentBuilder content: () -> Content)
}
extension Group: View where Content: View {}
extension Group: ToolbarContent where Content: ToolbarContent {}
extension Group: Commands where Content: Commands {}
对于已被 ContentBuilder 统一的 View、ToolbarContent、Commands 等领域,普通客户端代码中的 Group { ... } 会优先走这个不带领域约束的通用装配入口。编译器不必再在每一层枚举多个外形相同的初始化器,而是先确定 Content,再让 Group<Content> 通过条件遵循(conditional conformance)获得相应能力。
需要说明的是,这并不意味着所有 SwiftUI 代码的编译时间都会按同等比例下降,更不代表视图创建、更新或渲染等运行时性能得到提升。苹果着力解决的是 Group、ForEach、Section 这类跨领域、可嵌套的共享组件——它缩短的,是包含这类重载树的表达式的类型检查时间。
为向后部署而保留的低优先级兼容重载,以及 Scene、Table、Tab 等尚未完全统一的领域,仍可能提供专用入口。
调整 API 声明真的有用吗?
为了验证这一推断,我用上述两种声明方式分别构建了 LegacyGroup(旧模型,多构建入口)与 UnifiedContentBuilder(新模型,只保留一个无领域约束的 builder 与初始化器),代码参见 Gist。两者的表达能力完全相同,差别仅在于“判断发生在哪里”。
我分别生成 1 至 11 层嵌套,测试结果如下:
| 嵌套深度 | 旧模型 scopes | 新模型 scopes | 旧模型语义分析 | 新模型语义分析 | 旧模型分配 | 新模型分配 |
|---|---|---|---|---|---|---|
| 1 | 17 | 8 | 3.40 ms | 3.22 ms | 11.80 MB | 11.35 MB |
| 3 | 217 | 22 | 5.34 ms | 3.57 ms | 11.96 MB | 11.31 MB |
| 6 | 6,067 | 43 | 56.56 ms | 3.79 ms | 15.34 MB | 11.35 MB |
| 8 | 54,667 | 57 | 518.13 ms | 3.87 ms | 43.32 MB | 11.53 MB |
| 9 | 164,017 | 64 | 1.56 s | 5.04 ms | 103.01 MB | 11.76 MB |
| 10 | 492,067 | 71 | 4.76 s | 4.33 ms | 289.95 MB | 11.76 MB |
| 11 | 1,063,341 | 78 | 10.90 s 后失败 | 5.38 ms | 607.85 MB | 11.83 MB |
数据通过向
swiftc传入-stats-output-dir采集,scopes对应统计项中的Sema.NumConstraintScopes;语义分析时间为单次运行的墙钟时间,会随机器负载浮动;“分配”是 Swift 前端记录的最大分配量,并非进程 RSS。
在第 11 层时,旧模型最终触发了那句熟悉的诊断:
the compiler is unable to type-check this expression in reasonable time
通过不同版本的 Xcode,也能在真实的 SwiftUI 中观察到同样的差异。我让 Xcode 26.6 与 Xcode 27 beta 4 类型检查同一段五层 Section → Group → ForEach 嵌套代码,两次均使用 @ViewBuilder 拼写:
| 工具链 | 约束 scopes | 语义分析 |
|---|---|---|
| Xcode 26.6 / Swift 6.3.3 | 1,050,052 | 11.06 s |
| Xcode 27 beta 4 / Swift 6.4 | 189 | 26.97 ms |
当然,这不是严格的单变量实验:Swift 编译器也从 6.3.3 升级到了 6.4,不能把全部差值直接归功于 SwiftUI 的 API 调整。但它至少回答了另一个问题:苹果在 WWDC 中展示的组合搜索并非理论推演,在真实的 Xcode 26 SwiftUI 接口上可以稳定复现。
ContentBuilder 并非万能
除了目前只覆盖部分 SwiftUI 结果构造器外,苹果在 TN3211 中也列出了与 ContentBuilder 有关的典型问题。
比如:
.overlay(Color.blue.opacity(0.2)) // 在部分上下文中可能因约束放宽而产生重载歧义
.overlay { // 改用闭包形式可恢复明确的 API 上下文
Color.blue.opacity(0.2)
}
另外,同时导入 MapKit 时,Group {} 之类的空 block 可能在不同的空内容类型之间缺少选择依据,此时可显式写出 EmptyView();Charts 在旧部署目标上仍保留专用的兼容 builder 路径,复杂条件分支可以提取到独立的 @ChartContentBuilder 函数中,以缩小类型检查范围。
这些案例共同揭示了一条通则:当 builder 不再替每条表达式预设协议时,那些原本依赖“隐式领域约束”完成重载选择的代码,就需要通过闭包、显式类型或更小的函数边界,把上下文补回来。
ContentBuilder 不只是给 View 使用
理解了苹果的改动后,我们会发现一个有趣的能力:第三方也可以借用 ContentBuilder 构造非 View 的 DSL。
例如,我们定义一组文章节点:
import SwiftUI
protocol ArticleContent {
func render() -> [String]
}
struct Heading: ArticleContent {
let text: String
func render() -> [String] {
["# \(text)"]
}
}
struct Paragraph: ArticleContent {
let text: String
func render() -> [String] {
[text]
}
}
随后,让 ContentBuilder 的结构产物在元素满足要求时遵循 ArticleContent:
extension TupleContent: ArticleContent
where repeat each Content: ArticleContent {
func render() -> [String] {
var output: [String] = []
for element in repeat each content {
output += element.render()
}
return output
}
}
extension Optional: ArticleContent
where Wrapped: ArticleContent {
func render() -> [String] {
self?.render() ?? []
}
}
extension _ConditionalContent: ArticleContent
where TrueContent: ArticleContent,
FalseContent: ArticleContent {
func render() -> [String] {
switch storage {
case let .trueContent(content):
content.render()
case let .falseContent(content):
content.render()
}
}
}
extension ForEach: ArticleContent
where Content: ArticleContent {
func render() -> [String] {
data.flatMap { content($0).render() }
}
}
现在可以直接复用 @ContentBuilder:
@ContentBuilder
func article(showNote: Bool) -> some ArticleContent {
Heading(text: "ContentBuilder")
Paragraph(text: "先装配结构,后验证能力。")
if showNote {
Paragraph(text: "这是一段可选内容。")
}
ForEach(
["移除重载", "条件遵循"],
id: \.self
) {
Paragraph(text: $0)
}
}
以上代码在 Xcode 27 beta 4 中实测通过。
这不只是共享了一个属性包装名。TupleContent、Optional、_ConditionalContent 与 ForEach 也一并被保留了下来——要知道,在此之前,自定义一个功能同等完整的结果构造器,要比上面这些代码复杂得多。
提醒:
Group、Section的内部内容并非都适合第三方遍历。
从 ContentBuilder 中得到的 API 设计启示
ContentBuilder 的意义不止于 SwiftUI。它展示了一种适用于泛型 DSL 的设计原则:当多种领域共享相同的结构操作时,尽量让构造路径保持唯一,把领域能力交给产物的条件遵循去验证。
但这并不意味着“所有约束都应尽量推迟”。如果推迟约束会让重载失去必要的上下文,或让错误提示跨越过大的代码范围,入口处的约束仍有其价值。
真正需要避免的是:为了表达几种相同的结构能力,在每一个嵌套节点上摆出多组外形一致、只有协议约束不同的重载。
SwiftUI 为什么没有从一开始就这样设计?
理清了 ContentBuilder 的调整后,一个疑问始终萦绕:既然思路如此自然,SwiftUI 为什么没有从第一天就这样设计?梳理 SwiftUI 与 Swift 的发展脉络后,我得到了如下推测:
- SwiftUI 的多个内容领域是逐年增加的,那棵重载树并非最初就存在,而是随功能扩张一层层长出来的;
TupleContent依赖参数包(parameter packs)与可变参数泛型类型,而这项语言能力近年才成熟——在此之前,这套设计根本无从写起;- ABI 稳定性与向后部署要求苹果保留兼容路径。这个看似简单的调整,对框架而言意味着大量的测试与准备。
可以说,苹果在 2026 年对开发体验与性能的强调,只是促成 ContentBuilder 落地的最后一推。它更像是一场长期演进后水到渠成的结果。
苹果没有为 ContentBuilder 大书特书,对绝大多数开发者和场景而言,它也确实是无感的。但它对 SwiftUI 乃至 Swift API 设计的影响不容小觑。
必然还有一些“不为人知”但有积极作用的改动尚未被发现,等待我们挖掘。