SwiftUI 渲染
SwiftUI 会关注你代码中的三件事:标识(这是和之前相同的元素吗?)、生命周期(标识持续多久——以及与其绑定的 @State),以及依赖(哪些输入的变化会触发 body 重新运行)。几乎每个“为什么它会重渲染/丢失状态/出现奇怪动画/卡顿”的问题,都源于这三者之一。
模型
- 视图值是短暂的;标识不是。 视图结构体被创建、与前一个值比较、然后丢弃。持久存在的是它的标识,
@State/@StateObject的存储与该标识绑定:当标识发生变化时,状态将被替换,并且onAppear/task会再次运行,图片会重新加载。 - 两种标识。 显式:
ForEach的 ID、Identifiable、.id(_:)。结构式:视图的类型和在层级结构中的位置——if/switch的每个分支都是不同的标识。 - 依赖图。 视图的依赖包括它的存储属性(来自父级的输入)、
@State、@Environment、@Binding,以及其body读取的@Observable属性。当其中一个发生变化时,只有依赖它的视图会获得一个新的body。SwiftUI 然后将新的子视图值与旧的进行比较,并在没有变化的地方停止。 - 失效视图的更新顺序:新值 → 动态属性更新 →
body运行 → 比较子视图 → 递归。
规则
标识
- 切勿使用索引作为标识(
ForEach(Array(x.enumerated()), id: \.offset)或对动态数据使用0..<count)。任何在中间插入的内容都会使所有后续索引偏移:它们的状态会重置、图片会重新加载、动画会指向错误的位置。应使用稳定、唯一的 ID。 - ID 必须稳定且唯一。 不要在计算出的
id中使用UUID()(每次渲染都会产生新的标识)。不要使用可能重复的名称(例如两个“狗饼干”零食)。如果数据无法保证唯一性,则对重复项进行唯一化处理(key、key#2)——ForEach中的重复 ID 会导致行为异常。 - 对于生成或流式内容,从不会移动的结构中派生标识:元素在源文本中的起始位置(流式内容仅追加),或模板位置 + 每次迭代的键(用于程序输出,类似 React 的模型:
template:key)。对于未键化的项目(零散文本、段落),以其前面的键化元素加上计数器来命名(e812+2),这样末尾新增内容永远不会重命名较早的项目。 - 优先使用一个带有变化修饰符的视图,而不是使用分支。
if expired { v.opacity(0.3) } else { v }是两个标识;v.opacity(expired ? 0.3 : 1)则是一个。“惰性”值(opacity 1、padding 0、无尺寸的frame、layoutPriority(0)、transformEnvironment)几乎没有成本。仅在需要完全不同的视图时才使用分支。 - 使用
AnyLayout切换布局,而不是if wrap { FlowLayout { … } } else { HStack { … } }——这样子视图能保持其标识和状态。 - 避免使用
AnyView。 它会向 SwiftUI 隐藏类型结构(导致更差的 diff 性能,并在List/Table中强制构建每一行来计算数量)。使用@ViewBuilder函数、switch和泛型。 - 将元素类型建模为枚举,并在分发视图中对其进行 switch,不要使用
default:这样新添加的 case 就不会被静默忽略。
依赖
- 只给视图传递它需要的数据。 接收整个模型的视图会在任何字段变化时重新运行
body;应传递图片,而不是整个狗的对象。提取子视图来限定依赖范围——要有判断,而不是对每一行都这么做。 @Observable追踪body读取的内容。 在操作闭包中读取属性不会创建依赖;但在body中读取则会。将视图显示的状态放在模型中,并仅在需要的地方在body中读取它。- 切勿在每次渲染时将新的闭包(或其他不可比较的值)放入环境中——例如在
body中写.environment(\.actions, Actions { … })。它无法被比较,因此每个读取者都会在每次父级更新时被无效化。应将一个稳定的对象放入环境中(.environment(model)配合@Observable,并以@Environment(Model.self) var model: Model?方式读取),然后在操作中调用它。 - 当没有变化时,不要通知。
@Observable属性在每次设置时都会发出通知。应进行保护:if new != value { value = new }——当你在每个数据块或节拍上重建派生状态时,这一点至关重要。 - 使用
Equatable视图跳过未变化的子树。 使视图遵循Equatable(仅当它没有属性包装器时才有可能),并在父视图频繁重渲染且子视图大多未变化的场景(例如,在内容流式传输时的块列表)中应用.equatable()。编写自定义的==,仅比较视图显示的内容。 ⚠️.equatable()会向其容器隐藏子视图的layoutPriority。 在一个HStack/行内部,被EquatableView包裹的layoutPriority(1)子视图会停止争夺额外空间,导致该行的布局不同。请在垂直列表的块中使用.equatable(),而不要在依赖优先级的行/堆栈的子元素上使用。
轻量 body
- 在
body中不要进行解析、解码、过滤、排序或大量分配的工作。body会在每次失效时运行。应在模型中预计算,使用缓存(以输入为键的NSCache),或将其提升到其输入实际会变化的视图中。 - 将昂贵的输入与快速变化的状态分离。 如果一个视图既解码数据又持有频繁变化的
@State(如拖拽、滚动、悬停),那么每次状态变化时解码都会重新运行。应在父视图中解码,然后将结果传递给拥有@State的子视图。 - 将耗时工作放在
.task中,而不是init或body中。 使其成为async;切勿在动态属性的初始值中阻塞。 - 缓存昂贵的格式化操作——比如
AttributedString(markdown:)、热路径上的字符串插值、bundle 查找。
列表
List/Table会预先收集每个 ID。 保持 ID 生成的成本低廉,并为每个ForEach元素提供恒定数量的视图——行闭包内不要有if过滤或AnyView(应在模型中过滤数据,而不是在body中内联过滤)。嵌套的ForEach用于分区是可以的。
UIKit 单元格中的 SwiftUI(UIHostingConfiguration)
- 单元格会被重用,SwiftUI 视图状态可能会带入到该单元格显示的下一个项目中。为根视图赋予
.id(item.id),以便不同的项目获得全新的视图树——同时将有状态(选中、展开、选中状态)的用户可见状态存储在模型中,以稳定 ID 为键,而不是存储在@State中,否则这些状态会在重用和滚动时丢失或错误应用。 - 重新配置单元格会重建根视图值。 如果内容由被观察的模型驱动,请使根视图在其显示的内容上是
Equatable的(而不是在其仅用于输入模型的原始输入上),并应用.equatable(),这样按更新重新配置就不会重新运行整个视图树——而由观测机制完成实际的更新。 - 自适应布局使用开放高度进行测量。 没有理想尺寸的视图在那里会坍塌为零:如
Canvas、形状、GeometryReader。.aspectRatio(r, contentMode: .fit)是不够的——需要用一个小的自定义Layout为它们指定尺寸(宽度来自提案,高度 = 宽度 / 比例;忽略探测高度)。 .aspectRatio(nil, contentMode:)会将视图锁定到其内容的理想尺寸——卡片会停止填充其所在的行。仅在你明确需要一个比例时应用.aspectRatio。
审查清单
- 每个
ForEach都有稳定、唯一的 ID——没有\.offset,没有计算出的UUID()。 - 没有
AnyView;仅在真正需要不同视图时使用分支;使用AnyLayout切换布局。 - 视图只接收它们渲染的数据。
- 环境中没有在每次渲染时创建不可比较的对象。
-
body不做解析/解码/过滤;昂贵的输入与快速的@State分离。 - 不会将
@Observable属性重新设置为相等的值。 -
.equatable()用在能节省工作的地方——并且不用在使用了layoutPriority的子视图上。 - 托管的单元格:根视图
.id(item.id),可见状态在模型中,Equatable 根视图。 - 没有理想尺寸的视图在被放入自适应布局单元格之前被赋予尺寸。
验证
let _ = Self._printChanges()放在body顶部,会记录其运行的原因(@self、@identity或发生变化的属性)。仅用于调试——发布前请移除。- Instruments → SwiftUI 模板 用于查看 body 数量、慢速更新;Hangs/Hitches 用于查看效果。
- 标识测试:渲染流式内容的每个前缀,并断言每个已完成项目的 ID 在最终渲染中保持不变;在交互后,断言存活的元素保持了它们的 ID;断言没有重复的兄弟 ID。
- 渲染重构前后的像素差异比较:在 macOS 上使用
ImageRenderer(相同代码,frame(width:),proposedSize高度为 nil)离屏渲染视图并比较 PNG。这能捕获布局回归,比如上述EquatableView/layoutPriority问题。(Map、ScrollView和远程图片无法离屏渲染;比较其他所有内容。)通过一次切换一个更改的文件来区分差异。
参考文献
references/case-study.md——AddToChatMenu 回复渲染器(流式、模型生成的标记在集合视图单元格中):根据这些规则找到的每个问题及其修复方法。- WWDC21 Demystify SwiftUI(session 10022);WWDC23 Demystify SwiftUI performance(session 10160)。