ContentBuilder 解析:SwiftUI 类型检查性能提升的秘密

WWDC 2026 的 SwiftUI 新功能 Session 中,苹果工程师介绍了 SwiftUI 的一个新特性——ContentBuilder。从使用方式看,它似乎只是一个覆盖范围更广的 ViewBuilder:过去分别接受 ViewBuilderToolbarContentBuilderCommandsBuilder 的 API,如今可以共享同一个 builder。苹果同时宣称,这项调整能够显著改善类型检查性能。本文将解析 ContentBuilder 的本质,探索性能提升背后的奥妙。

本文对公开接口和实现细节的观察基于 Xcode 27 beta 4。正式版发布后,仍需重新核对 .swiftinterface 中的兼容路径。

变了,又好像什么都没变

苹果说,ContentBuilder 对开发者来说是无感的。当我查看 Xcode 27 的公开接口后,首先看到的是:

Swift
public typealias ContentBuilder = ViewBuilder

ContentBuilder 甚至不是一个新的结果构造器(result builder),而只是 ViewBuilder 的类型别名。这让我十分困惑:难道这就是所谓的“无感”?性能提升究竟从何而来?

共享组件中的类型检查陷阱

结果构造器不是运行时的容器,而是一套编译期的语法转换规则。下面的代码:

Swift
@ViewBuilder
func content() -> some View {
    Text("Hello")
    Image(systemName: "star")
}

可以粗略理解为:

Swift
func content() -> some View {
    let v0 = Text("Hello")
    let v1 = Image(systemName: "star")
    return ViewBuilder.buildBlock(v0, v1)
}

其余的控制流语句同样由一组约定俗成的方法承接:

ifif let#available 和循环则分别由 buildIf(或 buildOptional)、buildEitherbuildLimitedAvailabilitybuildArray 等方法转换。关于这些转换规则,我已在此前的 ViewBuilder 研究(上)ViewBuilder 研究(下) 中作过详细讨论。

随着 SwiftUI 应用的复杂度提升,苹果逐年为它增加了 Scene、Toolbar、Commands、Table 等内容领域,并为每个领域配备了专用的结果构造器。与此同时,GroupForEachSection 这类共享组件也获得了面向不同 builder 的初始化器——它们外形一模一样,只有协议约束不同。

这些共享组件有一个共同特点:它们能跨多个 DSL 使用,并且可以反复嵌套。但问题并不在它们的运行效率上,而在于这一组组“长得一样”的初始化器,会在每一层嵌套上都摆出一道选择题。

以 Xcode 26 中的 Group 为例:

Swift
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 还公开了使用 SceneBuilderAccessibilityRotorContentBuilderTableRowBuilderTableColumnBuilderTabContentBuilder 等 builder 的初始化器。Section 同时存在 ViewBuilderTableRowBuilder 路径,ForEach 也有 ViewBuilderTableRowBuilderTabContentBuilder 等入口。

这种声明方式给编译器造成了极大的困扰:

Swift
Group {
    Group {
        Text("Hello")
    }
}

这段代码对人类开发者毫无难度,一眼便知闭包的最终结果。但编译器要考虑的更多。

刚看到最外层的 Group 时,编译器并不知道闭包最终会生成什么。上面列出的三个初始化器都可能成立:它可能是一个 View,也可能是 ToolbarContentCommands。为了选出正确的那一个,编译器中负责求解类型的模块——约束求解器(constraint solver)——必须继续向闭包内部推进。可闭包里又出现了一个 Group,同样的三个候选再次摆在面前。只有走到最深处的 Text,编译器才获得足够的判断依据。

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 中,新的核心方法大致具有如下形状:

Swift
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: ViewContent: ToolbarContentContent: Commands 约束。builder 的职责变得纯粹——保存单个内容、组合多个内容、记录可选分支与二选一分支。它不再负责回答“这些内容最终属于哪个 SwiftUI 领域”。

新引入的 TupleContent 则是这套设计的关键。它最大的特点是按元素的能力获得不同的身份

Swift
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_ConditionalContentGroupForEach 也都改用了这一思路。

于是,builder 可以先得到唯一且具体的结构:

Text
TupleContent<Text, Image>

当函数声明要求 some View 时,编译器才回过头验证 TextImage 是否都遵循 View。如果同一个结构被工具栏上下文要求遵循 ToolbarContent,编译器就沿另一组条件检查。

旧设计把“构造结构”和“证明身份”交织在每层调用中;新设计则是先构造,后证明。

当然,仅仅放宽 builder 还不够。只要组件仍暴露多组公开初始化器,调用点的选择题就依然存在。因此 SwiftUI 同时改造了共享容器。

Group 为例,新接口可以概括为:

