mcpField
S
swiftui-debugging

swiftui-debugging

github.com/rshankras开发83883 次安装14 周前更新
代码仓库

关于 swiftui-debugging

swiftui-debugging 是面向 SwiftUI 开发者的性能诊断指南,覆盖视图更新、身份与惰性加载问题。它以测量、定位、优化并重新测量为流程,提供 Instruments 等调试方法。

主要功能

  1. 01诊断视图重复求值
  2. 02检查视图身份与 diff
  3. 03分析列表惰性加载
  4. 04使用 Instruments 定位耗时
  5. 05优化依赖观察范围

适用场景

  1. 01排查滚动卡顿或视图抖动
  2. 02查明视图无故重复更新原因
  3. 03减少列表加载与渲染开销

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rshankras/claude-code-apple-skills --skill swiftui-debugging

安装后在 Claude Code 中输入/swiftui-debugging确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

body-reevaluation.md
common-pitfalls.md
lazy-loading.md
view-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 15body-reevaluation.md
@ObservableiOS 17 / macOS 14body-reevaluation.md
@ObservableObjectiOS 13body-reevaluation.md
LazyVStack / LazyHStackiOS 14lazy-loading.md
LazyVGrid / LazyHGridiOS 14lazy-loading.md
.id() 修饰符iOS 13view-identity.md
Instruments SwiftUI 模板Xcode 14+SKILL.md
重新设计的 SwiftUI instrumentXcode 26 / Instruments 26SKILL.md
os_signpostiOS 12SKILL.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,或对可变对象使用 @Statecommon-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:,也不需要为每一行编写复杂闭包)——这样行数就能保持为静态常量。

常见的更新缓慢原因

如果视图主体或更新在界面中出现得很慢,原因几乎总是以下之一:

  1. 动态属性初始化开销大——例如,@StateObject/@State 对象在初始化器中执行 I/O
  2. 在 body 中执行工作——字符串插值/格式化、排序、筛选
  3. 在 body 中分配堆内存——格式化器、谓词、中间数组
  4. 在 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 模板
  1. Xcode > Product > Profile(Cmd+I)
  2. 选择 SwiftUI 模板(包含 View Body、View Properties、Core Animation Commits)
  3. 开始录制,重现缓慢的交互,然后停止
  4. View Body 轨道显示哪些视图的 body 被求值,以及求值频率
  5. 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 GroupsSwiftUI 执行任何工作的时间。如果此轨道为空时 CPU 占用飙升,问题就在 SwiftUI 之外 —— 切换到通用性能分析技能
Long View Body Updates耗时过长的 body 属性
Long Representable Updates耗时过长的 UIView/UIViewController/NSView representable 更新
Other Long Updates所有其他耗时较长的 SwiftUI 工作

颜色编码:长时间更新按其可能导致卡顿或挂起的程度显示为橙色或红色——优先调查红色;普通更新为灰色。跟踪开头出现的长时间更新是启动时构建初始视图层级所致,属于预期情况,不会导致卡顿;不要追查它们(WWDC25 306)。

处理耗时较长的 body 的工作流:

  1. 展开 SwiftUI 轨道 -> 选择 View Body Updates 子轨道;详细信息面板会汇总所有运行过的 body(进程 -> 模块 -> 视图类型)
  2. 将摘要下拉菜单切换为 Long View Body Updates,筛选出问题项并查看其计数
  3. 将鼠标悬停在视图名称上 -> 箭头 -> Show Updates -> 右键点击其中一项 -> Set Inspection Range and Zoom
  4. 选择 Time Profiler 轨道,查看这次 body 运行期间 CPU 的具体操作(按住 Option 并点击可展开主线程堆栈;按 Cmd+F 查找你的 body 帧)
  5. 修复后重新录制,并确认该视图已从 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.mdAnyView、在 body 中创建对象、过度观察、耗时计算
../profiling/SKILL.md通用 Instruments 性能分析(Time Profiler、Memory、Energy)
../../swiftui/data-flow/SKILL.md上述所有内容背后的标识 / 生命周期思维模型——如果修复方案感觉像是在猜测,请阅读此文

评论

支持 Markdown