swiftui-hang-triage
name: swiftui-hang-triage
description: macOS 与 iOS 原生应用主线程卡死诊断。当 App 转圈、彩虹圈、界面无响应、CPU 空转跑满、滚动卡顿、或者进程看起来像死了时使用,通过采样定位热点并给出修复方案。触发词:卡死、卡住、转圈、彩虹圈、beachball、假死、没响应、CPU 100%、界面卡顿、列表滚动卡、主线程阻塞、sample、spindump、SwiftUI 卡。不负责网页应用和 JVM 应用调优,也不负责服务器端进程排查(走 linux-incident-triage)。
原生应用主线程卡死诊断
界面卡死只有两种可能:主线程在等(阻塞),或者主线程在忙(空转)。先用采样区分这两种,再对症下药。凭感觉猜是最慢的路。
一、抓采样
App 正在卡的时候立刻采,卡过去了就没了。
# 先找到进程
pgrep -fl YourApp
# 采样 5 秒,间隔 1 毫秒
sample <pid> 5 1 -file /tmp/sample.txt
# 完全无响应时用 spindump(需要管理员权限),能看到内核态等待
sudo spindump <pid> 5 -file /tmp/spindump.txt
sample 看用户态调用栈,spindump 还能看出线程卡在什么内核等待上。先 sample,不够再 spindump。
Xcode 里的 Instruments(Time Profiler 模板)更直观,但 App 已经卡死时命令行更可靠。
二、读采样结果
打开采样文件,找 Thread 1 / main thread 那一段。看它最深的那几帧。
判断在等还是在忙:
| 栈顶特征 | 结论 |
|---|---|
__psynch_mutexwait、__ulock_wait、semaphore_wait |
在等锁或信号量 |
__read、__write、recvfrom、nw_ 系列 |
在等 IO 或网络 |
dispatch_group_wait、dispatch_semaphore_wait |
在同步等待异步任务,典型死锁 |
| 业务代码函数反复出现,栈不断变化 | 在忙,是计算量问题 |
SwiftUI 的 AG::Graph、AttributeGraph 帧巨多 |
视图求值风暴 |
采样里同一个栈出现的次数就是它占用的时间比例。占比最高的那一条就是元凶,不用看别的。
三、常见卡死模式
1. 在主线程做同步 IO 或网络
最经典。读大文件、同步网络请求、Data(contentsOf: url) 直接读远程地址、CoreData 大批量 fetch、Keychain 访问。
修法:挪到后台,用 async/await 或者 Task.detached,结果回主线程更新 UI。
2. 用信号量把异步变同步
// 错误示范:主线程上这么写,必卡
let sem = DispatchSemaphore(value: 0)
Task { data = await fetch(); sem.signal() }
sem.wait()
如果 fetch 内部也需要主线程,这是死锁;即使不死锁,主线程也被卡住了。
修法:让调用方也变成 async,一路 await 上去。不要在中间用信号量搭桥。
3. SwiftUI 视图求值风暴
症状是 CPU 跑满、界面卡顿但不完全死,采样里全是 AttributeGraph 相关帧。
常见原因:
body里做了重活:排序、过滤、日期格式化、正则、图片解码。body每帧可能被调用多次,里面必须只做拼装。@Published属性在body求值过程中被修改,触发无限刷新循环。ObservableObject粒度太粗:一个大 ViewModel 任何字段变化都刷新整棵视图树。id不稳定:ForEach用了每次都变的 id(比如UUID()现生成),导致整个列表重建。GeometryReader嵌套触发反复布局。
修法:
- 重活挪到
onAppear或 ViewModel 里预计算,body只读现成的值。 - 拆分视图,让状态变化只影响需要刷新的那一小块。
ForEach的 id 用数据本身的稳定标识,不要用数组下标(有增删时)也不要现生成。- 用
Equatable视图或.equatable()减少无谓重算。 - Xcode 的
_printChanges()能打印出是什么触发了刷新,非常有用。
4. 主线程等后台线程,后台线程又要回主线程
典型的循环等待。采样里会看到主线程 dispatch_semaphore_wait,某个后台线程 dispatch_sync 到 main queue。
修法:打破环。要么主线程不等,要么后台不回主线程。
5. 大列表一次性加载
一次渲染上万行,或者 List 里每行都做重计算。
修法:用 LazyVStack / List 的懒加载,分页,行内容预计算好。
6. 图片解码在主线程
大图直接 Image(uiImage:) 会在主线程解码。滚动时每行都解一张,必卡。
修法:后台预解码并缩放到实际显示尺寸,缓存结果。
四、验证修复
不要凭「感觉不卡了」收工。
- 再采一次样,确认原来占比最高的栈消失了。
- Instruments 的 Time Profiler 看主线程占用。
- Xcode 的 Thread Performance Checker 打开(Scheme → Diagnostics),它会在主线程做 IO 时直接报警。
- Main Thread Checker 打开,捕获后台线程误改 UI。
- 用 Instruments 的 Hangs 模板或 MetricKit 采集真实用户的卡顿数据,本机跑得动不代表用户设备跑得动。
五、预防
body里不许出现:网络、文件、数据库、正则、排序、日期格式化、图片解码。- DateFormatter、NumberFormatter 要复用,创建它们本身就很贵。
- ViewModel 拆细,一个状态只驱动一小片视图。
- 所有异步边界用
async/await,禁止信号量搭桥。 - 定期在最低配的目标设备上跑一遍,不要只在最新的 Mac 上测。
六、善后
诊断完如果起过测试进程,记得清理:
pkill -f YourApp
采样文件里可能包含文件路径和用户名,分享给别人之前先看一眼。