Communitygithub.com

gtokman/skills

深入解析 SwiftUI 的渲染机制——标识、生命周期和依赖——并教你如何编写高效更新的视图,尤其适用于托管在 UIKit 单元格中的 SwiftUI(UIHostingConfiguration),以及从流式或生成内容重建的视图。在编写或审查 SwiftUI 视图的性能、修复状态丢失、闪烁、过度重绘或图片重新加载、选择 ForEach 标识符、在集合/表格视图单元格中托管 SwiftUI,或调试因视图包裹而改变的布局时使用。基于 WWDC21“Demystify SwiftUI”和 WWDC23“Demystify SwiftUI performance”,并辅以实践验证的经验。

skills 是什么?

该技能提供了关于 SwiftUI 渲染机制的全面指导,重点解决开发者常见的性能问题,如视图不必要的重渲染、状态丢失、动画异常和布局偏差。它基于一个核心模型:视图值是短暂的,标识是持久的;依赖关系决定了哪些输入的变化会触发布局更新。技能涵盖了两种标识类型(显式与结构式)、依赖图的工作原理,以及更新顺序,并提供了清晰、可操作的规则。 规则主要围绕标识管理、依赖管理、`body` 计算优化和列表/表格使用展开。例如,它禁止使用索引作为 `ForEach` 的标识,推荐使用稳定唯一的 ID;优先使用单一视图配合变化修饰符,而非分支结构;传递视图所需的最小数据,避免传递整个模型;将解析、解码等重任务移出 `body`;以及为 `UIHostingConfiguration` 提供专门的最佳实践,处理单元格重用和自适应性布局。 该技能以 WWDC 官方内容为基础,并融合了实际开发中的经验教训,提供了一份详尽的审查清单和调试技巧,帮助开发者在从流式内容生成到复杂列表的多种场景中,系统地定位和解决 SwiftUI 性能瓶颈。它同时考虑到了跨平台使用(如 macOS 上的像素级对比测试),和避免 `EquatableView` 与 `layoutPriority` 冲突等易被忽视的细节。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/gtokman/skills/tree/HEAD/swiftui-rendering

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

SwiftUI 渲染

SwiftUI 会关注你代码中的三件事:标识(这是和之前相同的元素吗?)、生命周期(标识持续多久——以及与其绑定的 @State),以及依赖(哪些输入的变化会触发 body 重新运行)。几乎每个“为什么它会重渲染/丢失状态/出现奇怪动画/卡顿”的问题,都源于这三者之一。

模型

  • 视图值是短暂的;标识不是。 视图结构体被创建、与前一个值比较、然后丢弃。持久存在的是它的标识,@State / @StateObject 的存储与该标识绑定:当标识发生变化时,状态将被替换,并且 onAppear / task 会再次运行,图片会重新加载。
  • 两种标识。 显式:ForEach 的 ID、Identifiable、.id(_:)。结构式:视图的类型和在层级结构中的位置——if / switch 的每个分支都是不同的标识。
  • 依赖图。 视图的依赖包括它的存储属性(来自父级的输入)、@State、@Environment、@Binding,以及其 body 读取的 @Observable 属性。当其中一个发生变化时,只有依赖它的视图会获得一个新的 body。SwiftUI 然后将新的子视图值与旧的进行比较,并在没有变化的地方停止。
  • 失效视图的更新顺序:新值 → 动态属性更新 → body 运行 → 比较子视图 → 递归。

规则