Swift
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 代码的编译时间都会按同等比例下降,更不代表视图创建、更新或渲染等运行时性能得到提升。苹果着力解决的是 GroupForEachSection 这类跨领域、可嵌套的共享组件——它缩短的,是包含这类重载树的表达式的类型检查时间。

为向后部署而保留的低优先级兼容重载,以及 Scene、Table、Tab 等尚未完全统一的领域,仍可能提供专用入口。

调整 API 声明真的有用吗?

为了验证这一推断,我用上述两种声明方式分别构建了 LegacyGroup(旧模型,多构建入口)与 UnifiedContentBuilder(新模型,只保留一个无领域约束的 builder 与初始化器),代码参见 Gist。两者的表达能力完全相同,差别仅在于“判断发生在哪里”。

我分别生成 1 至 11 层嵌套,测试结果如下:

嵌套深度旧模型 scopes新模型 scopes旧模型语义分析新模型语义分析旧模型分配新模型分配
11783.40 ms3.22 ms11.80 MB11.35 MB
3217225.34 ms3.57 ms11.96 MB11.31 MB
66,0674356.56 ms3.79 ms15.34 MB11.35 MB
854,66757518.13 ms3.87 ms43.32 MB11.53 MB
9164,017641.56 s5.04 ms103.01 MB11.76 MB
10492,067714.76 s4.33 ms289.95 MB11.76 MB
111,063,3417810.90 s 后失败5.38 ms607.85 MB11.83 MB

数据通过向 swiftc 传入 -stats-output-dir 采集,scopes 对应统计项中的 Sema.NumConstraintScopes;语义分析时间为单次运行的墙钟时间,会随机器负载浮动;“分配”是 Swift 前端记录的最大分配量,并非进程 RSS。

在第 11 层时,旧模型最终触发了那句熟悉的诊断:

Text
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.31,050,05211.06 s
Xcode 27 beta 4 / Swift 6.418926.97 ms

当然,这不是严格的单变量实验:Swift 编译器也从 6.3.3 升级到了 6.4,不能把全部差值直接归功于 SwiftUI 的 API 调整。但它至少回答了另一个问题:苹果在 WWDC 中展示的组合搜索并非理论推演,在真实的 Xcode 26 SwiftUI 接口上可以稳定复现。

ContentBuilder 并非万能

除了目前只覆盖部分 SwiftUI 结果构造器外,苹果在 TN3211 中也列出了与 ContentBuilder 有关的典型问题。

比如:

Swift
.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。

例如,我们定义一组文章节点:

Swift
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

Swift
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

Swift
@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 中实测通过。

这不只是共享了一个属性包装名。TupleContentOptional_ConditionalContentForEach 也一并被保留了下来——要知道,在此之前,自定义一个功能同等完整的结果构造器,要比上面这些代码复杂得多。

提醒:GroupSection 的内部内容并非都适合第三方遍历。

从 ContentBuilder 中得到的 API 设计启示

ContentBuilder 的意义不止于 SwiftUI。它展示了一种适用于泛型 DSL 的设计原则:当多种领域共享相同的结构操作时,尽量让构造路径保持唯一,把领域能力交给产物的条件遵循去验证。

但这并不意味着“所有约束都应尽量推迟”。如果推迟约束会让重载失去必要的上下文,或让错误提示跨越过大的代码范围,入口处的约束仍有其价值。

真正需要避免的是:为了表达几种相同的结构能力,在每一个嵌套节点上摆出多组外形一致、只有协议约束不同的重载。

SwiftUI 为什么没有从一开始就这样设计?

理清了 ContentBuilder 的调整后,一个疑问始终萦绕:既然思路如此自然,SwiftUI 为什么没有从第一天就这样设计?梳理 SwiftUI 与 Swift 的发展脉络后,我得到了如下推测:

  • SwiftUI 的多个内容领域是逐年增加的,那棵重载树并非最初就存在,而是随功能扩张一层层长出来的;
  • TupleContent 依赖参数包(parameter packs)与可变参数泛型类型,而这项语言能力近年才成熟——在此之前,这套设计根本无从写起;
  • ABI 稳定性与向后部署要求苹果保留兼容路径。这个看似简单的调整,对框架而言意味着大量的测试与准备。

可以说,苹果在 2026 年对开发体验与性能的强调,只是促成 ContentBuilder 落地的最后一推。它更像是一场长期演进后水到渠成的结果。

苹果没有为 ContentBuilder 大书特书,对绝大多数开发者和场景而言,它也确实是无感的。但它对 SwiftUI 乃至 Swift API 设计的影响不容小觑。

必然还有一些“不为人知”但有积极作用的改动尚未被发现,等待我们挖掘。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