“滚动时偶尔卡一下”是症状,不是诊断;“SwiftUI 重绘太多”也不是可以直接修改的结论。一次 hitch 可能来自图片解码、同步 I/O、昂贵布局、过宽的 Observation 依赖,也可能只是一个很小的 body 被计时器高频触发。只有把具体交互、SwiftUI 更新区间和触发它的状态变化连接起来,优化才不会变成随意添加 EquatableView、drawingGroup 或缓存。
本文面向已经能用 Instruments 录制 App、但面对 SwiftUI 更新仍只会看 CPU 百分比的开发者。稳定基线为 Xcode 26.6;Xcode 26 提供的新一代 SwiftUI Instrument 与 Cause & Effect Graph 已在 Xcode 26 release notes 和 WWDC25 Session 中公开。正文精确代码映射到 InstrumentsExamples.swift,通过 macOS 与通用 iOS Simulator 编译。本站没有 App trace,实测提升与因果归因仍是 source-reviewed。
先把“慢”变成可重复场景
不要从随意浏览开始录制。定义一段短而确定的动作:用固定 fixture 冷启动,打开同一列表,匀速滚过二十项,展开指定行,再返回。记录设备、系统、构建配置、数据量、网络与温度条件;性能结论尽量来自 Release 构建和目标真机,Simulator 适合功能调试,不代表设备渲染预算。
保留未修改的基线 trace。若问题无法稳定出现,先增加 signpost 或固定数据,不要在噪声上比较两次主观手感。
SwiftUI Instrument 先回答两个问题
Apple 的 Understanding and improving SwiftUI performance 将诊断拆成耗时更新与更新原因。第一步选中用户感知问题附近的时间段,观察哪些 View body 或 representation update 持续较久;第二步判断同一更新是否被不必要地频繁触发。
这两个问题对应不同修复。单次更新很长,应检查同步格式化、排序、图片处理、复杂布局和主线程工作;单次很短但每秒出现很多次,应检查 Timer、手势、geometry、环境值和 observable 属性的依赖范围。只按“总耗时最大”排序,可能把一个合理但少见的大更新与真正造成连续卡顿的高频小更新混在一起。
选中长 update 后再看调用树。body 应描述状态到 View 的映射,不适合直接做 JSON 解码、磁盘读取、日期格式器创建或大集合筛选。把业务工作移出 body 不等于无条件缓存:缓存需要失效规则,也可能扩大内存。先确认相同工作确实重复,再选择预计算、模型层派生值或更窄的子视图。
图 1:每次只验证一条因果假设,并用同一场景复测;本站原创 1400×800 程序化 SVG。
Cause & Effect Graph:沿边向左找触发源
Cause & Effect Graph 的价值,是把“这个 View 更新了”继续连接到“哪一个动态属性、环境变化或框架事件使它失效”。WWDC25:Optimize SwiftUI performance with Instruments 演示了从昂贵 View update 沿因果边追到 state、environment、observable property 和手势等源头。图中蓝色节点代表应用侧工作,灰色节点代表 SwiftUI 框架工作;颜色帮助定位所有权,不等于蓝色必有 bug、灰色就无法影响。
使用时从问题时间范围里的 effect 开始,逐层向 cause 追踪,并问三个问题:这个值是否真的影响当前 View;变化频率是否符合产品语义;依赖能否缩小到更靠近使用点。图展示的是记录期间观察到的因果链,不是静态架构全貌。一个更新也可能有多条路径,节点在不同上下文出现;不要凭一张图推断未录制的场景。
例如页面把全局时钟模型放进环境,外层容器读取 now 只为显示一个角标。每次 tick 都可能让这个大容器失效,进而影响大量子树。更合适的设计是让最小的 ClockBadge 读取时间,其他内容只接收稳定数据:
struct Dashboard: View {
let articles: [Article]
var body: some View {
VStack {
ClockBadge()
ArticleGrid(articles: articles)
}
}
}
struct ClockBadge: View {
@Environment(ClockModel.self) private var clock
var body: some View {
Text(clock.now, style: .time)
.monospacedDigit()
}
}
这不是“拆成小 View 必然更快”。关键在于 Observation 能把属性读取与使用它的视图关联,依赖边界因此变窄。若 ArticleGrid 自己仍读取整个模型、模型把多个概念绑在同一计算属性,或 tick 同时改写文章数组,拆文件不会改变因果链。复测 trace 应证明更新范围真的改变。
几何反馈:一个常见的闭环
滚动页面常把 GeometryReader 或 geometry change 的连续值写进 State,再用该 State 改 frame、padding 或布局。滚动改变几何,写回触发 body,布局又生成新几何,最终在 Cause & Effect 中形成密集链路。若信息只用于颜色、透明度或位移,优先留在 visualEffect、scrollTransition 等渲染路径;若业务只关心“当前章节”,先把连续 offset 映射成章节 ID,值真正变化时才写入。
阈值是语义降采样,不是隐藏问题。把每个像素变动改成“跨过章节边界”后,状态频率与产品概念一致。反之,用 debounce 延迟所有更新可能让导航反馈滞后,却没有消除布局依赖。图上应该能看到触发源从连续 geometry 变化收敛为少量离散事件。
身份、依赖与计算成本要分开判断
Demystify SwiftUI performance 从依赖、身份和生命周期解释了 SwiftUI 如何更新。列表 ID 不稳定会让既有状态和存储无法复用,表现为行反复创建;依赖过宽会让仍有稳定身份的 View 频繁执行 body;body 内算法昂贵则让每次合法更新都变慢。三者在用户眼中都可能是滚动卡顿,但修复不同。
因此不要一次同时改 ID、拆模型、加缓存和换 LazyVStack。先用 Instrument 识别主导证据:创建生命周期异常就修身份;更新原因不相关就缩依赖;调用树显示排序占用才移出热路径。多项一起改会失去可归因性,也可能让一个改进掩盖另一个回归。
与其他 Instruments 配合
SwiftUI Instrument 擅长解释框架更新与动态属性之间的关系,但不是所有性能问题都来自 SwiftUI。主线程被图片解码、数据库查询或锁占用时,Time Profiler 更直接;用户可见挂起要结合 Hangs;内存增长、网络与能耗也有各自工具。Apple 的性能工作流 建议先测量并选择匹配症状的工具,而不是期望一张轨迹回答所有问题。
交互区间可以用 os_signpost 标注业务动作,使多次录制落在相同边界。Signpost 的名称应表达“打开文章”“完成筛选”,不要为每个 body 添加日志;测量代码本身也有成本。测试结束后保留必要、低频且有语义的标记。
匹配复测,而不是寻找漂亮数字
每次只实施一个有证据的改动,用相同设备、数据、构建与动作重新录制。比较问题区间的 update 持续时间、出现次数、因果节点和用户可见 hitch;同时确认功能、动画与无障碍没有退化。一次录制更快可能来自缓存预热、温度或手势差异,需要多次可重复结果才能支持结论。
性能报告至少写明:复现步骤;软硬件与构建版本;基线 trace;选中的 effect;Cause & Effect 路径;改动假设;对照 trace;功能回归结果;尚未解释的噪声。没有这些上下文的“提升 30%”即使数字真实,也无法复查。
常见失败包括只看平均 FPS;在 Debug 与 Simulator 上下结论;把所有蓝色节点当 bug;看到 body 次数多就强行 Equatable;为压低更新次数破坏实时状态;一次改四处;没有保存基线;用 Instruments 截图代替 trace。工具的目的不是让时间线更干净,而是让具体用户任务在预算内稳定完成。
可靠的 SwiftUI 优化路径是:先复现症状,再找到耗时或高频 effect,沿 Cause & Effect 追到可修改的 cause,做最小改动并匹配复测。身份、依赖和工作量被分别验证后,优化才是工程证据,而不是对声明式框架的猜测。