抖音思路复刻:iOS 卡死(ANR)监控 + 自动符号化全流程实战

在 iOS 上,App 在启动 / 退出 / 响应系统事件等关键阶段如果长时间卡住,超过系统阈值就会触发保护机制,最终被 Watchdog 以 SIGKILL 强制终止。这类异常的共同特点是:不是进程内异常抛出,而是"进程外指令"直接结束进程,因此传统基于 signal/exception 的崩溃捕获往往覆盖不到,也就导致它在生产环境中经常"只见数据、不见堆栈",长期被忽视。

为了解决这个盲区,我选择站在巨人的肩膀上:本文实践并复现字节跳动团队的文章《iOS 稳定性问题治理:卡死崩溃监控原理及最佳实践》,从 原理 → 代码实现 → 候选文件保存 → dSYM/atos/脚本符号化 + Swift demangle,把一整套卡死(ANR/Watchdog)监控链路完整跑通,并沉淀成一个可复用 Demo。

文末给出完整代码链接


1. 痛点:卡死最难的不是"知道卡",而是"知道卡在哪"

线上卡死/假死常见现象:

  • UI 不响应(点击无反应、滑动卡住),几秒后恢复或被系统杀掉
  • Crash 日志没有(不是进程内 crash),只剩用户反馈:"刚刚卡住了"
  • 拿到的堆栈是一堆 0x00000001.... 地址:不符号化就等于没有结论

所以要解决这类问题,本质是两件事:

  1. 卡死当下抓到主线程堆栈(或卡死期间持续采样)
  2. 自动符号化:把地址变成可读函数名,并尽可能 demangle Swift 符号

2. 原理:RunLoop 心跳 + 超阈值采样 = 还原卡死现场

2.1 监控"主线程卡死"的本质

主线程 RunLoop 正常情况下会不断在这些状态间流转:

  • BeforeTimers
  • BeforeSources
  • BeforeWaiting
  • AfterWaiting

当主线程执行重任务(大解析 / 同步 IO / 复杂布局 / 锁等待等),RunLoop 会长期卡在某个阶段不动,表现为 UI 无响应。

2.2 本 Demo 的检测策略(尽量向"抖音方案"靠拢)

Demo 采用的策略(参数对齐你当前实现):

  • 阈值:8 秒hangThreshold = 8
  • 检测周期:1 秒窗口tickInterval = 1
  • 超过阈值后:每秒采样一次主线程栈 (从 Live Report 中提取 Thread 0
  • 最多保留 10 帧样本maxMainThreadSamples = 10,保留最近 10 次)

这套策略的意义是:
先判断"已经卡死到足够严重"(接近 Watchdog 风险),再进入"持续采样",避免把轻微卡顿也当成卡死去抓栈/写文件。


3. Demo 实现结构

3.1 项目目录骨架

text 复制代码
LagMonitorDemo/
├── LagMonitorDemo.xcodeproj
├── Sources/
│   └── Monitor/
│      ├── HMDANRMonitor.swift
│      ├── HMDLiveReportCapture.swift
│      ├── HMDANRRecord.swift
│      ├── HMDANRCandidateStore.swift
│      └── HMDDebugCacheCleaner.swift
├── Scripts/
│   └── hmd_anr_symbolicate.py
└── Samples/
    ├── hmd_anr_candidate.json
    └── symbolicated.txt

3.2 Monitor:HMDANRMonitor.swift(核心监控器)

3.2.1 监控目标

  • 主线程装 CFRunLoopObserver,每次回调认为 RunLoop 推进:heartbeat += 1
  • 监控线程用 1 秒观察窗口 检查 heartbeat 是否变化
    • 1 秒内有推进:健康/恢复,清理候选
    • 1 秒内无推进 :认为卡住,hangSeconds += 1
  • 卡住累计达到 8 秒阈值:创建 candidate
  • 超阈值后仍未恢复:每秒采样一次主线程栈,最多 10 帧

3.2.2 为什么看起来"没 sleep 1 秒",却实现了"每秒检查一次"?

核心就在这一句:

swift 复制代码
_ = wakeSemaphore.wait(timeout: .now() + .seconds(config.tickInterval))