标识

  1. 切勿使用索引作为标识(ForEach(Array(x.enumerated()), id: \.offset) 或对动态数据使用 0..<count)。任何在中间插入的内容都会使所有后续索引偏移:它们的状态会重置、图片会重新加载、动画会指向错误的位置。应使用稳定、唯一的 ID。
  2. ID 必须稳定且唯一。 不要在计算出的 id 中使用 UUID()(每次渲染都会产生新的标识)。不要使用可能重复的名称(例如两个“狗饼干”零食)。如果数据无法保证唯一性,则对重复项进行唯一化处理(key、key#2)—— ForEach 中的重复 ID 会导致行为异常。
  3. 对于生成或流式内容,从不会移动的结构中派生标识:元素在源文本中的起始位置(流式内容仅追加),或模板位置 + 每次迭代的键(用于程序输出,类似 React 的模型:template:key)。对于未键化的项目(零散文本、段落),以其前面的键化元素加上计数器来命名(e812+2),这样末尾新增内容永远不会重命名较早的项目。
  4. 优先使用一个带有变化修饰符的视图,而不是使用分支。 if expired { v.opacity(0.3) } else { v } 是两个标识;v.opacity(expired ? 0.3 : 1) 则是一个。“惰性”值(opacity 1、padding 0、无尺寸的 frame、layoutPriority(0)、transformEnvironment)几乎没有成本。仅在需要完全不同的视图时才使用分支。
  5. 使用 AnyLayout 切换布局,而不是 if wrap { FlowLayout { … } } else { HStack { … } }——这样子视图能保持其标识和状态。
  6. 避免使用 AnyView。 它会向 SwiftUI 隐藏类型结构(导致更差的 diff 性能,并在 List / Table 中强制构建每一行来计算数量)。使用 @ViewBuilder 函数、switch 和泛型。
  7. 将元素类型建模为枚举,并在分发视图中对其进行 switch,不要使用 default:这样新添加的 case 就不会被静默忽略。

依赖

  1. 只给视图传递它需要的数据。 接收整个模型的视图会在任何字段变化时重新运行 body;应传递图片,而不是整个狗的对象。提取子视图来限定依赖范围——要有判断,而不是对每一行都这么做。
  2. @Observable 追踪 body 读取的内容。 在操作闭包中读取属性不会创建依赖;但在 body 中读取则会。将视图显示的状态放在模型中,并仅在需要的地方在 body 中读取它。
  3. 切勿在每次渲染时将新的闭包(或其他不可比较的值)放入环境中——例如在 body 中写 .environment(\.actions, Actions { … })。它无法被比较,因此每个读取者都会在每次父级更新时被无效化。应将一个稳定的对象放入环境中(.environment(model) 配合 @Observable,并以 @Environment(Model.self) var model: Model? 方式读取),然后在操作中调用它。
  4. 当没有变化时,不要通知。 @Observable 属性在每次设置时都会发出通知。应进行保护:if new != value { value = new }——当你在每个数据块或节拍上重建派生状态时,这一点至关重要。
  5. 使用 Equatable 视图跳过未变化的子树。 使视图遵循 Equatable(仅当它没有属性包装器时才有可能),并在父视图频繁重渲染且子视图大多未变化的场景(例如,在内容流式传输时的块列表)中应用 .equatable()。编写自定义的 ==,仅比较视图显示的内容。 ⚠️ .equatable() 会向其容器隐藏子视图的 layoutPriority。 在一个 HStack/行内部,被 EquatableView 包裹的 layoutPriority(1) 子视图会停止争夺额外空间,导致该行的布局不同。请在垂直列表的块中使用 .equatable(),而不要在依赖优先级的行/堆栈的子元素上使用。

轻量 body

  1. 在 body 中不要进行解析、解码、过滤、排序或大量分配的工作。 body 会在每次失效时运行。应在模型中预计算,使用缓存(以输入为键的 NSCache),或将其提升到其输入实际会变化的视图中。
  2. 将昂贵的输入与快速变化的状态分离。 如果一个视图既解码数据又持有频繁变化的 @State(如拖拽、滚动、悬停),那么每次状态变化时解码都会重新运行。应在父视图中解码,然后将结果传递给拥有 @State 的子视图。
  3. 将耗时工作放在 .task 中,而不是 init 或 body 中。 使其成为 async;切勿在动态属性的初始值中阻塞。
  4. 缓存昂贵的格式化操作——比如 AttributedString(markdown:)、热路径上的字符串插值、bundle 查找。

列表

  1. 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)。

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

相关技能