关于 axiom-hang-diagnostics
面向 Swift 应用开发者的挂起诊断技能,介绍如何区分主线程繁忙与阻塞,并使用 Instruments、Xcode Organizer 和 MetricKit 定位及预防无响应问题。
主要功能
- 01区分挂起、卡顿与延迟
- 02使用 Time Profiler 分析主线程
- 03用 System Trace 检查阻塞
- 04解读 Organizer 与 MetricKit 诊断
- 05提供常见原因及异步修复模式
适用场景
- 01调查用户报告的界面冻结
- 02分析生产环境挂起报告
- 03排查启动或切换前后台时的看门狗终止
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-hang-diagnostics安装后在 Claude Code 中输入/axiom-hang-diagnostics确认已加载。
详细介绍
卡顿诊断
系统地诊断和解决应用卡顿问题。当主线程阻塞超过 1 秒时,就会发生卡顿,导致应用无法响应用户输入。
警示信号 — 遇到以下情况时查看此技能
| 症状 | 是否适用此技能 |
|---|---|
| 使用过程中应用短暂冻结 | 是 — 可能是卡顿 |
| UI 无法响应触控 | 是 — 主线程已阻塞 |
| 系统弹出“应用无响应”对话框 | 是 — 严重卡顿 |
| Xcode Organizer 显示卡顿诊断 | 是 — 现场卡顿报告 |
| 收到 MetricKit MXHangDiagnostic | 是 — 聚合卡顿数据 |
| 动画卡顿或跳帧 | 可能 — 可能是画面卡顿,而非应用卡顿 |
| 应用感觉很慢,但仍能响应 | 否 — 这是性能问题,而非卡顿 |
什么是卡顿
卡顿是指主运行循环超过 1 秒无法处理事件。用户点按了,但什么都没有发生。
User taps → Main thread busy/blocked → Event queued → 1+ second delay → HANG
关键区别:主线程处理所有用户输入。如果主线程正忙或被阻塞,整个 UI 都会冻结。
卡顿、画面卡顿与延迟的区别
| 问题 | 持续时间 | 用户体验 | 工具 |
|---|---|---|---|
| 卡顿 | >1 秒 | 应用冻结,无法响应 | Time Profiler、System Trace |
| 画面卡顿 | 1-3 帧(16-50ms) | 动画不流畅 | Animation Hitches 仪器 |
| 延迟 | 100-500ms | 感觉较慢,但仍能响应 | Time Profiler |
此技能涵盖应用卡顿。 关于画面卡顿,请参阅 axiom-swiftui-performance。关于一般延迟,请参阅 axiom-performance-profiling。
卡顿的两种原因
所有卡顿都源于以下两种原因之一:
1. 主线程繁忙
主线程正在执行任务,而非处理事件。
子类别:
| 类型 | 示例 | 修复方法 |
|---|---|---|
| 主动工作 | 预先计算用户尚未请求的数据 | 延迟初始化,按需计算 |
| 无关工作 | 处理所有通知,而不只是相关通知 | 筛选通知,使用定向观察者 |
| 非最优 API | 存在异步 API 时仍使用阻塞 API | 改用异步 API |
2. 主线程被阻塞
主线程正在等待其他任务完成。
子类别:
| 类型 | 示例 | 修复方法 |
|---|---|---|
| 同步 IPC | 同步调用系统服务 | 使用对应的异步 API |
| 文件 I/O | 在主线程上调用 Data(contentsOf:) | 移至后台队列 |
| 网络 | 同步 URL 请求 | 使用 URLSession 异步 API |
| 锁竞争 | 等待由后台线程持有的锁 | 缩短临界区,使用 actors |
| 信号量/dispatch_sync | 阻塞等待后台工作完成 | 重构为异步完成处理 |
决策树 — 诊断卡顿
START: App hangs reported
│
├─→ Do you have hang diagnostics from Organizer or MetricKit?
│ │
│ ├─→ YES: Examine stack trace
│ │ │
│ │ ├─→ Stack shows your code running
│ │ │ → BUSY: Main thread doing work
│ │ │ → Profile with Time Profiler
│ │ │
│ │ └─→ Stack shows waiting (semaphore, lock, dispatch_sync)
│ │ → BLOCKED: Main thread waiting
│ │ → Profile with System Trace
│ │
│ └─→ NO: Can you reproduce?
│ │
│ ├─→ YES: Profile with Time Profiler first
│ │ │
│ │ ├─→ High CPU on main thread
│ │ │ → BUSY: Optimize the work
│ │ │
│ │ └─→ Low CPU, thread blocked
│ │ → Use System Trace to find what's blocking
│ │
│ └─→ NO: Enable MetricKit in app
│ → Wait for field reports
│ → Check Organizer > Hangs
工具选择
| 场景 | 主要工具 | 原因 |
|---|---|---|
| 本地可复现 | Time Profiler | 查看主线程具体在执行什么 |
| 怀疑线程被阻塞 | System Trace | 显示线程状态和锁竞争 |
| 仅有现场报告 | Xcode Organizer | 聚合卡顿诊断 |
| 需要应用内数据 | MetricKit | 提供带调用栈的 MXHangDiagnostic |
| 需要精确计时 | System Trace | 纳秒级线程分析 |
使用 Time Profiler 诊断卡顿的工作流
- 启动 Instruments → 选择 Time Profiler 模板
- 在卡顿期间录制 → 重现冻结
- 停止录制 → 在时间轴中找到卡顿时段
- 选择卡顿区域 → 拖动以选取冻结的时间段
- 检查调用树 → 查找主线程正在执行的工作
需要关注的内容:
- 主线程上“Self Time”较高的函数
- 深度异常的调用栈
- 不应在主线程上执行的系统调用
阻塞挂起的系统跟踪工作流
- 启动 Instruments → 选择 System Trace 模板
- 挂起期间录制 → 捕获线程状态
- 找到主线程 → 筛选主线程
- 查看红色/橙色状态 → 阻塞状态
- 检查阻塞原因 → 锁、信号量、IPC
线程状态:
- 运行中(蓝色):正在执行代码
- 被抢占(橙色):可运行,但尚未获得调度
- 阻塞(红色):正在等待资源
常见挂起模式及修复方法
模式 1:同步文件 I/O
修改前(会挂起):
// Main thread blocks on file read
func loadUserData() {
let data = try! Data(contentsOf: largeFileURL) // BLOCKS
processData(data)
}
修改后(异步):
func loadUserData() {
Task.detached {
let data = try Data(contentsOf: largeFileURL)
await MainActor.run {
self.processData(data)
}
}
}
模式 2:未筛选的通知观察者
修改前(处理所有通知):
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: nil // Receives ALL contexts
)
修改后(经过筛选):
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: relevantContext // Only this context
)
模式 3:创建开销大的格式化器
修改前(每次都创建):
func formatDate(_ date: Date) -> String {
let formatter = DateFormatter() // EXPENSIVE
formatter.dateStyle = .medium
return formatter.string(from: date)
}
修改后(缓存):
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
func formatDate(_ date: Date) -> String {
Self.dateFormatter.string(from: date)
}
模式 4:对主线程调用 dispatch_sync
修改前(有死锁风险):
// From background thread
DispatchQueue.main.sync { // BLOCKS if main is blocked
updateUI()
}
修改后(异步):
DispatchQueue.main.async {
self.updateUI()
}
模式 5:使用信号量等待异步结果
修改前(阻塞主线程):
func fetchDataSync() -> Data {
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data
semaphore.signal()
}.resume()
semaphore.wait() // BLOCKS MAIN THREAD
return result!
}
修改后(async/await):
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
模式 6:锁竞争
修改前(共享锁):
class DataManager {
private let lock = NSLock()
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
lock.lock() // Main thread waits for background
defer { lock.unlock() }
return cache[key]
}
}
修改后(actor):
actor DataManager {
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
cache[key] // Actor serializes access safely
}
}
模式 7:应用启动挂起(看门狗)
修改前(工作过多):
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
loadAllUserData() // Expensive
setupAnalytics() // Network calls
precomputeLayouts() // CPU intensive
return true
}
修改后(延后执行):
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Only essential setup
setupMinimalUI()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Defer non-essential work
Task {
await loadUserDataInBackground()
}
}
模式 8:在主线程上处理图像
修改前(阻塞 UI):
func processImage(_ image: UIImage) {
let filtered = applyExpensiveFilter(image) // BLOCKS
imageView.image = filtered
}
修改后(后台处理):
func processImage(_ image: UIImage) {
imageView.image = placeholder
Task.detached(priority: .userInitiated) {
let filtered = applyExpensiveFilter(image)
await MainActor.run {
self.imageView.image = filtered
}
}
}
Xcode Organizer 挂起诊断
Window > Organizer > Select App > Hangs
Organizer 会显示选择共享诊断信息的用户所产生的挂起数据汇总。
读取报告:
- 挂起率:每台设备每天发生的挂起次数
- 调用栈:发生挂起的位置
- 设备/操作系统细分:受影响的配置
解读调用栈:
- 顶部是你的代码:主线程正忙于执行你的工作
- 顶部是系统 API:你在主线程上调用了阻塞 API
- pthread_mutex/semaphore:锁竞争或显式等待
MetricKit 挂起诊断
在应用中接入 MetricKit,以接收挂起诊断信息:
import MetricKit
class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
if let hangDiagnostics = payload.hangDiagnostics {
for diagnostic in hangDiagnostics {
analyzeHang(diagnostic)
}
}
}
}
private func analyzeHang(_ diagnostic: MXHangDiagnostic) {
// Duration of the hang
let duration = diagnostic.hangDuration
// Call stack tree (needs symbolication)
let callStack = diagnostic.callStackTree
// Send to your analytics
uploadHangDiagnostic(duration: duration, callStack: callStack)
}
}
MXHangDiagnostic 的关键属性:
hangDuration:挂起持续的时间callStackTree:包含堆栈帧的 MXCallStackTreesignatureIdentifier:用于对相似挂起进行分组
看门狗终止
看门狗会在关键转换期间终止发生挂起的应用:
| 转换 | 时间限制 | 后果 |
|---|---|---|
| 应用启动 | ~20 秒 | 应用被终止,并记录崩溃 |
| 转入后台 | ~5 秒 | 应用被终止 |
| 转到前台 | ~10 秒 | 应用被终止 |
以下情况下会停用看门狗:
- 模拟器
- 已连接调试器
- 开发版本(有时)
看门狗终止会记录为崩溃,异常类型为 EXC_CRASH (SIGKILL),终止原因是 Namespace RUNNINGBOARD, Code 3735883980(十六进制 0xDEAD10CC——表示应用在挂起时持有文件锁或 SQLite 数据库锁)。
压力场景
场景 1:经理说“加个加载指示器就行”
情况:应用在加载数据时挂起。经理建议添加加载指示器来“修复”问题。
为什么行不通:添加加载指示器无法避免挂起——UI 仍会冻结,指示器也无法动画显示,应用依然没有响应。
正确回应:“挂起时主线程处于阻塞状态,加载指示器无法播放动画。我们需要把这项工作移出主线程,这样指示器才能正常转动,应用也能保持响应。”
场景 2:“测试时运行得好好的”
情况:QA 无法复现挂起,但日志显示问题发生在生产环境中。
分析:
- 现场设备的数据量有所不同
- 网络状况各异(连接速度慢会导致同步时间更长)
- 后台应用会占用内存/CPU
- 调试版本中会停用看门狗
操作:
- 添加 MetricKit,以采集现场诊断信息
- 使用生产环境规模的数据集进行测试
- 在未连接调试器的情况下进行测试
- 在 Organizer 中检查挂起报告
场景 3:“我们一直都是这么做的”
情况:旧代码在主线程上调用同步 API。重构“风险太大”。
为什么重要:即使以前运行正常:
- 数据可能已经变多
- 操作系统更新可能改变了时序
- 新设备的特性有所不同
- 应用越快,用户越容易察觉
做法:
- 添加指标,衡量当前的卡顿率
- 使用功能开关逐步重构
- 进行 A/B 测试,展示改进效果
- 记录不修复的风险
应避免的反模式
| 反模式 | 错误原因 | 替代方案 |
|---|---|---|
从后台调用 DispatchQueue.main.sync | 可能导致死锁,并且始终会阻塞 | 使用 .async |
| 使用信号量将异步转换为同步 | 会阻塞调用线程 | 使用完成回调或 await 保持异步 |
| 在主线程上执行文件 I/O | 延迟不可预测 | 使用后台队列 |
| 未筛选的通知观察者 | 会处理无关事件 | 按对象/名称筛选 |
| 在循环中创建格式化器 | 初始化开销大 | 缓存并重复使用 |
| 同步网络请求 | 会因网络延迟而阻塞 | 使用 URLSession 异步请求 |
卡顿预防检查清单
发布前,请确认:
- 主线程上没有
Data(contentsOf:)或文件读取操作 - 后台线程没有调用
DispatchQueue.main.sync - 主线程上没有调用 semaphore.wait()
- 格式化器(DateFormatter、NumberFormatter)已缓存
- 通知观察者已进行适当筛选
- 尽量减少启动时的工作(推迟非必要操作)
- 图像处理在主线程之外进行
- 数据库查询不会在主线程上运行
- 已采用 MetricKit 进行现场诊断
资源
WWDC:2021-10258、2022-10082
文档:/xcode/analyzing-responsiveness-issues-in-your-shipping-app、/metrickit/mxhangdiagnostic
技能:axiom-metrickit-ref、axiom-performance-profiling、axiom-swift-concurrency、axiom-lldb(在冻结时交互式检查线程)