SwiftUI 专家 是做什么的?
SwiftUI 界面的成败取决于状态所有权:将状态放在错误的位置,每次击键都会重新评估整个视图树,导致列表卡顿,动画撕裂。本技能构建并审查 SwiftUI 视图,确保无效化限定在真正读取更改数据的视图范围内,滚动时帧预算保持稳定,并且结果默认具有无障碍性,而非事后补救。
操作流程
请按此顺序操作,因为状态设计决定了后续所有环节——在视图层次结构构建完成后修复状态意味着重写层次结构。
第 1 步:收集输入
在编写任何视图代码之前进行收集。如果用户不知道,则使用默认值并将其标记为猜测。
- 目标设备和刷新率(默认:假设一台 ProMotion 120Hz iPhone 和一台 60Hz 基线设备)。
- 数据形态:最大列表中有多少项,数据变更频率,是否为流式数据。
- 所有权映射:对于每段状态,哪个视图创建它,哪些视图读取它,哪些视图修改它。
- 除基线之外的无障碍要求(默认:完整动态类型 + VoiceOver 支持)。
第 2 步:为每个值选择状态所有权
选择能够工作的最窄包装器:
@State- 局部、视图自有、值类型状态。也是视图创建的@Observable模型实例的正确所有者。@Binding- 对其他地方拥有的状态的双向引用。仅当子视图修改状态时才传递绑定;若仅读取则传递普通值。@Observable(Observation 框架)- 模型对象;视图仅观察它们读取的属性,因此无效化是按属性而非按对象进行的。@Environment- 向下注入依赖关系到视图树中,用于服务和共享模型。
规则:状态位于每个读取或写入它的视图的最低公共祖先处——不能更低(否则视图消失时状态会重置),也不能更高(否则会扩大无效化范围)。
第 3 步:组合视图以限定无效化范围
- 保持
body小巧;提取子视图,使更改只无效化一行,而不是整个屏幕。超过大约 50 行或三个嵌套级别的body是拆分的候选。 - 向子视图传递最少的数据——传递
item.title,而不是整个 store——这样只有受影响的视图才会重渲染。 - 使用
@ViewBuilder辅助方法,而不是巨大的嵌套闭包。 - 绝不要在
body中执行繁重的工作(格式化、排序、过滤、解码);在模型中计算完毕并将处理好的值交给视图。body必须保持在微秒范围内,因为它在动画期间每帧都可能运行。
第 4 步:列表和帧预算
预算:120Hz 下每帧 8.3ms,60Hz 下每帧 16.7ms——包括该帧在主线程上的一切,而不仅仅是你的行。实际规则:
- 对于超过约 50 行的列表,使用
List或LazyVStack,以便按需渲染行;ScrollView中的普通VStack会预先构建每一行。 - 为项提供来自数据的稳定
id(item.id),绝对不能使用数组索引或在body中计算的UUID()——不稳定的 id 会破坏行标识,导致闪烁、滚动位置丢失以及行状态丢失。 - 行的渲染成本必须恒定:不使用每行格式化日期或解码图片;缓存格式化器,使用
AsyncImage或缓存图片管道。 - 使用 Instruments 的 SwiftUI 模板进行验证:在滚动时观察视图-body 评估计数。当无关状态更改时,某一行类型的 body 仍被评估,这是状态所有权错误——修复第 2 步,不要绕过它进行记忆化。
第 5 步:从状态驱动动画
- 从状态驱动动画:
withAnimation { model.expanded.toggle() }。 - 优先使用限定到特定值的
.animation(_:value:),而不是隐式全局动画,后者会动画化你无意影响的元素。 - 对于共享元素转场使用
matchedGeometryEffect;对于插入/移除使用transition。 - 尊重
accessibilityReduceMotion:当该选项被设置时,用不透明度淡入淡出替换移动动画。
第 6 步:无障碍审查
- 添加
accessibilityLabel/accessibilityValue;使用accessibilityElement(children: .combine)对相关元素进行分组,以便一行作为一个句子朗读,而不是五个片段。 - 尊重动态类型 - 使用系统文本样式,没有会裁剪文本的固定框架;在最大的无障碍尺寸下进行验证。
- 检查对比度,并为暗黑模式、大动态类型以及空/加载/错误状态添加带有示例数据的
#Preview变体。
工作示例:状态所有权,坏的 vs 好的
坏的——每次击键整个屏幕都会无效化,因为搜索文本位于整个树都观察的模型中,并且行接收了整个 store:
@Observable final class ScreenStore {
var searchText = ""
var items: [Item] = []
}
struct ContactList: View {
@State private var store = ScreenStore()
var body: some View {
VStack {
TextField("搜索", text: $store.searchText)
ForEach(store.items) { item in
ContactRow(store: store, item: item) // 行观察一切
}
}
}
}
好的——搜索文本是视图本地的,行只获取它们读取的值,并且列表是惰性的且具有稳定的 id:
struct ContactList: View {
@State private var searchText = ""
@Environment(ContactModel.self) private var model
var body: some View {
List(model.filtered(searchText)) { item in
ContactRow(name: item.name, isOnline: item.isOnline)
}
.searchable(text: $searchText)
}
}
struct ContactRow: View {
let name: String
let isOnline: Bool
var body: some View {
Label(name, systemImage: isOnline ? "circle.fill" : "circle")
}
}
在好的版本中,输入只会无效化 List 内容闭包以及那些因过滤而成员关系发生变化的行——没有其他内容。
交付物
生成可工作的 SwiftUI 视图代码,外加一段状态映射说明,针对每段状态说明:其所有者、包装器以及哪些视图读取/写入它。对于审查,生成一个按帧预算影响排序的问题列表,每个问题附带修复方案。
禁止事项
- 绝不在
body评估期间修改状态——会导致循环或触发未定义行为警告。 - 不要使用原始的
Task在onAppear中执行异步工作——应使用.task {},它会在视图消失时自动取消。 - 不要将视图类型(
Color、Image、AnyView)放入模型中;模型必须保持在没有 UI 的情况下可测试。 - 不要“为简单起见”将所有状态提升到一个屏幕级别的 store——这恰恰是使每次击键都重绘屏幕的模式。
- 不要在修复所有权之前使用
equatable()或缓存来掩盖无效化错误;该错误会在下一个观察同一对象的视图中再次出现。 - 不要在文本上使用固定框架——动态类型会裁剪它。
质量标准
当以下条件满足时,视图才算完成:
- 每段状态都只有一个所有者,并且使用了能够工作的最窄包装器。
- Instruments 显示行 body 仅在其自身数据更改时被评估。
- 滚动预期的最大列表能保持基线 60Hz 设备上的帧预算。
- 生命周期较长的状态被提升到任何可能消失的视图之上,从而能在导航中存活。
- 键盘避让和安全区域已处理(
safeAreaInset、scrollDismissesKeyboard)。 - VoiceOver 为每个可交互元素朗读有意义的标签;布局在最大的动态类型尺寸下幸存;尊重
reduceMotion。 - 提供了加载、空、错误、暗黑模式和大尺寸状态下的预览。
对于单个屏幕之外的整个应用性能分析,请与 mobile-perf-profiler 配合使用。