我想知道 SwiftUI 到底是什么,而不是教程里说它是什么。所以我从两个互不串通的方向切进去。我像探测任何黑盒一样,不断测量真实框架的行为,直到它变得可预测;然后又逐行阅读了 Apple 自己关于它的专利。已经发布的框架是判准。获批的专利则是 Apple 在描述自己的机器。当两者相互吻合时,你就不再是在猜了。
flowchart LR B["真实框架行为"] --> M["一张需求驱动的图"] P["Apple 获批的专利"] --> M
而且二者几乎逐字段吻合。它们一起描述的东西,比通常的说法更简单也更奇特:不是一棵会被 diff 的视图树,而是一张单一的、由需求驱动的图。我会把这张图展示出来,因为它真的很优雅。然后在最后,我会告诉你为什么它反而是 Apple 技术栈里最不让我感兴趣的部分,以及我为此一直在做什么。先从引擎开始。
视图是值,不是对象
当你写一个 SwiftUI view 时,你并不是在创建屏幕上的某个东西。你是在描述屏幕应该是什么样子,描述方式是一个廉价的 struct,它会被不断丢弃和重建。这一点很容易让人困惑,因为它看起来很浪费。但其实并不是。struct 本来就是故意设计成一次性的。UI 中真正持久存在的部分,完全住在另一个地方。
那个地方就是 attribute graph,而这就是整件事的核心。
Attribute graph
每个 view 都会编译成一张由属性组成的图。一个属性节点只有两样东西:一个缓存值,以及一条根据输入计算这个值的规则。一个 view 的 body 本身就是一个属性,它的规则是“求值这个 body”。
巧妙之处在于边是如何形成的。当一条规则运行并读取另一个属性时,它们之间就会记录一条边。依赖从来不需要声明。它们是通过观察每个 body 在运行时实际读取了什么而发现的。只要求值,你就免费得到了依赖图。
现在,不带任何魔法地解释大家喜欢的响应式行为。你改变一小块状态。SwiftUI 不会重建整个世界。它把这个单一的源属性标记为脏,然后沿着边把 dirty flag 向前传播,只传播给确实依赖它的属性,其他的一概不碰。这个集合就是锥体。随后,当屏幕需要某个值时,它会惰性地 pull。它向下走到脏输入,只重新计算那些节点,缓存结果,再把结果向上冒泡。输入没有变化的属性会直接返回缓存,它的规则不会运行。这就是为什么一个有一千个 view 的屏幕,也只会重新运行发生变化的那个 body。
flowchart TD S["@State count,一个源"] -->|dirty| L["Label.body"] L --> T["Text 属性"] S -.->|没有边,不受影响| O["Other.body"]
锥体就是效率故事的全部。天真的重建会触碰界面里的每一个 body,所以成本会随着屏幕规模增长。图只触碰脏节点和真正依赖它的部分,因此成本跟随的是变化本身,而不是 UI 的大小。
还有一个和性能有关的关键细节:如果一次重新计算得到的值和之前相等,传播就会停在这里。一个没有变化的值不会打扰下游任何东西,所以如果某次状态变化碰巧产生了相同结果,代价几乎为零。
状态与身份
因为 view struct 是一次性的,你的 @State 不能真的住在里面。它住在持久化存储中,并由视图身份作为 key。这个身份是结构性的,也就是视图在树中的类型和位置。显式的 id 并不会覆盖它,而是和它配对:改变标识符,这个位置上的视图就会获得一个新身份;但在别处复用同一个标识符,这两个视图仍然是不同的。
这一条规则解释了很多令人困惑的行为。只要身份稳定,struct 每次重建时你的状态都会保留下来。身份一旦变化——无论是修改 .id,还是移除后重新插入视图——旧状态都会被销毁,并用默认值创建一份新的。这也解释了为什么 List 可以复用行而不丢失它们的状态。状态跟随的是身份,而不是屏幕上的位置。重新排序数据时,每一行的状态会跟着它的标识符一起移动。
布局是一场协商
布局由三步和一条规则组成。父视图向子视图提出一个尺寸。子视图选择自己的尺寸。然后父视图放置子视图。规则是:父视图从不把尺寸强加给子视图。一个 stack 会在子视图之间分配空间,先给最不灵活的子视图它们需要的尺寸,再共享剩下的空间。Text 通过文本引擎测量自身,包含字距调整和字体自己的行度量,所以一个 label 的宽度会刚好等于它需要的宽度。
flowchart TD P1["父视图提出一个尺寸"] --> C1["子视图选择自己的尺寸"] C1 --> P2["父视图放置子视图"]
它如何变成像素,以及人们常搞错的部分
SwiftUI 位于 Core Animation 之上。Core Animation 持有 layer 的状态:position、opacity、transform,也就是 GPU 负责合成、render server 负责插值的那些属性。Core Graphics 则把内容、文本和形状绘制成位图,作为 layer 的 contents。
这里是大多数人会搞错的地方。SwiftUI 并不会为每个 view 创建一个 layer。UIKit 是这么做的,每个 view 都一对一地由 layer 支撑。SwiftUI 会合并。十个文本 label 组成的 stack 通常是一个 layer 加一次绘制,而不是十一个 layer。 只有当一个 view 需要 layer 级别的属性,比如 opacity 或 transform 时,它才会获得自己的 layer。这是 SwiftUI 高效的真实原因之一。一个很长的列表,对 compositor 来说只是少数几个需要管理的 layer,而不是成千上万个。代价是,合并后的内容变化时必须重新绘制;而独立 layer 可以在不重绘的情况下移动。对典型界面来说,这个取舍显然是赚的。
动画,美妙的部分
当你在 withAnimation 里改变一个值时,模型值会直接跳到目标值。它不会慢慢爬过去。相反,框架会复制一份那个目标状态,然后把一个此刻精确插值出的中间值注入到这份副本里。视图绘制的是副本。你的数据已经在最终值上了。仍在运动的只有呈现层。
每个动画都是一条很小的记录:起始值、目标值、时间函数和开始时间。它只需要这些。
flowchart TD R["动画记录"] --> A["起始值"] R --> B["目标值"] R --> C["动画函数"] R --> D["开始时间"]
时间本身只是图的另一个输入。每一帧时钟滴答一次,这个 tick 只会把动画相关的属性标记为脏,然后它们会通过和其他所有东西相同的锥体机制,重新 pull 出一个新的插值。插值作用在 delta vector 上,也就是起始值和目标值之间的差,再按曲线缩放。
弹簧并不是特例。它是一个随时间求解的阻尼谐振子,所以它才会越过目标再稳定下来。而且由于动画只提交一次,render server 会按自己的时钟进行插值,即使你的主线程正忙于别的事情,它也能继续平滑运行。
两行数学就承载了整段运动。呈现出来的值等于起点加上差值,再乘以曲线 $p(t)$;这条曲线从 0 走到 1,因此数据停在目标值上,只有呈现结果在移动:
v(t) = vfrom + (vto - vfrom) p(t)
弹簧也是同一套机制,只是曲线换成了阻尼谐振子。从静止释放时,它的进度曲线是:
p(t) = 1 - e-ζ ω0 t[cos(ωd t) + ζ ω0ωd sin(ωd t)]
它从 0 开始,越过 1,然后回落并稳定在 1。这个越过 1 的部分,正是弹簧会冲过目标再稳定下来的原因。
专利说了什么
US 11,042,388 B2,授权于 2021 年 6 月 22 日,优先权日为 2018 年 6 月 3 日。发明人是 Jacob Xiao、Kyle Macomber、Joshua Shaffer 和 John Harper——也就是 2006 年创造 Core Animation 的那位 John Harper。