它等价于:

  • 最多等待 1 秒tickInterval = 1)作为一个观察窗口
  • 如果期间 RunLoop 有推进,observer 会 signal() → 监控线程会提前醒来
  • 窗口结束后比较 heartbeat:若 1 秒内完全没变,才算这一秒"卡住"

所以这里不是"固定每秒到点触发一次",而是:

"最多等 1 秒,但只要 RunLoop 一推进就立刻醒来重置状态"

这比 Timer 的"固定周期触发"更贴合我们想观察的对象(RunLoop 推进)。

3.2.3 为什么不用 NSTimer / GCD 定时器,而用信号量?

Timer 触发本身就依赖调度与 RunLoop/线程状态,卡死时最容易抖动或延迟;信号量 + 超时是更稳定的"观察窗口",还能被 RunLoop 推进即时唤醒。


3.3 抓栈:HMDLiveReportCapture.swift(PLCrashReporter Live Report)

抓栈使用 PLCrashReporter 的 Live Report 能力:

  • generateLiveReportAndReturnError():生成"当下全线程现场"
  • 再从文本中提取 Thread 0 作为主线程样本
swift 复制代码
import CrashReporter

/// 抓一次"全线程现场报告"
static func captureAllThreadsText() -> String? {
    let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
    guard let reporter = PLCrashReporter(configuration: config) else { return nil }

    do {
        let data = try reporter.generateLiveReportAndReturnError()
        let report = try PLCrashReport(data: data)
        return PLCrashReportTextFormatter.stringValue(for: report, with: PLCrashReportTextFormatiOS)
    } catch {
        print("[HMDLiveReportCapture] parse report error: \(error)")
        return nil
    }
}

这里的思路就是:
卡死现场抓"全线程",用于兜底;超阈值后持续采"主线程",用于定位稳定卡点。


3.4 保存:HMDANRCandidateStore.swift(hmd_anr_candidate.json)

Demo 保存的核心数据结构是 HMDANRRecord,通过 Codable 编码为 JSON:

swift 复制代码
public struct HMDANRRecord: Codable {
    public let recordID: String
    public let timestamp: Date
    public var hangSeconds: Int

    /// 超过阈值那一刻:全线程现场(PLCrash live report,文本)
    public var allThreadsReportText: String?

    /// 超阈值后:每秒采样主线程调用栈(最多保留最近 N 条)
    public var mainThreadSamples: [String]
}

保存到 Caches/hmd_anr_candidate.json 后,大致字段长这样:

json 复制代码
{
  "recordID": "E2D0...-....",
  "timestamp": "2026-01-05T12:34:56Z",
  "hangSeconds": 9,
  "allThreadsReportText": "PLCrashReporter live report text ...",
  "mainThreadSamples": [
    "Thread 0 ...\n0 LagMonitorDemo 0x...\n1 UIKitCore ...",
    "Thread 0 ...\n0 LagMonitorDemo 0x...\n1 UIKitCore ..."
  ]
}

HMDANRCandidateStore卡住时把记录保存到缓存文件;一旦主线程恢复推进就立刻删除;如果进程被系统强杀来不及上报,这个文件会留到下次启动再读取导出/符号化。


4. 复现场景(Demo 内置)

Demo 里包含几种典型卡死触发方式:

swift 复制代码
stack.addArrangedSubview(makeButton("主线程 Busy 2s(轻微卡顿)") { [weak self] in self?.busy(seconds: 2) })
stack.addArrangedSubview(makeButton("主线程 Busy 20s(触发 candidate + 采样)") { [weak self] in self?.busy(seconds: 20) })
stack.addArrangedSubview(makeButton("锁竞争:子线程持锁 12s → 主线程尝试加锁") { [weak self] in self?.lockContention() })
stack.addArrangedSubview(makeButton("死锁:串行队列 sync + 主队列 sync(必卡死)") { [weak self] in self?.deadlock() })

5. 自动符号化:从 hmd_anr_candidate.json 到"可读堆栈"

我点击 "主线程 Busy 20s(触发 candidate + 采样)" ,在第 10 秒手动杀掉 App,然后导出沙盒里的 hmd_anr_candidate.json

你会看到类似信息:

  • hangSeconds = 13(很明确的主线程长时间阻塞)
  • mainThreadSamples 有多次采样(证明卡住期间栈稳定)
  • 但这些堆栈仍然是地址/偏移,无法直接定位业务代码

