swiftui-hang-triage

Category: Coding Risk: Medium risk niuwoai/skills CC-BY-4.0
shell_executionnetwork_access

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_waitsemaphore_wait 在等锁或信号量
__read__writerecvfromnw_ 系列 在等 IO 或网络
dispatch_group_waitdispatch_semaphore_wait 在同步等待异步任务,典型死锁
业务代码函数反复出现,栈不断变化 在忙,是计算量问题
SwiftUI 的 AG::GraphAttributeGraph 帧巨多 视图求值风暴

采样里同一个栈出现的次数就是它占用的时间比例。占比最高的那一条就是元凶,不用看别的。

三、常见卡死模式

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

采样文件里可能包含文件路径和用户名,分享给别人之前先看一眼。