mcpField
A
axiom-hang-diagnostics

axiom-hang-diagnostics

github.com/charleswiltgen开发1.2k170 次安装今天更新
代码仓库

关于 axiom-hang-diagnostics

面向 Swift 应用开发者的挂起诊断技能,介绍如何区分主线程繁忙与阻塞,并使用 Instruments、Xcode Organizer 和 MetricKit 定位及预防无响应问题。

主要功能

  1. 01区分挂起、卡顿与延迟
  2. 02使用 Time Profiler 分析主线程
  3. 03用 System Trace 检查阻塞
  4. 04解读 Organizer 与 MetricKit 诊断
  5. 05提供常见原因及异步修复模式

适用场景

  1. 01调查用户报告的界面冻结
  2. 02分析生产环境挂起报告
  3. 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 诊断卡顿的工作流

  1. 启动 Instruments → 选择 Time Profiler 模板
  2. 在卡顿期间录制 → 重现冻结
  3. 停止录制 → 在时间轴中找到卡顿时段
  4. 选择卡顿区域 → 拖动以选取冻结的时间段
  5. 检查调用树 → 查找主线程正在执行的工作

需要关注的内容:

  • 主线程上“Self Time”较高的函数
  • 深度异常的调用栈
  • 不应在主线程上执行的系统调用

阻塞挂起的系统跟踪工作流

  1. 启动 Instruments → 选择 System Trace 模板
  2. 挂起期间录制 → 捕获线程状态
  3. 找到主线程 → 筛选主线程
  4. 查看红色/橙色状态 → 阻塞状态
  5. 检查阻塞原因 → 锁、信号量、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 会显示选择共享诊断信息的用户所产生的挂起数据汇总。

读取报告:

  1. 挂起率:每台设备每天发生的挂起次数
  2. 调用栈:发生挂起的位置
  3. 设备/操作系统细分:受影响的配置

解读调用栈:

  • 顶部是你的代码:主线程正忙于执行你的工作
  • 顶部是系统 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:包含堆栈帧的 MXCallStackTree
  • signatureIdentifier:用于对相似挂起进行分组

看门狗终止

看门狗会在关键转换期间终止发生挂起的应用:

转换时间限制后果
应用启动~20 秒应用被终止,并记录崩溃
转入后台~5 秒应用被终止
转到前台~10 秒应用被终止

以下情况下会停用看门狗:

  • 模拟器
  • 已连接调试器
  • 开发版本(有时)

看门狗终止会记录为崩溃,异常类型为 EXC_CRASH (SIGKILL),终止原因是 Namespace RUNNINGBOARD, Code 3735883980(十六进制 0xDEAD10CC——表示应用在挂起时持有文件锁或 SQLite 数据库锁)。

压力场景

场景 1:经理说“加个加载指示器就行”

情况:应用在加载数据时挂起。经理建议添加加载指示器来“修复”问题。

为什么行不通:添加加载指示器无法避免挂起——UI 仍会冻结,指示器也无法动画显示,应用依然没有响应。

正确回应:“挂起时主线程处于阻塞状态,加载指示器无法播放动画。我们需要把这项工作移出主线程,这样指示器才能正常转动,应用也能保持响应。”

场景 2:“测试时运行得好好的”

情况:QA 无法复现挂起,但日志显示问题发生在生产环境中。

分析:

  1. 现场设备的数据量有所不同
  2. 网络状况各异(连接速度慢会导致同步时间更长)
  3. 后台应用会占用内存/CPU
  4. 调试版本中会停用看门狗

操作:

  • 添加 MetricKit,以采集现场诊断信息
  • 使用生产环境规模的数据集进行测试
  • 在未连接调试器的情况下进行测试
  • 在 Organizer 中检查挂起报告
场景 3:“我们一直都是这么做的”

情况:旧代码在主线程上调用同步 API。重构“风险太大”。

为什么重要:即使以前运行正常:

  • 数据可能已经变多
  • 操作系统更新可能改变了时序
  • 新设备的特性有所不同
  • 应用越快,用户越容易察觉

做法:

  1. 添加指标,衡量当前的卡顿率
  2. 使用功能开关逐步重构
  3. 进行 A/B 测试,展示改进效果
  4. 记录不修复的风险

应避免的反模式

反模式错误原因替代方案
从后台调用 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(在冻结时交互式检查线程)

评论

支持 Markdown