因此需要做:

  • hmd_anr_candidate.json 里解析 frame
  • dSYM + atos 还原符号;并对 Swift 符号 demangle$s... → Foundation.Date.init()

本项目用脚本 hmd_anr_symbolicate.py 自动完成批量符号化:

bash 复制代码
python3 hmd_anr_symbolicate.py   --record hmd_anr_candidate.json   --app-dsym LagMonitorDemo.app.dSYM   --arch arm64   --demangle   --out symbolicated.txt

符号化后的symbolicated.txt的大致内容如下:

hmd_anr_symbolicate.pyGITHUB项目的Scripts文件夹下,hmd_anr_candidate.jsonsymbolicated.txt在Samples文件下

5.1 符号化后如何"从栈定位问题"?

symbolicated.txt 里抽 mainThreadSamples[0] 的关键几帧(你这次 5 次采样基本一致):

text 复制代码
1   libsystem_c.dylib          gettimeofday
3   Foundation                 Date.init
4   LagMonitorDemo.debug.dylib ... (ViewController.swift:52)
5   LagMonitorDemo.debug.dylib ... (ViewController.swift:33)
6   LagMonitorDemo.debug.dylib ... (ViewController.swift:44)
7   UIKitCore                  ...

这说明卡死期间主线程一直在跑 ViewController.swift 的某段逻辑,并且频繁调用 Date()(最终落到 gettimeofday/clock_gettime),典型特征就是忙等/死循环式等待

对应 Demo 中的实现:

swift 复制代码
private func busy(seconds: Int) {
    let end = Date().addingTimeInterval(TimeInterval(seconds))
    while Date() < end {
        _ = 1 + 1
    }
}

这类栈顶常见现象就是:看起来"卡在 Date()",其实根因是 while 循环让主线程一直跑

采样刚好截在 Date() 这一行,于是栈顶表现为 Date.init -> gettimeofday


6. 结语:复刻的意义,是把"文章里的方案"变成"项目里能用的工具"

大厂的稳定性方案往往更深、更体系化,但很多时候只停留在文章层面:看懂了思路,却很难在项目里直接落地。本文的目标就是把它"拆开 + 跑通":

  • 把抖音文章里的链路拆成可运行代码(监控、采样、保存、恢复、导出)
  • 把最后一公里补齐(dSYM/atos 自动符号化 + Swift demangle)
  • 让"卡死问题"从 只有 SIGKILL 数字 ,变成 能指向具体业务函数/代码行 的结论

你真正需要的不是"我们检测到了卡死",而是:

卡死那 8~20 秒内,主线程到底在跑什么?它卡在谁身上?

当你把"采样 + 保存 + 下次启动捞取 + 自动符号化 + demangle"这条链路跑通,线上卡死排查效率会明显提升。


GITHUB源码

相关推荐
山沐与山8 小时前
【Redis】读写锁实战详解:读多写少场景的性能优化利器
数据库·redis·性能优化
茅根竹蔗水__10 小时前
iOS应用(App)生命周期、视图控制器(UIViewController)生命周期和视图(UIView)生命周期
ios
小二·10 小时前
Vite 构建完全指南:极致性能优化、安全加固与自动化部署(Vue 3 + TypeScript)
安全·性能优化·typescript
步步为营DotNet12 小时前
深度解析.NET中MemoryCache:高效缓存策略与性能优化的关键
缓存·性能优化·.net
一只爱吃糖的小羊12 小时前
Web Worker 性能优化实战:将计算密集型逻辑从主线程剥离的正确姿势
前端·性能优化
卓码软件测评13 小时前
第三方软件国产化测评机构【API验收测试,除了Postman,还有什么推荐工具?】
测试工具·ci/cd·性能优化·单元测试·测试用例·postman
shughui13 小时前
JMeter(二):什么是jmeter参数化?为什么需要它?如何使用
测试工具·jmeter·性能优化·单元测试
毛发浓密的女猴子14 小时前
SSE Connect 数据解析详解
ios
DemonAvenger14 小时前
深入Redis Stream:打造高效消息队列系统的实战指南
数据库·redis·性能优化
努力的小郑14 小时前
SQL 性能避坑:为什么阿里强制禁用 ORDER BY RAND()?
java·mysql·性能优化