关于 swiftui-debugging
swiftui-debugging 是面向 SwiftUI 开发者的性能诊断指南,覆盖视图更新、身份与惰性加载问题。它以测量、定位、优化并重新测量为流程,提供 Instruments 等调试方法。
主要功能
- 01诊断视图重复求值
- 02检查视图身份与 diff
- 03分析列表惰性加载
- 04使用 Instruments 定位耗时
- 05优化依赖观察范围
适用场景
- 01排查滚动卡顿或视图抖动
- 02查明视图无故重复更新原因
- 03减少列表加载与渲染开销
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rshankras/ claude-code-apple-skills --skill swiftui-debugging安装后在 Claude Code 中输入/swiftui-debugging确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
body-reevaluation.mdcommon-pitfalls.mdlazy-loading.mdview-identity.md详细介绍
SwiftUI 性能调试
系统地诊断和修复 SwiftUI 性能问题:不必要的视图重新求值、标识问题、耗时的 body 计算,以及延迟加载错误。
此技能的触发场景
用户遇到以下情况时使用此技能:
- 报告 SwiftUI 视图运行缓慢或卡顿
- 发现视图过度重新渲染或 body 反复重新求值
- 询问
Self._printChanges()或视图调试 - 遇到列表或网格的滚动性能问题
- 询问为什么视图在没有任何变化时仍不断更新
- 提及
@Observable与ObservableObject的性能差异 - 想了解 SwiftUI 视图标识或差异比对
- 使用
AnyView,并询问其对性能的影响 - 遇到可追溯到 SwiftUI 渲染的卡死或卡顿
性能排查循环
每次排查都遵循相同的循环:症状 -> 测量 -> 定位 -> 优化 -> 重新测量。最后一步最容易被跳过——从未重新测量过的“优化”只是猜测;而 SwiftUI 中的猜测经常会出错(修复可能会把开销转移到其他地方),因此这套循环不可省略。绝不能只凭差异对比就关闭性能问题。
决策树
What SwiftUI performance problem are you seeing?
|
+- Views re-render when they should not
| +- Read body-reevaluation.md
| +- Self._printChanges() to identify which property changed
| +- @Observable vs ObservableObject observation differences
| +- Splitting views to narrow observation scope
|
+- Scrolling is slow / choppy (lists, grids)
| +- Read lazy-loading.md
| +- VStack vs LazyVStack, ForEach without lazy container
| +- List prefetching, grid cell reuse
|
+- Views lose state unexpectedly / animate when they should not
| +- Read view-identity.md
| +- Structural vs explicit identity
| +- .id() misuse, conditional view branching
|
+- Known pitfall (AnyView, DateFormatter in body, etc.)
| +- Read common-pitfalls.md
| +- AnyView type erasure, object creation in body
| +- Over-observation, expensive computations
|
+- General "my SwiftUI app is slow" (unknown cause)
| +- Start with body-reevaluation.md, then common-pitfalls.md
| +- Use Instruments SwiftUI template (see Debugging Tools below)
API 可用性
| API / 技术 | 最低版本 | 参考 |
|---|---|---|
Self._printChanges() | iOS 15 | body-reevaluation.md |
@Observable | iOS 17 / macOS 14 | body-reevaluation.md |
@ObservableObject | iOS 13 | body-reevaluation.md |
LazyVStack / LazyHStack | iOS 14 | lazy-loading.md |
LazyVGrid / LazyHGrid | iOS 14 | lazy-loading.md |
.id() 修饰符 | iOS 13 | view-identity.md |
| Instruments SwiftUI 模板 | Xcode 14+ | SKILL.md |
| 重新设计的 SwiftUI instrument | Xcode 26 / Instruments 26 | SKILL.md |
os_signpost | iOS 12 | SKILL.md |
最常见的 5 个错误——速查
| # | 错误 | 修复方法 | 详情 |
|---|---|---|---|
| 1 | 在 VStack 或 ScrollView 中使用大型 ForEach,但没有惰性容器 | 包装在 LazyVStack 中——急切加载的 VStack 会一次性创建所有视图 | lazy-loading.md |
| 2 | 使用 AnyView 擦除类型 | 使用 @ViewBuilder、Group 或具体泛型类型——AnyView 会妨碍差异比对 | common-pitfalls.md |
| 3 | 在 body 中创建对象(DateFormatter()、NumberFormatter()) | 对共享实例使用 static let,或对可变对象使用 @State | common-pitfalls.md |
| 4 | 只需要一个属性,却观察整个模型 | 拆分为更小的 @Observable 对象,或提取子视图 | body-reevaluation.md |
| 5 | 不稳定的 .id() 值导致每次渲染都完整重建视图 | 使用稳定标识符(数据库 ID、UUID),绝不要使用数组索引或随机值 | view-identity.md |
列表、表格和 ForEach:标识就是开销模型
List 和 Table 在加载时会急切地收集所有标识符——即使行 视图 是惰性加载的。ID 如果开销低且预先计算好,加载就会很快;而会计算或哈希昂贵内容的 id: key path,会在任何内容渲染之前对每个元素运行。
行数公式: 行数 = 元素数量 x 每个元素的视图数;每个元素的视图数必须是框架无需执行闭包就能读取的常量。
- ❌ 不要在
ForEach中使用if筛选或AnyView——两者都会使每个元素的视图数变成非常量,迫使 SwiftUI 运行每个闭包来统计行数(并破坏List的常量行数优化)。 - ✅ 在数据中筛选,而不是在视图中筛选——并且在模型中缓存筛选后的集合:在
body中内联使用items.filter { ... },会在每次 body 求值时重新进行线性筛选。 - ✅ 对于
Table,优先使用精简的ForEach(collection)初始化器(不带id:,也不需要为每一行编写复杂闭包)——这样行数就能保持为静态常量。
常见的更新缓慢原因
如果视图主体或更新在界面中出现得很慢,原因几乎总是以下之一:
- 动态属性初始化开销大——例如,
@StateObject/@State对象在初始化器中执行 I/O - 在
body中执行工作——字符串插值/格式化、排序、筛选 - 在
body中分配堆内存——格式化器、谓词、中间数组 - 在
body中查找 Bundle/资源——搜索Bundle.main、同步解码图像
将加载和开销大的派生计算移到 .task(或模型)中,然后重新测量。
严格限定依赖范围,但不必过度。 将要渲染的 Image 传给子视图,而不是传入整个模型对象,这样模型中无关的更改就不会再使子视图失效。不要做得过头:将一个大型结构体拆成几十个单属性参数,连接代码的成本可能高于节省的开销。@Observable 已经将失效范围缩小到 body 实际读取的属性——首先善用这一点。
调试工具
Self._printChanges()
将其添加到任意视图的 body 中,查看是什么触发了重新求值:
var body: some View {
let _ = Self._printChanges()
// ... view content
}
输出内容如下:ViewName: @self, @identity, _propertyName changed. 简写含义:@self = 视图的值发生了变化(父视图重新构建了它);命名属性 = 相应依赖项发生了变化。完整的解读指南请参阅 body-reevaluation.md。
也可以通过 LLDB 调用,无需编辑代码——在视图上下文中暂停,然后运行:
(lldb) expression Self._printChanges()
发布前请移除它——它会带来实际的运行时开销。
Instruments SwiftUI 模板
- Xcode > Product > Profile(Cmd+I)
- 选择 SwiftUI 模板(包含 View Body、View Properties、Core Animation Commits)
- 开始录制,重现缓慢的交互,然后停止
- View Body 轨道显示哪些视图的 body 被求值,以及求值频率
- View Properties 轨道显示哪些属性发生了变化
重新设计的 SwiftUI Instrument(Instruments 26 -- WWDC25 306)
需要 Xcode 26,以及被分析设备上较新的 OS 版本(录制跟踪的支持由 OS 提供)。按 Cmd+I 可构建 Release 版本并打开 Instruments;SwiftUI 模板包含 SwiftUI instrument、Time Profiler 和 Hangs + Hitches instruments。当你想知道“哪个 body 正在超出帧预算”时,就从这里开始。
**为什么 body 耗时很重要:**每一帧中,应用都会处理事件、运行已更改视图的 body,并且必须在帧截止时间前完成;如果某个 body 耗时过长,整个处理队列都会延迟,前一帧也会在屏幕上停留过久——这就是卡顿。两种不同的故障模式都会以相同方式超出截止时间:(1)一次耗时很长的 body 更新;(2)一帧内发生许多单次耗时很短、但不必要的更新(WWDC25 306)。
顶层轨道:
| 轨道 | 含义 |
|---|---|
| Update Groups | SwiftUI 执行任何工作的时间。如果此轨道为空时 CPU 占用飙升,问题就在 SwiftUI 之外 —— 切换到通用性能分析技能 |
| Long View Body Updates | 耗时过长的 body 属性 |
| Long Representable Updates | 耗时过长的 UIView/UIViewController/NSView representable 更新 |
| Other Long Updates | 所有其他耗时较长的 SwiftUI 工作 |
颜色编码:长时间更新按其可能导致卡顿或挂起的程度显示为橙色或红色——优先调查红色;普通更新为灰色。跟踪开头出现的长时间更新是启动时构建初始视图层级所致,属于预期情况,不会导致卡顿;不要追查它们(WWDC25 306)。
处理耗时较长的 body 的工作流:
- 展开 SwiftUI 轨道 -> 选择 View Body Updates 子轨道;详细信息面板会汇总所有运行过的 body(进程 -> 模块 -> 视图类型)
- 将摘要下拉菜单切换为 Long View Body Updates,筛选出问题项并查看其计数
- 将鼠标悬停在视图名称上 -> 箭头 -> Show Updates -> 右键点击其中一项 -> Set Inspection Range and Zoom
- 选择 Time Profiler 轨道,查看这次 body 运行期间 CPU 的具体操作(按住 Option 并点击可展开主线程堆栈;按 Cmd+F 查找你的 body 帧)
- 修复后重新录制,并确认该视图已从 Long View Body Updates 摘要中消失
经典案例(演示中的问题):在 body 中读取计算属性,每次 body 运行时都会为每个可见行创建一个 NumberFormatter 和 MeasurementFormatter,并格式化字符串。修复方法:只创建一次 formatter(将其作为模型/管理器的存储属性),将字符串预先计算并存入以 ID 为键的缓存,让 body 通过字典查找获取字符串(WWDC25 306)。一般规则请参阅 common-pitfalls.md。
因果图:为什么运行了 body?(WWDC25 306)
回溯可以解释命令式 UIKit 更新,却无法解释 SwiftUI——SwiftUI 回溯中充满递归的 AttributeGraph 帧,而且从不说明为什么你的视图更新了。因此,“为什么运行了 body?”实际上是在问“是什么让我的 body 过时了?”——这正是这个工具的因果图所回答的问题(将鼠标悬停在视图名称上,点击箭头 -> Show Cause & Effect Graph):
- 节点表示更新,每种类型对应一个图标(视图 body、状态更改、手势、
@Observable更改、环境);蓝色节点 = 你的代码 / 你的交互;图表从左(原因)向右(结果)读取 - 边标记为 “update”(导致重新运行)或 “Creation”(让视图首次出现)
- 变暗的图标 = 视图收到通知并经过检查,但其 body 并未运行
- 选择状态更改节点后,会显示值的设置位置的回溯
它发现的演示 Bug:每一行的 body 都调用了 isFavorite(landmark),而该函数读取了 @Observable 模型中的共享 landmarks 数组——因此每一行都依赖整个数组,点击一次就会让每个可见行的 body 重新运行。
// ❌ Every row reads the shared array -> @Observable makes every row depend on it
func isFavorite(_ landmark: Landmark) -> Bool {
favoritesCollection.landmarks.contains(landmark) // whole-array dependency
}
// ✅ Per-item @Observable view model -> each row depends only on its own flag
@Observable class ViewModel { var isFavorite: Bool = false }
@ObservationIgnored private var viewModels: [Landmark.ID: ViewModel] = [:]
// ^ @ObservationIgnored: don't observe the dictionary itself, only each model
func isFavorite(_ landmark: Landmark) -> Bool {
viewModel(for: landmark).isFavorite // body's read = narrow dependency
}
func addFavorite(_ landmark: Landmark) {
favoritesCollection.landmarks.append(landmark)
viewModel(for: landmark).isFavorite = true
}
在跟踪中已验证:点击 2 次 = body 恰好更新 2 次。规则:让数据依赖的粒度与渲染它们的 UI 一致(WWDC25 306)。
环境规则:EnvironmentValues 是一个值类型结构体,每个使用 @Environment 的视图都依赖整个结构体。环境中的任何更改都会通知所有这类视图;每个视图都会比较自身键的值——只有值发生变化时,body 才会重新运行,但每个读取环境的视图都会因比较本身耗费时间。切勿将快速变化的值(几何信息、计时器)存储在环境中(WWDC25 306)。图表会区分 External Environment 节点(在 SwiftUI 外部更改,例如色彩方案)和 EnvironmentWriter 节点(通过 .environment(...) 更改)。
使用 os_signpost 进行自定义测量
import os
private let perfLog = OSLog(subsystem: "com.app.perf", category: "SwiftUI")
var body: some View {
let _ = os_signpost(.event, log: perfLog, name: "MyView.body")
// ... view content
}
使用 Instruments 中的 os_signpost 工具,统计每秒 body 求值次数。
审查清单
视图标识
- 没有不稳定的
.id()值(随机值、Date()、可变数组中的数组索引) - 条件分支(
if/else)不会导致视图被不必要地销毁 -
ForEach使用模型中稳定且唯一的标识符
Body 重新求值
- 视图仅观察其实际使用的属性
- 优先使用
@Observable类,而非ObservableObject(iOS 17+) - 没有会触发 body 重新求值的不必要
@State更改 - 将大型视图拆分为较小的子视图,以缩小观察范围
延迟加载
- 大型集合使用
LazyVStack/LazyHStack,而非VStack/HStack - 50 个以上的项目使用
List或延迟堆栈 - 延迟容器中的子视图没有使用
.frame(maxHeight: .infinity)(这会破坏延迟加载)
常见陷阱
- 没有使用
AnyView进行类型擦除(改用@ViewBuilder或Group) - 没有在
body中分配对象(DateFormatter、NSPredicate、视图模型) - 耗时计算已通过
task { }或Task.detached移至后台执行 - 图像使用
AsyncImage或带有适当尺寸设置的.resizable(),而非在 body 中直接解码UIImage
参考文件
| 文件 | 内容 |
|---|---|
| view-identity.md | 结构标识与显式标识、.id() 用法、条件分支 |
| body-reevaluation.md | 触发 body 的因素、_printChanges()、@Observable 与 ObservableObject 的比较 |
| lazy-loading.md | 延迟与急切容器、List、ForEach、网格性能 |
| common-pitfalls.md | AnyView、在 body 中创建对象、过度观察、耗时计算 |
| ../profiling/SKILL.md | 通用 Instruments 性能分析(Time Profiler、Memory、Energy) |
| ../../swiftui/data-flow/SKILL.md | 上述所有内容背后的标识 / 生命周期思维模型——如果修复方案感觉像是在猜测,请阅读此文 |