John Harper,来自他的 GitHub。
记住这个名字。没有 Steve Jobs,就不会有 Apple 这家公司。而如果没有 John Harper,我们今天所理解的 iPhone,以及建立在它之上的整个流畅、动画化、GPU 合成的王国——iOS、iPadOS、watchOS、tvOS 和 visionOS——也不会以现在这种形态存在。 从 2006 年的 Core Animation,到这篇文章讨论的这张图,运行在这一切底层的引擎都出自同一只手。SwiftUI 只是他浇筑地基的那栋房子里,最新的一间屋子。他是 Apple 的 John Carmack,是 Apple 的 Linus Torvalds:一位工程师,他的作品每天被十亿人触碰,但几乎没人知道他的名字。 这很可惜。而用我自己的小方式纠正这一点,也是我写下这些内容的原因之一。
flowchart LR I["输入,0 到 n 个"] --> F["函数"] F --> O["缓存输出,持久化"] O -.->|重新运行| F
正如上图所示,专利用非常直白的语言描述了 attribute graph。它支持 0 到 n 个输入,对输入应用一个函数来计算输出,把输出存进持久内存结构中;每当任一输入变化时,函数就会重新运行。受影响的属性会设置 dirty bit,树会自底向上遍历,让脏属性触发一次更新。这和我们观察到的行为完全一致,包括 dirty bit 和自底向上的 pull,现在也有了 Apple 自己的文字说明。
它还把动画记录明确写成了行为所暗示的同样四个字段:起始值、目标值、动画函数、开始时间。
flowchart LR T["目标状态,你的数据"] --> Cp["副本"] Cp --> Inj["注入中间值"] Inj --> Rn["渲染副本"]
同样如上图所示,专利把这个方法描述为:生成目标状态的一份副本,并向这份副本注入一个用于渲染的中间值。这正是你可以在真实框架中观察到的模型与呈现的分离。
我不是从专利里得出这些结论的。行为和专利是两个独立证人,而它们抵达的是同一台机器。当你能观察到的框架,和 Apple 获批的专利,描述的是同一个结构时,这个结构就是真实存在的。它是问题本身强迫出来的,不是品味选择出来的。
一张图,一个锥体,惰性绘制
这就是全部。一张由需求驱动的图。状态通过 environment 向下流动,preferences 从子视图向父视图向上流动,时间只是另一个输入;图只重新计算真正发生变化的部分,然后把合并后的 layer tree 交给 Core Animation 去合成。剥掉语法,SwiftUI 就是一张图、一个锥体,惰性地绘制出来。
还有一部分,我只稍微提一下。这件事其实从来不只是关于 SwiftUI。它始于 Core Animation,因为我想理解它,而我唯一信任的理解方式,就是从零开始把一个东西重新造一遍。但你无法在不了解底下绘图层的情况下理解 Core Animation,所以接下来就轮到它了。

一个 text layer 会把你拉进 typesetting。Transitions 会把你拉进 image processing。每个引擎都要求你理解它下面的那个,直到整套栈自己站了起来,而 SwiftUI 几乎像事后的补充一样落在了最上面。这些东西没有一个会发布,也没有一个是为了分享。它们属于我,是我通过重建机器来学习机器的一种方式,而且它有一个名字:SlayerMotion。
flowchart TD SUI["SwiftUI,一张图"] --> CA["Core Animation,合成器"] CA --> CG["Core Graphics,绘图"] CA --> CT["Core Text,排版"] CA --> CI["Core Image,效果"]
在这五者之中,SwiftUI 是最让我提不起兴趣的,而且差距很大。 它甚至不是一开始就被创造来承载平台未来的:它起初只是为了让 watchOS app 变得没那么难写。只有当它已经存在之后,Apple 才看着它意识到自己手里有什么,然后把它带到了所有地方。它带着一个安静的承诺到来:你终于可以跳过这一切,跳过底下那十几个框架,直接发布产品。

这个承诺是假的。你当然可以发布,但你并没有学会这个平台;你学会的是平台之上的一层门面。等到有一天有东西从门面里漏出来——一个怎么都不听话的布局、一段卡顿的动画、一块测量错误的文本——你会发现自己正站在一套从未真正认识过的栈上。那些把 SwiftUI 当作绕过底层框架捷径的人,并没有成为 Apple 开发者。他们成了 SwiftUI 用户,而这是另一种更小的东西。
把 SwiftUI 放在它下面那些引擎旁边看,它确实没有那么有趣,甚至有点无聊:一张薄而整洁的图,做了一件聪明事,然后就让开了。最初让我震撼的那个东西,直到现在仍然最让我震撼,而且我怀疑以后也会一直如此:Core Animation。它是一个合成器,可以在你的 app 忙于别处时,仍然靠自己的时钟保持动画平滑运行。绘图层、文字引擎、图像管线紧随其后。那些艰难、古怪又美丽的问题就在那里,而那才是这个系列要讲的东西。SwiftUI 只是进入这个故事最容易的一扇门。