崩溃捕获设计实践
# 崩溃捕获设计实践
📊 学习成本预估 | 难度:⭐⭐⭐⭐⭐(5/5)| 阅读:约 50 分钟 | 实操:3 小时 🔗 前置阅读:卷一·02, 卷二·03 | ➡️ 后续延伸:—
# 目录介绍
- 01.阅读说明
- 02.贯穿案例
- 03.问题域定义
- 04.第一性原理
- 05.度量与采集
- 06.Java 崩溃全链路 ⭐
- 07.Native 崩溃全链路 ⭐
- 08.ANR 全链路 ⭐
- 09.iOS 异常全链路 ⭐
- 10.Mach 异常全链路 ⭐
- 11.Watchdog 与 OOM ⭐
- 12.Web 错误全链路 ⭐
- 13.跨端流程对照
- 14.归因方法
- 15.求证实验 ⭐
- 16.优化策略深化
- 17.实战案例
- 18.防劣化与长效治理
- 19.跨平台对照速查
- 20.总结与延伸
- 21.一句话总结
# 01.阅读说明
- 本文卷归属:卷五 · 交付与防御 · 第 1 篇
- 本文目标层级:L3 专家
- 适用平台:Android / iOS / Web / 嵌入式
- 前置阅读:
卷一·02 稳定性专项建设(崩溃是稳定性的具体形式)
- 本文核心命题:
崩溃捕获是性能体系的最后一道防线:当所有保护都失败时,至少要让"故障可见"。 一切崩溃捕获 = 信号 / 异常 / Native 三类原理 + 完整现场 + 高可靠上报。 看不到的崩溃比可见崩溃更危险。
# 02.贯穿案例
本案例贯穿全文:§03 看懂代价、§04 拿到三类崩溃模型、§05 用三方案、§06-§13 拆解各端原理、§15 用实验复盘、§16 给出分层闭环。
# 2.1 案例背景
某头部出行 App V12.0 在用户反馈中持续收到"打开就闪退、订单时崩溃"投诉,但 Crashlytics 看板显示崩溃率仅 0.04%(行业极致)。用户量级和投诉量级完全对不上:
- 客服记录"闪退"投诉每日 8000+,按 DAU 1500 万估算应有 0.5%+。
- 监控显示崩溃率仅 0.04%,真实崩溃率被低估 12 倍。
- 应用商店评分掉到 3.2(行业平均 4.5),用户评论高频出现"打不开"。
研发组:"我们接了 Crashlytics,应该都收到了。"——这是经典的"工具迷信"。
# 2.2 经验派 4 周折腾
| 周次 | 动作 | 结果 |
|---|---|---|
| 第 1 周 | 升级 Crashlytics 到最新版 | 数据无变化 |
| 第 2 周 | 加大上报采样率到 100% | 无变化(原本就 100%) |
| 第 3 周 | 跑用户回访问卷 | 无 stack,无法修复 |
| 第 4 周 | 客服关键词"闪退"加白名单 | 仅是症状缓解 |
复盘:四周折腾错在"以为工具能覆盖全部崩溃"。Crashlytics 类 SDK 漏掉:Android Native 崩溃、启动早期崩溃、LMK 被杀、iOS Watchdog、OOM、Web unhandledrejection。
# 2.3 方法派 7 天闭环
Day 1(§05 三方案 + §15 实验数据):用 §15.1 数据论证"三方案组合覆盖 99% vs 单方案 60-70%"。
Day 2(§14 决策树审计盲区):逐类崩溃审计当前覆盖(Native/启动早期/LMK/iOS Watchdog/jetsam/Promise rejection)。
Day 3-4(§16 分层策略):四层施治——捕获覆盖 / 现场质量 / 上报可靠性 / 治理闭环。
Day 5-6(自动符号化基建):CI 自动上传 mapping/dSYM,发版前阻断"未上传符号"的版本。
Day 7(灰度上线)。
# 2.4 上线效果
| 指标 | 经验派 4 周后 | 方法派 7 天后 | 行业基准 |
|---|---|---|---|
| 可见崩溃率 | 0.04%(假数据) | 0.48%(真实) | 0.5% |
| Native 崩溃可见 | 0% | 98% | - |
| iOS OOM 可见 | 0% | MetricKit 95% | - |
| 线上事故定位时长 | > 24h | < 2h | - |
| 4 周后真实崩溃率 | 0.04%(造假) | 0.13%(真治) | < 0.1% |
核心洞察:"可见崩溃率"不等于"真实崩溃率"。经验派"看到 0.04%"实际是"只看到 12% 的崩溃"。看不见的崩溃才是最大风险——因为你完全不知道有问题。
# 2.5 案例串联全文
- §03 现象与代价 ▶▶ 投诉与数据 12× 偏差。
- §04 三类崩溃模型 ▶▶ 案例 11 类崩溃只覆盖了 3 类。
- §05 三方案组合 ▶▶ 异常 + 信号 + 系统历史。
- §06-§13 各端原理 ▶▶ 每一类漏报背后都有"原理上的解药"。
- §15 求证实验 ▶▶ §15.1 + §15.4 + §15.5 都在案例中变现。
- §16 分层策略 ▶▶ "捕获→现场→上报→治理"四层落地路径。
# 03.问题域定义
# 3.1 现象与代价
崩溃是稳定性的"显示信号":应用退出 / 数据丢失 / 状态丢失 / 隐性崩溃。
业务代价:
- 头部应用崩溃率每降 0.1% 留存 +0.5%。
- 启动崩溃比运行时崩溃影响更大(用户进不去)。
- 不可见的"静默崩溃"会影响线上数据准确性。
# 3.2 度量准则
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 崩溃率(cph) | crash / hour 或 crash / DAU | < 0.1% |
| 启动崩溃率 | 启动失败 / 总启动 | < 0.05% |
| 上报成功率 | 上报成功 / 总崩溃 | > 95% |
| 符号化成功率 | 可还原栈 / 总崩溃 | > 90% |
# 3.3 行业基准与目标
| 平台 | 崩溃率 | 启动崩溃 | 上报成功率 |
|---|---|---|---|
| Android | < 0.1% | < 0.05% | > 95% |
| iOS | < 0.05% | < 0.02% | > 98% |
| Web | < 0.05% | N/A | > 90% |
| 嵌入式 | 0% | 0% | 内部记录 |
# 3.4 反直觉问题清单
带着这些问题阅读:
- Java 崩溃和 Native 崩溃捕获方式一样吗?
- iOS 崩溃栈为什么是地址而不是函数名?
- 崩溃时再上报数据可靠吗?
- 静默崩溃如何发现?
- SIGABRT 和 SIGKILL 应用都能捕获吗?
- ANR 和崩溃是同一类问题吗?
- 用户主动杀死也算"崩溃"吗?
- 崩溃时还能写文件 / 发网络吗?
# 04.第一性原理
# 4.1 崩溃本质定义
崩溃 = 应用在执行过程中遇到不可恢复的错误,被运行时或操作系统强制终止。
三个不可商量的物理约束:
约束一:崩溃是"不可恢复"的状态——进程内某些数据已经处于"不可信"状态(栈被破坏 / 内存被覆盖 / 状态机错乱)。OS / 运行时主动杀死进程是为了避免错误扩散。
约束二:崩溃捕获的"时间窗"极短——崩溃到进程被强杀通常只有几十 ms 到几秒。操作必须最简单、最可靠:不能依赖复杂的库,不能新建线程,不能分配大块内存。
约束三:崩溃种类决定捕获方式——不同语言 / 平台的崩溃机制不同(Java 异常 vs C 信号 vs JS 错误),需要分别捕获。
# 4.2 三类崩溃模型
┌────────────────────────────────────────────────┐
│ A. 异常型崩溃(语言级) │
│ Java RuntimeException / Swift error / JS Error│
│ 捕获:UncaughtExceptionHandler │
├────────────────────────────────────────────────┤
│ B. 信号型崩溃(系统级) │
│ SIGSEGV / SIGABRT / SIGBUS / SIGFPE │
│ 捕获:sigaction / signal handler │
├────────────────────────────────────────────────┤
│ C. 系统强杀(OOM Killer / Watchdog / Kill) │
│ 无法应用层捕获 │
│ 捕获:启动时检查"上次未正常退出" │
└────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
关键认知:A 类容易捕获(运行时提供 hook);B 类需要 Native 层信号处理;C 类应用代码无法响应(已被 SIGKILL),只能下次启动时推断。
# 4.3 跨平台同构原理
所有平台的崩溃捕获本质都是"在异常发生时拦截 + 记录 + 持久化":
[应用代码] ──[抛异常]──▶ [运行时/OS]
│
▼
[捕获 hook]
│
▼
[收集现场]
│
▼
[本地持久化]
│
下次启动时上报
2
3
4
5
6
7
8
9
10
11
12
跨平台术语对照
| 通用术语 | Android | iOS | Web | C/C++ |
|---|---|---|---|---|
| 异常 hook | Thread.setDefaultUncaughtExceptionHandler | NSSetUncaughtExceptionHandler | window.onerror + unhandledrejection | terminate_handler |
| 信号 hook | NDK sigaction | sigaction | N/A | sigaction |
| 现场采集 | stack + device + 自定义 | 同 + Mach exception | error.stack + UA | backtrace |
| 持久化 | 本地文件 | 本地文件 | localStorage / IndexedDB | 本地文件 |
| 上报 | 下次启动 | 下次启动 | 即时 / 延迟 | 视设计 |
# 4.4 平台差异点矩阵
| 维度 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 异常型 | Java / Kotlin Exception | Swift error / NSException | JS Error / Promise rejection | C++ exception |
| 信号型 | NDK 信号 | Mach exception + signal | N/A(沙箱) | signal |
| 系统强杀 | LMK / Force Stop | jetsam / watchdog | Tab Killer | 看门狗 |
| Stack 符号化 | mapping.txt(ProGuard)+ NDK symbols | dSYM | sourcemap | DWARF |
| 多线程崩溃 | 单 dump 含所有线程 | 同 | 单线程为主 | 视实现 |
后续 §06-§12 各端原理章节会逐一展开。
# 05.度量与采集
# 5.1 三类捕获方案
所有平台的崩溃捕获方案,本质上只有 3 类:
① 异常 hook(语言运行时层)
② 信号 hook(系统 POSIX 层)
③ 系统级历史(启动时回查上次)
2
3
① 异常 hook——在语言运行时提供的全局异常入口注册回调。物理本质:拦截"语言运行时强制终止"前的最后机会。Java 的 Thread.setDefaultUncaughtExceptionHandler、iOS 的 NSSetUncaughtExceptionHandler、Web 的 window.addEventListener('error') 都是同一思想的不同实现——给运行时留一个 callback,让它在杀死进程前先告诉应用。详细原理见 §06(Java)、§09(iOS OC)、§12(Web)。
② 信号 hook——在 Native 层注册 POSIX signal handler。物理本质:在 OS 信号机制层拦截 Native 层崩溃。Native crash 不会经过 Java VM 的 UncaughtExceptionHandler,必须在系统层捕获——因为这一层崩溃的是 CPU 执行流,而非语言运行时。局限根源:信号处理上下文严格受限(async-signal-safe 函数列表);stack unwind 跨语言(C → Java → JNI)很复杂;SIGKILL 不可捕获。详细原理见 §07(Android Native)、§10(iOS Mach)。
③ 系统级历史——进程被 OS 强杀(OOM / Watchdog / Force Stop)时应用无法响应,只能下次启动时通过系统 API 回查。物理本质:应用代码不在场,OS 帮你记录。详细原理见 §08(ANR)、§11(iOS Watchdog/OOM)。
| 平台 | API |
|---|---|
| Android 11+ | ActivityManager.getHistoricalProcessExitReasons |
| iOS | MetricKit MXMetricPayload.crashDiagnostic |
| Web | N/A(用户关闭即丢) |
三类方案的总览
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 异常 hook | 语言运行时 | Java/JS 栈 | 极低 | 高 | ✅ | 不覆盖 Native |
| ② 信号 hook | OS 层 | Native 栈 | 极低 | 中(需 NDK) | ✅ | 信号处理受限 |
| ③ 系统历史 | OS API | 进程级 | 极低 | 中(API 受限) | ✅ | 颗粒度粗 |
方案的"组合定律":①+② 必须组合(覆盖所有运行时)+ ③ 补全静默崩溃。
# 5.2 各方案的可见盲区
| 现象 | 方案 ① | 方案 ② | 方案 ③ |
|---|---|---|---|
| Java / Swift 异常 | ✅ | ❌ | ❌ |
| Native crash (SIGSEGV) | ❌ | ✅ | 部分 |
| ANR | ❌ | ❌ | ✅ |
| OOM 被杀 | ❌ | ❌ | ✅ |
| SIGKILL(用户强杀) | ❌ | ❌ | ✅ |
| 静默退出 | ❌ | ❌ | ✅ |
# 5.3 跨平台采集对照表
| 维度 | Android | iOS | Web | C/C++ |
|---|---|---|---|---|
| 异常 | Bugly / Crashlytics / 自实现 | Crashlytics / Sentry | Sentry / window.onerror | terminate_handler |
| 信号 | Bugly NDK / Breakpad | KSCrash / Crashlytics | N/A | google-breakpad / crashpad |
| 系统历史 | getHistoricalProcessExitReasons | MetricKit | N/A | dmesg / journal |
# 5.4 数据可信度评估
| 数据 | 可信度 | 偏差来源 |
|---|---|---|
| 异常 stack | 高 | 直接 throwable |
| Native stack | 中 | 符号化失败导致部分缺失 |
| 用户操作链 | 中 | 缓冲区可能丢失最近事件 |
| 系统历史 | 高 | OS 直接记录 |
| 上报到达 | < 100% | 网络失败 / 应用未启动 |
# 06.Java 崩溃全链路
本章把 Java/Kotlin 崩溃从"throw 关键字"一路拆到"进程退出",回答四个核心问题:异常怎么"长大"成崩溃 / JVM 怎么找 handler / 应用应该在哪一层切进去 / 抓到的栈为何能定位代码。
# 6.1 崩溃如何产生
Java/Kotlin 崩溃的唯一定义是:某个线程抛出了未被 try-catch 接住的 Throwable。
这一定义有三个推论:
- 崩溃永远是"线程级"事件:不是"应用崩了",而是"某个线程把异常抛到了栈顶"。但 Android 默认所有线程的未捕获异常都会让进程死——这是 ART 的策略,不是语言规定。
Error和Exception都算:OutOfMemoryError、StackOverflowError这些"系统级"错误也走同样路径,没有特殊待遇。- 代码里 throw 和运行时 throw 等价:
null.method()由 JVM 内部 throw,与你手写throw NullPointerException()走同一条投递链路。
最常见的"无意识"触发源:
- 空安全失败:
obj!!.x/obj.x当 obj 为 null。 - 集合/数组越界:
list[idx]/arr[idx]。 - 类型转换失败:
obj as String。 - 数值/状态异常:
Integer.parseInt("abc")/IllegalStateException。 - 资源耗尽:
OutOfMemoryError(堆/Native heap/线程数/FD)。
探索性思考:为什么 Kotlin 比 Java 崩溃更少? 不是因为 Kotlin 更"安全",而是因为 Kotlin 把"可能为 null"的事实强制到了类型系统里——你必须用
?.或!!主动表达意图。这把"运行时 NPE"前移到了"编译时 nullable"。从根因上说,Kotlin 没有消灭崩溃,只是把崩溃的责任从"用户"转移到了"编写者"。
# 6.2 JVM 投递流程
业务代码 throw e
│
▼
JVM 沿调用栈向上找 catch(栈展开 stack unwinding)
│
├─ 找到 catch ──▶ 正常处理(不是崩溃)
│
└─ 走到线程顶层(Thread.run 之外)都没 catch
│
▼
ThreadGroup.uncaughtException(t, e)
│
▼
Thread.getUncaughtExceptionHandler() 查找
│ 1. thread.uncaughtExceptionHandler ← 单线程级别
│ 2. thread.threadGroup ← ThreadGroup 级别
│ 3. Thread.defaultUncaughtExceptionHandler ← 全局
│
▼
handler 返回后,ART 调 RuntimeInit + 进程 exit
│
▼
Zygote 感知 → AMS 处理 → 弹"应用已停止"对话框
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
两个关键事实:
① handler 查找有三级优先级——单线程 > ThreadGroup > 全局。这意味着第三方库可以单独给自己的线程设置 handler(不影响你的全局 handler);但反过来,你的全局 handler 也可能被它们"屏蔽"——某线程被单独设置了 handler 后,全局 handler 收不到它的崩溃。这是 SDK 集成时崩溃数据"莫名漏报"的常见根源。
② handler 执行完后,ART 仍然要杀进程。Thread.setDefaultUncaughtExceptionHandler 给你的是**"杀之前先告诉我"**,不是"让我决定要不要杀"。所以 handler 内绝对不能死循环或抛新异常,否则会出现"假死但不退出"的最差状态。
# 6.3 监听点设计
正确做法是装饰器模式——保存系统原 handler、做完自己的事后再回调它:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
val original = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
try { collectAndSave(t, e) } // 抓现场+落盘
catch (_: Throwable) { } // 兜底吞噬,避免递归崩溃
finally { original?.uncaughtException(t, e) } // 回交原 handler
}
}
}
2
3
4
5
6
7
8
9
10
11
为什么必须回调原 handler?原 handler 做了三件你不能省的事:
- 打印 logcat:开发者还能从 logcat 看到栈,否则线上 release 包"什么都没有"。
- 通知 ActivityManager:让 AMS 弹"应用已停止"对话框、清理 Activity 栈。
- 真正
Process.killProcess:保证进程一定退出,不会"假死"。
如果你跳过这一步,进程进入"主线程已死、其他线程还在跑、UI 冻住、Activity 没清理"的诡异状态——用户体验比直接闪退还差。
注册时机为什么必须最早?Application.onCreate 第一行是底线,更严谨的做法是在 attachBaseContext 里注册。因为:
- ContentProvider 的
onCreate在 Application 之前执行——很多 SDK(推送/统计)通过 CP 偷启动,这部分崩溃会被漏掉。 - 静态初始化块(
static {}、companion object)也可能在 Application.onCreate 之前跑。
# 6.4 堆栈采集
崩溃 handler 拿到的 Throwable 自带 stack,但只有抛异常的那个线程的栈。这够吗?
对单线程逻辑错误来说够了(NPE/越界),但对多线程协作类崩溃完全不够:
- 死锁:主线程在等 worker,worker 在等主线程——只看主线程栈"在 wait"什么都说明不了。
- OOM:主线程抛 OOM 时,真正吃内存的是 background 线程。
- 被异常打断的协程:异常抛在协程的某个 continuation,原始 launch 点不在栈里。
所以现场采集要做三件事:
| 数据 | API | 价值 |
|---|---|---|
| 异常线程栈 | e.stackTraceToString() | 业务调用链 |
| 全线程栈 | Thread.getAllStackTraces() | off-CPU 类崩溃 / 锁竞争归因 |
| cause 链 | 遍历 e.cause | 包装异常(如 InvocationTargetException 嵌 NullPointerException) |
探索性思考:cause 链为什么重要? Spring/Retrofit/Dagger 这类框架会用反射/代理调用业务代码,真正的崩溃异常会被包成
InvocationTargetException。如果只看最外层,你看到的是"反射调用失败"——这等于没说。必须递归剥开 cause 才能找到根因。
# 6.5 符号化原理
线上 release 包都开了 R8/ProGuard,类名/方法名被压缩成 a/b/c:
原始栈: java.lang.NullPointerException at com.app.a.b.c(Unknown Source:5)
符号化后:java.lang.NullPointerException at com.app.user.UserListActivity.onCreate(UserListActivity.kt:42)
2
这个还原能成立,依赖一份关键文件:mapping.txt——R8 编译时输出,记录"原名 → 混淆名"的双向映射,包括类、方法、字段、行号。
核心约定(不可商量):
- 每次 release 构建归档 mapping.txt(按 versionCode 命名)。
- CI 自动上传到崩溃平台(Bugly/Crashlytics/Sentry)。
- 不要混淆 SDK 的回调接口/native 方法签名(用
-keep规则)——这会导致 native 找不到 Java 方法。
探索性思考:为什么 mapping 必须按 versionCode 归档,而不是覆盖? 用户不会同时升级——线上常年有 3-5 个版本并存。今天上线 V12.1,但还在收 V11.8 的崩溃。如果只留最新版的 mapping,老版本崩溃栈就永远是黑话。这是很多团队"线上崩溃看不懂"的根因。
# 07.Native 崩溃全链路
本章把 Native 崩溃从"野指针解引用"一路拆到"tombstone 落地",重点回答:崩溃发生在 CPU 还是内核 / 信号怎么找到我的进程 / 为什么 handler 不能 malloc / unwind 跨语言难在哪。
# 7.1 崩溃如何产生
Native(C/C++)崩溃的本质和 Java 完全不同——Java 是"语言运行时主动抛",Native 是"CPU 执行非法指令被硬件拦下来"。两者的传递路径有 3 层鸿沟:
CPU → 内核异常处理 → POSIX 信号 → 应用 handler
(硬件层) (内核层) (用户态) (应用层)
2
典型触发源:
| 触发源 | 信号 | 物理含义 |
|---|---|---|
解引用空指针 *null | SIGSEGV | MMU 检测到访问未映射地址 |
| 野指针 / use-after-free | SIGSEGV | 访问的地址不属于本进程 |
| 未对齐访问 | SIGBUS | 某些 CPU(ARM)要求自然对齐 |
| 整数除零 | SIGFPE | ALU 触发除零异常 |
主动 abort() | SIGABRT | 标准库主动调 raise |
assert(false) | SIGABRT | 同上 |
| 栈溢出(递归过深) | SIGSEGV | guard page 被访问 |
| C++ 未捕获异常 | SIGABRT | terminate handler 默认行为 |
探索性思考:为什么 Java NPE 不需要 sigaction 也能捕获? Java 对象访问会先做 null 检查(编译器插入或 JIT 优化保留),检查失败时主动 throw NullPointerException——走的是 Java 异常路径,不会真正解引用 null。但 Native 代码里
int *p=NULL; *p=1;会真的让 CPU 去访问地址 0,由 MMU 触发硬件异常——所以必须在信号层捕获。
# 7.2 信号投递流程
CPU 执行 mov [null], 1
│
▼
MMU 触发 page fault(访问违规)
│
▼
内核 do_page_fault() 判定为用户态错误
│
▼
force_sig_info(SIGSEGV, ...) 向当前进程发信号
│
▼
返回用户态前内核检查 pending signals
│
├─ 应用注册了 sigaction ──▶ 跳到 handler 执行
│
└─ 默认行为 ──▶ Android 由 debuggerd 接管
│
▼
debuggerd 写 tombstone → /data/tombstones/
│
▼
内核 do_exit → Zygote 感知 → AMS 处理
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
两个关键事实:
① 信号是"内核投递",不是"运行时主动抛"。这意味着:
- 应用不能选择"是否接收"信号,只能选择"接收后做什么"。
- 信号传递在内核态完成,应用层 hook 必须用系统 API(sigaction)。
② Android 有一道"系统兜底"叫 debuggerd。即使应用没注册 sigaction,崩溃也会被 debuggerd 写到 /data/tombstones/——这就是为什么 ADB shell 能拿到 native crash 栈。但应用从 Android 7+ 普通权限读不到 tombstone,所以仍需要自己捕获。
# 7.3 监听点设计
正确的注册模板有 4 个不可省的元素:
void install_native_crash_handler() {
// ① 备用栈:栈溢出崩溃时,主栈已不可用
static char alt_stack[SIGSTKSZ];
stack_t ss = { .ss_sp = alt_stack, .ss_size = SIGSTKSZ };
sigaltstack(&ss, NULL);
// ② SA_SIGINFO 拿到详细信息 + SA_ONSTACK 用备用栈
struct sigaction sa = {0};
sa.sa_sigaction = crash_handler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
// ③ 必捕的 6 个信号
int sigs[] = { SIGSEGV, SIGBUS, SIGABRT, SIGFPE, SIGILL, SIGTRAP };
for (int sig : sigs) sigaction(sig, &sa, NULL);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
每一项的"为什么":
① 备用栈(sigaltstack):栈溢出(SIGSEGV at stack guard)是常见崩溃,此时主栈已经爆了。如果 handler 还要在主栈上跑,handler 自己也会立刻栈溢出——结果是信号被默认处理,应用直接死,你什么都收不到。备用栈让 handler 有一块独立的内存可用。
② SA_SIGINFO:拿到 siginfo_t,里面有崩溃地址(si_addr)、错误码(si_code)等关键信息,能区分"空指针解引用"还是"野指针"。普通 signal() 拿不到这些。
③ async-signal-safe 限制——这是 Native handler 最大的认知坑。信号处理上下文里只能调一类特殊函数:
| 能用 | 不能用 |
|---|---|
write/read | printf/sprintf(stdio 锁可能在崩溃线程持有) |
_exit/raise | exit(会跑 atexit handlers,可能死锁) |
sigaction/kill | malloc/free/new/delete(heap 锁同上) |
mmap/munmap | 任何 std:: 容器、Objective-C 消息发送 |
| 内置原子操作 | pthread_mutex_lock(可能死锁) |
违反限制的后果:handler 自身死锁或崩溃,应用看上去"卡住几秒后挂",崩溃数据完全丢失。
探索性思考:为什么 malloc 不安全? malloc 内部用全局锁保护 free list。如果崩溃线程在 malloc 中途持有锁就崩了,handler 里再 malloc 就会自死锁——等一把自己持有的锁。这就是所谓"信号是异步事件"的本质陷阱。
handler 末尾必须 raise(signo):handler 跑完后必须让信号回到默认处理(杀进程),否则 ART 不会清理 Java 层资源,应用进入半死状态。
# 7.4 堆栈采集
Native 抓栈的核心算法叫 stack unwinding——给定当前 PC(指令指针)和 FP(帧指针),沿调用链回溯每一帧。
两类实现:
| 方法 | 原理 | 优缺点 |
|---|---|---|
| 基于 FP 链回溯 | ARM64/x86 调用约定下每帧 FP 指向上一帧 FP | 简单快,但需 -fno-omit-frame-pointer |
| 基于 .eh_frame / DWARF unwind | 用编译器生成的 CFI 信息 | 通用,但解析复杂 |
libunwind / _Unwind_Backtrace(gcc 提供)走第二种,能在 release 包正确工作。
采集后只能拿到地址(如 0x12a4),还需要做两件事:
dladdr(addr, &info):定位到属于哪个 so(info.dli_fname)和库基址(info.dli_fbase)。- 计算 offset:
addr - dli_fbase,得到"在该 so 内的偏移"——这是后续符号化的输入。
最终现场记录大致是:
#00 pc 0x000012a4 /data/app/com.x/lib/arm64/libnative.so+0x000012a4
#01 pc 0x00001340 /data/app/com.x/lib/arm64/libnative.so+0x00001340
#02 pc 0x00056789 /apex/com.android.runtime/lib64/libart.so
2
3
跨语言栈的两大难点:
- C → Java(JNI 调用):unwind 到
JNICall边界后无法继续——Java 层栈在 JVM 的内部数据结构里,不在 native 栈上。需要在 handler 里额外抓 Java 栈(通过 JNIEnv 反射调用Thread.getStackTrace,但信号上下文不能调 JNI,需要拷出关键数据到独立线程再调)。 - JIT/AOT 代码:ART 编译的 Java 方法运行时也在 native 栈上,但没有 .eh_frame 信息,普通 unwind 直接断掉。需要走 ART 内部 API。
这两点是为什么 xCrash/Breakpad 等成熟方案优于自己写——它们处理了大量边界 case。
# 7.5 符号化原理
Native 符号化的"输入"是 <so 路径, offset>,输出是 <函数名, 文件名, 行号>。能完成转换的依据是编译时保留的调试信息(DWARF)。
问题在于:release 包必须 strip(debug section 太大,会让 .so 体积膨胀几倍)。所以工程实践是:
编译产物 obj/local/arm64-v8a/libnative.so ← 未 strip,含 DWARF
打包到 APK 的 jniLibs/arm64-v8a/libnative.so ← strip 后,体积小
2
关键约定:每次构建归档未 strip 版本(与 mapping.txt 同样按 versionCode 命名),上传到崩溃平台。
工具链:
ndk-stack -sym obj/local/arm64-v8a -dump tombstone.txt:批量符号化整份崩溃日志。addr2line -e libnative.so -f -C 0x12a4:单地址查询,输出函数名 + 源码行。
探索性思考:为什么 Native 符号化比 Java 复杂这么多? Java 的混淆是全局名字映射(一对一替换),mapping.txt 几 KB;Native 的符号化是程序计数器到 AST 节点的映射,DWARF 数据可能几十 MB。前者只解决"名字",后者还要解决"哪一行"——因为同一个函数可能 inline 到很多地方。
# 08.ANR 全链路
ANR 严格说不是"崩溃"(进程没死),但用户感知一样恶劣。本章重点回答:为什么"卡 5 秒"会成为 ANR / 系统怎么把 ANR 投递到应用 / 应用为什么无法直接收到通知。
# 8.1 ANR 如何产生
ANR(Application Not Responding)的本质是 system_server 投递给应用的某项工作,应用主线程在预算时间内没完成。
各类组件的超时阈值:
| 组件 | 超时 | 触发条件 |
|---|---|---|
| Input | 5 秒 | 触摸/按键事件 5s 未被分发处理 |
| BroadcastReceiver(前台) | 10 秒 | onReceive 同步阻塞 10s |
| BroadcastReceiver(后台) | 60 秒 | 同上 |
| Service(前台) | 20 秒 | onStartCommand/onBind 阻塞 |
| Service(后台) | 200 秒 | 同上 |
| ContentProvider | 10 秒 | publish 超时 |
这些数字背后有统一哲学:人类感知卡顿的容忍上限 ≈ 5 秒。Input 5 秒是最严格的,因为触摸响应直接对应用户操作。Service 比 BroadcastReceiver 宽松,因为前者通常是后台逻辑。
根本根因永远是主线程被阻塞,具体形态有 4 种:
- 同步 IO:主线程读文件 / 查 DB / 跨进程查询。
- 锁等待:主线程 wait 一个被后台线程持有的锁。
- Binder 同步调用:跨进程调用对方进程很慢。
- 死循环 / 忙等:业务 bug。
# 8.2 系统投递流程
InputDispatcher (system_server) 派发触摸事件给 App
│
▼
App 主线程 InputEventReceiver 应该 5s 内 ack
│
├─ 按时 ack ──▶ 正常
│
└─ 5s 未 ack
│
▼
AMS.appNotResponding()
│
├─ 向应用进程发 SIGQUIT (signal 3)
│ │
│ ▼
│ ART signal handler 接管 → dump 所有线程栈到 /data/anr/traces.txt
│
├─ 抓取 CPU 使用率快照(top)
│
▼
决策:弹"应用无响应"对话框 / 直接 kill / 静默记录
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
关键事实:
① 信号是 SIGQUIT 而不是普通信号。SIGQUIT 在 Android 被 ART 特殊处理——收到后 ART 不会让进程死,而是 dump 所有线程的 Java/Native 栈到 traces.txt。这是 system_server 主动"采证"的方式:先看看你在干什么,再决定杀不杀。
② 应用自己收不到 ANR 通知。system_server 不会广播给当前应用——这设计是合理的:"你都已经无响应了,怎么还能处理通知"。所以应用层必须主动监测或事后查询。
# 8.3 三种监听方式
| 方式 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| A 主动 Watchdog | 独立线程定期 post 任务到主线程,检查是否回包 | 实时、跨版本通用 | 阈值不准(5s 是经验值) |
| B SIGQUIT Hook | hook 信号处理,截获 ART 的 dump | 与系统判定 100% 一致 | hook 风险,需 native 改造 |
| C ApplicationExitInfo | A11+ 系统 API 事后查询 | 合规、无 hook | 延迟(重启后才能拿) |
主动 Watchdog 的核心思想:
class AnrWatchDog : Thread() {
@Volatile private var ticker = 0
override fun run() {
val mainHandler = Handler(Looper.getMainLooper())
while (!isInterrupted) {
val before = ticker
mainHandler.post { ticker++ } // 主线程能跑就+1
sleep(5000)
if (ticker == before) { // 5s 没动 → 准 ANR
report(Thread.getAllStackTraces())
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
探索性思考:为什么 Watchdog 是"准"ANR? 它和系统 ANR 判定机制不同:系统看的是"事件 ack 是否超时",Watchdog 看的是"主线程 Looper 是否能处理消息"。两者在 95% 场景一致,但 5% 不一致——比如 Looper 在飞但每个消息都很慢、或者 Looper 没消息可处理时反而看起来"很闲"。所以 Watchdog 适合早期预警,权威判定还得靠 ApplicationExitInfo。
ApplicationExitInfo(A11+)的价值:拿到系统保存的真实 ANR trace,与系统判定 100% 一致:
val am = getSystemService(ActivityManager::class.java)
am.getHistoricalProcessExitReasons(null, 0, 10)
.filter { it.reason == ApplicationExitInfo.REASON_ANR }
.forEach { info -> report(info.traceInputStream?.readBytes()) }
2
3
4
# 8.4 抓全线程栈
ANR 现场抓栈的"陷阱"是只抓主线程。主线程栈往往看起来是:
at java.lang.Object.wait (Native Method)
at java.lang.Thread.parkFor$ (Thread.java:1220)
at sun.misc.Unsafe.park (Unsafe.java:299)
at ...MyLock.acquire(MyLock.java:42)
2
3
4
这告诉你"主线程在等一把锁"——但等谁的锁、谁持着它、那个线程在干什么,全看不到。
正确做法是主线程优先 + 其他线程全抓,并标注每个线程的状态(BLOCKED/WAITING/RUNNABLE)。这就是为什么 §3.5 系统 dump 总是包含所有线程的设计——状态机告诉你"等待者关系",栈告诉你"具体动作"。
# 8.5 归因三步法
抓到全线程栈后,定位 ANR 的标准方法论是三步法:
第一步:看主线程栈顶
Native Method(如Object.wait/park)→ 主线程在等待 → 走第二步。- 业务方法(
UserListActivity.refresh)→ 主线程在跑业务但跑得慢 → 直接看代码。
第二步:看主线程状态
BLOCKED:在等 monitor 锁(synchronized或ReentrantLock)。WAITING/TIMED_WAITING:在等条件变量、Future、Binder 调用。RUNNABLE:CPU bound(如 JSON 解析、图片处理)。
第三步:如果 BLOCKED,反向追锁持有者
- 现代 Android Studio profiler 会显示 lock owner,但 dump 文本里需要自己找。
- 看其他线程的栈顶——通常持锁线程也在做某个具体操作(IO/网络),这就是真正的 ANR 根因。
探索性思考:为什么 ANR 归因比崩溃归因难得多? 崩溃是"某行代码错了"——栈顶就是凶手。ANR 是"系统整体响应慢了"——可能是 10 个原因的组合:主线程任务多 + 后台线程抢 CPU + 系统资源紧张 + Binder 远端慢。所以 ANR 治理本质是性能治理,不是简单的 bug 修复。详细治理思路见
卷三·04 ANR 监控与治理。
# 09.iOS 异常全链路
本章拆解 iOS OC 异常路径,重点回答:OC 异常和 Swift fatalError 为什么走不同路径 / 为什么仅接 NSSetUncaughtExceptionHandler 完全不够。
# 9.1 OC 与 Swift 差异
iOS 上有两种"崩溃源",但它们的底层路径完全不同——这是很多团队"接了 handler 还漏崩溃"的根因。
Objective-C NSException:纯 OC 语言层异常,可被 @try/@catch 接住。
NSArray *a = @[]; a[5]; // NSRangeException
[d setValue:nil forKey:@"k"]; // NSInvalidArgumentException
[someObj performSelector:@selector(noMethod)]; // unrecognized selector
@throw [NSException exceptionWithName:@"Biz" ...]; // 主动抛
2
3
4
Swift fatalError 与"陷阱":编译器在如下场景插入 trap 指令:
let arr: [Int] = []; _ = arr[5] // 数组越界
let opt: Int? = nil; _ = opt! // 强解包 nil
fatalError("biz") // 主动 fatalError
preconditionFailure(...) // precondition 失败
2
3
4
关键差异:
| 维度 | Objective-C NSException | Swift Trap |
|---|---|---|
| 触发方式 | runtime 主动抛 | 编译器插入 __builtin_trap() |
| 是否能 catch | 可以 @try/@catch | 不能(trap 是 CPU 指令) |
| 走哪条路径 | NSUncaughtExceptionHandler → SIGABRT | 直接 SIGILL / SIGTRAP → §10 Mach |
| 监听 API | NSSetUncaughtExceptionHandler | sigaction 或 Mach handler |
探索性思考:为什么 Swift 设计成"trap 而非异常"? Swift 的核心设计原则之一是**"错误必须显式处理"**——
throws/try/catch用于可恢复错误,fatalError 是"绝对不可恢复"的承诺。如果允许 catch fatalError,就破坏了"不可恢复"的语义。这是语言哲学的差异:OC 让所有异常可 catch,Swift 把"程序员保证不会发生的错"做成不可恢复。
# 9.2 OC 投递流程
[@throw exception] 或 系统抛出 NSException
│
▼
objc_exception_throw(exception)
│
▼
沿调用栈找 @catch(用 C++ 异常机制实现的 unwind)
│
├─ 找到 @catch ──▶ 正常处理
│
└─ 走到栈顶都没 catch
│
▼
NSUncaughtExceptionHandler ← 应用注册点
│
▼
handler 返回后,runtime 调 abort()
│
▼
abort() 内部 raise(SIGABRT) ──▶ 走 §10 Mach Exception 路径
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键事实:NSException 不是终点,而是"信号路径的上游"——最终也会变成 SIGABRT 信号。这意味着:
- 只接 NSSetUncaughtExceptionHandler 的应用,能抓 OC 异常但抓不到 SIGABRT 触发的崩溃。
- 只接 Mach/signal handler 的应用,能抓 SIGABRT 但拿不到"原异常对象"的 reason/userInfo。
- 必须两者结合:NSException handler 拿语义信息,Mach handler 兜底兜全。
# 9.3 监听点设计
static void handleUncaughtException(NSException *exception) {
NSDictionary *report = @{
@"name": exception.name ?: @"",
@"reason": exception.reason ?: @"",
@"stack": exception.callStackSymbols ?: @[],
};
[self saveReportToDisk:report]; // handler 退出后进程就死,必须立刻落盘
}
NSSetUncaughtExceptionHandler(&handleUncaughtException); // didFinishLaunching 第一行
2
3
4
5
6
7
8
9
10
三个隐藏陷阱:
① handler 是全局单例。如果同时接 Crashlytics 和 KSCrash,后注册的会覆盖先注册的——除非新 handler 在执行完自己逻辑后手动转交旧 handler。SDK 顺序错了就有数据漏报。这与 §6.3 Android 装饰器模式同理——这是一个跨平台的"hook 链"基本功。
② handler 执行期间不能做太多事。runtime 已处于"即将退出"状态,runloop 不再转,Core Animation 不再渲染,网络 API 行为未定义。只能做:抓栈 + 写本地文件。
③ 抓不到子线程的 OC 异常——非主线程的 NSException 默认直接调 abort,不走 NSSetUncaughtExceptionHandler。要捕获子线程异常,必须用 KSCrash 这类 hook objc_exception_throw 的方案。
# 9.4 堆栈与符号化
iOS 的 [NSException callStackSymbols] 是个贴心 API——它返回的已经是符号化字符串:
0 CoreFoundation 0x00000001 __exceptionPreprocess + 165
1 libobjc.A.dylib 0x00000002 objc_exception_throw + 48
2 MyApp 0x00000003 -[ViewController buttonTapped:] + 234
3 UIKit 0x00000004 -[UIControl sendAction:to:forEvent:] + 78
2
3
4
但这有两个边界:
- 应用方法在开发期是符号化的(dyld 加载时携带符号表),在 App Store release 包是地址(如
MyApp 0x100002a8c)——苹果会 strip 应用的符号表。 - 不能在信号 handler 里调(malloc 不安全)。
线上还原必须靠 dSYM:
# atos:单地址查询
atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x100002a8c
# 输出:-[ViewController buttonTapped:] (in MyApp) (ViewController.m:42)
2
3
-l 0x100000000 的含义:这是 load address——可执行文件被加载到内存的基址。需要从崩溃日志的 Binary Images 部分读取该次崩溃的真实基址(ASLR 让每次启动基址都不同),用错了基址符号化结果完全是错的。
探索性思考:为什么 iOS 比 Android 多一个 ASLR 问题? 实际上 Android 也有 ASLR,但 mapping.txt 的"原名→混淆名"是编译期固定的,不受加载地址影响。dSYM 的"地址→源码"是绝对地址,必须减去 load address 得到相对偏移。这是符号化体系设计差异——前者是"名字映射",后者是"地址映射"。
# 10.Mach 异常全链路
本章拆解 iOS 底层异常机制,重点回答:Mach 微内核如何投递异常 / 为什么 KSCrash 选 Mach 而非 signal / 独立线程接管的工程价值。
# 10.1 双层异常机制
iOS/macOS 底层是 Mach 微内核 + BSD 层,硬件异常有两个截获机会:
int *p = NULL; *p = 1; // EXC_BAD_ACCESS(KERN_INVALID_ADDRESS)
1 / 0; // EXC_ARITHMETIC
abort(); // EXC_CRASH(通过 SIGABRT)
__builtin_trap(); // EXC_BREAKPOINT(Swift 数组越界编译成此指令)
2
3
4
Swift 的 fatalError、数组越界、强解包 nil 都编译成 trap 指令,触发 EXC_BREAKPOINT。这就是为什么 Swift 的异常都要走 Mach 层抓——OC 异常路径根本不会被它们经过。
# 10.2 Mach 投递流程
CPU 执行非法指令
│
▼
Mach 内核捕获硬件异常
│
▼
Mach Exception Port 投递(每个 task 有 exception port)
│
├─ 应用注册了 Mach exception handler ──▶ 应用接管
│
└─ 未注册 ──▶ 转换为 BSD 信号(如 EXC_BAD_ACCESS → SIGSEGV)
│
▼
BSD signal 投递
│
├─ 应用注册了 sigaction ──▶ 应用接管
│
└─ 默认行为 ──▶ ReportCrash 生成 .ips 崩溃日志,进程被杀
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
关键事实:Mach 层比 BSD 层更原始更早。BSD signal 实际上是 Mach exception 的一个"fallback"——只有 Mach 没人接才会转 signal。这意味着 KSCrash 选 Mach 不是技术炫技,而是抓"第一手数据"。
# 10.3 监听点设计
方案 A:BSD Signal(同 Android Native,简单)
struct sigaction sa = {0};
sa.sa_sigaction = signal_handler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigaction(SIGSEGV, &sa, NULL);
sigaction(SIGABRT, &sa, NULL);
sigaction(SIGTRAP, &sa, NULL); // Swift trap
// ... 其他信号
2
3
4
5
6
7
方案 B:Mach Exception Port(KSCrash 选用)
核心思路是创建独立线程接收 Mach 异常消息:
void install_mach_handler() {
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &exception_port);
// 注册到本 task 的所有崩溃异常类型
task_set_exception_ports(mach_task_self(),
EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION |
EXC_MASK_ARITHMETIC | EXC_MASK_BREAKPOINT | EXC_MASK_CRASH,
exception_port,
EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES, THREAD_STATE_NONE);
pthread_create(&thread, NULL, exception_handler_thread, NULL);
}
static void* exception_handler_thread(void* arg) {
while (1) {
mach_msg_header_t msg;
mach_msg(&msg, MACH_RCV_MSG, 0, sizeof(msg), exception_port, ...);
handle_mach_exception(&msg); // 抓栈、写本地
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
为什么 Mach 比 BSD Signal 显著更好:
- 独立线程执行:Mach handler 不在崩溃线程内跑——崩溃线程的寄存器/栈保持原状,抓栈数据更完整。BSD signal handler 是同步打断崩溃线程,破坏了原始状态。
- 不受 async-signal-safe 限制:在独立线程,可以用
malloc、Objective-C 消息发送——handler 能写得更安全更完整。 - 早于 BSD signal 触发:第一手数据。
- 可以兜底转交系统:处理完后把 Mach 消息原样转发回系统,让 ReportCrash 仍能生成 .ips(便于线上验证)。
探索性思考:为什么 Apple 不直接推荐用 Mach? 因为它是 private API(虽然不会被审核拒)。Apple 推荐应用走 BSD signal + MetricKit,但工程上 KSCrash/PLCrashReporter 普遍用 Mach 因为质量好。这是"官方推荐 vs 工程最佳实践"的典型分歧。
# 10.4 堆栈采集
独立线程里抓崩溃线程的栈,核心步骤是读崩溃线程的寄存器,然后沿 frame pointer 回溯:
void dump_thread_stack(thread_t thread, int fd) {
// 1. 获取崩溃线程的寄存器状态(PC/FP/SP/LR)
arm_thread_state64_t state;
mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT;
thread_get_state(thread, ARM_THREAD_STATE64, (thread_state_t)&state, &count);
// 2. 从 PC 开始 unwind,沿 FP 链回溯
uintptr_t pc = state.__pc, fp = state.__fp;
while (fp != 0) {
// 用 dladdr 把 PC 翻译成 <so 路径, offset>
// ... 详见 §7.4 同款方法
pc = *(uintptr_t*)(fp + 8); // ARM64 调用约定:fp+8 是返回地址
fp = *(uintptr_t*)(fp); // fp 指向上一帧 fp
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ARM64 调用约定的关键:每个函数调用时,编译器约定:
fp(x29)指向上一帧的 fp。fp + 8存的是返回地址(即调用者的 PC)。
这形成了 fp 链表——理论上可以一直回溯到 main()。但前提是编译没开 -fomit-frame-pointer 优化(去掉 fp 寄存器节省寄存器分配),所以 release 包要显式保留 fp。
符号化部分同 §9.4:用 atos + dSYM,从 PC 减去 load address 得偏移。
# 11.Watchdog 与 OOM
本章拆解 iOS 上不可捕获的崩溃,回答:SIGKILL 为什么是物理上不可捕获 / 系统怎么记录这些事件 / MetricKit 的延迟为什么是合理代价。
# 11.1 SIGKILL 不可捕获
SIGKILL(信号 9)和 SIGSTOP(信号 19)是POSIX 规定无法捕获、无法忽略、无法重新映射的两个信号。原因是它们是"系统对进程的绝对控制权"——如果应用能拒绝 SIGKILL,那"用户主动杀进程"和"系统 OOM 救命"就都失效。
iOS 中所有"系统主动杀"都用 SIGKILL:
- Watchdog(启动 hang):超时后 launchd 发 SIGKILL。
- Jetsam(内存压力):kernel 选中后发 SIGKILL。
- 用户从应用切换器划掉:springboard 发 SIGKILL。
这意味着应用代码完全没有响应机会——崩溃 handler 不会触发,本地数据无法保存。
# 11.2 系统强杀机制
Watchdog(启动 hang):
App 启动 → main() → application:didFinishLaunching:
│
├─ 20 秒内完成 ──▶ 正常
│
└─ 超时
│
▼
launchd 给 App 进程发 SIGKILL(无法捕获)
│
▼
ReportCrash 写崩溃日志(类型 0x8badf00d)
│
▼
App 完全没有响应机会
2
3
4
5
6
7
8
9
10
11
12
13
14
iOS 启动超时配置:
- 前台启动:~20 秒。
- 后台→前台过渡:~5 秒。
- 后台启动(Background Mode):~30 秒。
崩溃日志中的特征码 0x8badf00d("ate bad food" 的字母双关)是 Watchdog 的标识。
Jetsam(OOM):
iOS 没有传统 Linux OOM Killer,用 Jetsam 机制——内存吃紧时按"jetsam priority"从低到高杀进程:
内存压力达到阈值 → kernel 计算 jetsam priority
│
▼
按优先级从低到高杀进程
│
▼
被杀进程收到 SIGKILL(不可捕获)
│
▼
下次启动后,可通过 MetricKit 拿到 jetsam 报告
2
3
4
5
6
7
8
9
10
优先级规则(高到低,越高越容易被保留):
| Priority | 类型 |
|---|---|
| FOREGROUND | 当前前台应用 |
| FOREGROUND_HIGH_BAND | 前台高优 |
| FOREGROUND_BAND | 前台普通 |
| BACKGROUND | 后台 |
| BACKGROUND_OPPORTUNISTIC | 后台机会性 |
| IDLE | 完全 idle |
探索性思考:前台应用也会被 Jetsam 杀吗? 会。当系统内存极度紧张(如同时跑大型游戏 + 视频通话),即使前台应用也可能被杀。但这种情况苹果会优先杀 priority 低的——所以你的应用越"占内存",被杀的优先级越高。降低内存占用不只是为了避免 OOM 错误,更是为了在系统压力下"被保留"。
# 11.3 MetricKit 兜底
既然应用层不可能捕获 SIGKILL,唯一办法是让系统替你记录,下次启动后给你。这就是 MetricKit(iOS 13+)的设计:
import MetricKit
class MetricSubscriber: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) { /* 性能指标 */ }
@available(iOS 14.0, *)
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
// 启动 hang
payload.applicationLaunchDiagnostics?.forEach { hang in
report("launch_hang", hang.callStackTree.jsonRepresentation())
}
// OOM/Jetsam
payload.crashDiagnostics?.forEach { crash in
if crash.terminationReason?.contains("memory") == true {
report("oom", crash.callStackTree.jsonRepresentation())
}
}
}
}
}
MXMetricManager.shared.add(MetricSubscriber()) // didFinishLaunching 注册
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
MetricKit 提供符号化好的 callStackTree(JSON 格式,含 binary UUID/offset/symbol),不需要你自己 atos。
关键边界:
- MetricKit 延迟 24 小时才能拿到(系统聚合机制)→ 不能实时告警,只能事后分析。
- 数据量较小(系统统计采样),不能用于"逐崩溃归因"。
探索性思考:为什么 MetricKit 必须延迟 24 小时? 因为系统在后台聚合多个应用的指标后批量推送(统一调度避免唤醒应用)。这是苹果"省电优先"哲学的体现——为了让你的应用不被唤醒,宁可数据延迟。这是不可改变的物理约束,所以 MetricKit 适合周/月报,不适合发版回归。
# 11.4 异常退出排除法
iOS 12 及之前完全没有办法捕获 OOM/Watchdog → 用"异常退出排除法":
思路:启动时检查上次是否正常退出;如果没有正常退出且没有任何已知崩溃类型,那剩下的必然是 OOM/Watchdog 嫌疑。
let lastExitNormal = UserDefaults.standard.bool(forKey: "lastExitNormal")
if !lastExitNormal {
let lastVersion = UserDefaults.standard.string(forKey: "lastVersion")
// 排除已知崩溃类型(NSException / Mach / Signal),剩下的就是 OOM/Watchdog 嫌疑
if !hasKnownCrash(lastVersion) {
report(type: "unknown_kill", lastVersion: lastVersion)
}
}
UserDefaults.standard.set(false, forKey: "lastExitNormal")
// 在 applicationWillTerminate 中置为 true(正常退出)
2
3
4
5
6
7
8
9
10
这种方法的局限:
- 不能区分 OOM vs Watchdog vs 用户主动杀——只知道"不正常退出"。
- applicationWillTerminate 不一定被调用(系统杀时不一定调)——导致"被杀但下次启动以为正常退出"。
- 统计不精确:只能给出"嫌疑总量",不能定位具体堆栈。
所以 iOS 13+ 必须用 MetricKit,老版本只能凑合用排除法。
# 12.Web 错误全链路
Web 没有传统进程崩溃概念(浏览器沙箱),本章重点回答:没有 SIGSEGV 的世界里什么是"崩溃" / capture 阶段为什么是资源错误捕获的关键 / sourcemap 为什么不能部署到 CDN。
# 12.1 Web 四类故障
Web 应用运行在浏览器沙箱里,进程崩溃不属于应用层处理范畴(属于浏览器 Tab Killer)。应用层只需关心 4 类故障:
| 类型 | 触发 | 影响 |
|---|---|---|
| JS 运行时错误 | null.foo / undefined() | 当前调用栈终止,应用可继续 |
| Promise rejection 未处理 | Promise.reject(...) 没 catch | 控制台报错,应用继续 |
| 资源加载失败 | <img src=404> / 脚本 404 | 部分功能不可用 |
| 页面崩溃 | 内存爆炸 / Tab 进程崩溃 | 显示"页面无响应" |
关键差异:与 Android/iOS 不同,Web 错误不必然导致应用退出——JS 错误后应用通常还能继续跑(甚至用户没感知)。这意味着"崩溃捕获"的语义在 Web 是"错误捕获"。
# 12.2 V8 投递流程
JS 抛错(throw / 隐式)
│
▼
V8 引擎沿调用栈找 try/catch
│
├─ 找到 ──▶ 正常处理
│
└─ 没找到
│
▼
Window.dispatchEvent('error', ErrorEvent) ← 应用监听点
│
▼
事件冒泡到 window
│
▼
如未 preventDefault → 控制台打印
────────────────────────────
Promise 未捕获 rejection:
await reject 或 .then(...) 无 catch
│
▼
宿主调度 microtask 完成后检测
│
▼
Window.dispatchEvent('unhandledrejection', PromiseRejectionEvent) ← 应用监听点
────────────────────────────
资源加载失败:
<img/script/link> 加载失败
│
▼
元素本身派发 'error' 事件(不冒泡到 window) ← 必须用 capture 阶段才能在 window 监听
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
关键事实:
① 三类错误走三个不同事件,必须分别监听——只接一个会漏其他两类。
② 资源错误不冒泡——这是 DOM 规范里的一个"反直觉设计"。资源加载失败的 error 事件只在元素自身派发,不冒泡到父级。要在 window 监听,必须用 capture 阶段(事件下沉阶段)。
# 12.3 三个监听点
// 1. JS 运行时错误 + 资源加载失败(同一个 listener,capture=true)
window.addEventListener('error', (event) => {
if (event.target && event.target.tagName) {
// 资源错误(IMG/SCRIPT/LINK 等)
report({ type: 'resource_error', tagName: event.target.tagName, src: event.target.src });
} else {
// JS 运行时错误
report({
type: 'js_error',
message: event.message,
stack: event.error?.stack, // 关键:完整调用栈
filename: event.filename,
lineno: event.lineno,
});
}
}, true); // ⚠️ capture=true 是关键
// 2. Promise rejection(必须单独监听)
window.addEventListener('unhandledrejection', (event) => {
report({
type: 'promise_rejection',
reason: String(event.reason),
stack: event.reason?.stack,
});
});
// 3. 框架错误(React/Vue 走自己的体系)
// React: ErrorBoundary componentDidCatch
// Vue: app.config.errorHandler
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
为什么 capture=true 是关键?事件传播分两阶段:
window → document → ... → target ← capture(捕获)阶段
target → ... → document → window ← bubble(冒泡)阶段
2
资源错误不进入冒泡阶段,只在 capture 路径上传播。addEventListener('error', cb, true) 第三个参数指定监听 capture 阶段,这是唯一能在 window 抓到资源错误的方式。漏写 true 等于漏掉资源错误(典型如 CDN 挂掉的诊断)。
# 12.4 堆栈与符号化
开发环境的 stack 很贴心:
Error: something broken
at handleClick (app.js:42:11)
at HTMLButtonElement.<anonymous> (app.js:120:5)
2
3
但生产环境压缩后:
Error: x is not a function
at i (main.abc123.js:1:54221)
2
每个函数都被压成单字母变量,所有代码挤到一行。需要 sourcemap 还原。
核心约定:
- 构建时生成 sourcemap,与压缩 JS 一一对应。
- 不部署到 CDN——sourcemap 暴露完整源码,等于源代码泄漏。
- 上传到错误监控平台(Sentry/自建),由平台符号化后展示给开发者。
跨域脚本错误的特殊处理:默认情况下,跨域脚本(CDN 的 JS)抛错只会返回 "Script error." 字符串,没有 stack。这是浏览器同源策略——防止其他域的脚本泄漏内部信息。要拿到真实 stack,必须:
- HTML 标签加
crossorigin="anonymous":<script src="https://cdn.x.com/app.js" crossorigin="anonymous">。 - CDN 服务端响应
Access-Control-Allow-Origin: *。
两者都设置后,浏览器才允许暴露完整错误信息。
上报通道的特殊性:页面 unload 时普通 fetch 可能被取消,要用 navigator.sendBeacon:
function reportError(data) {
if (navigator.sendBeacon) {
navigator.sendBeacon('/report', JSON.stringify(data));
} else {
fetch('/report', { method: 'POST', body: JSON.stringify(data), keepalive: true });
}
}
2
3
4
5
6
7
探索性思考:Web 没有"下次启动上报",怎么办? 浏览器关闭后没有"下次启动"概念——所以 Web 错误必须即时或近即时上报。sendBeacon 是浏览器为这个场景设计的:保证页面 unload 时这个请求仍会发出(不阻塞 unload 流程)。这与 Android/iOS"写本地+下次启动"的策略本质不同——Web 是"现在发,发完忘"。
# 13.跨端流程对照
# 13.1 端到端流程对照表
| 阶段 | Android Java | Android Native | iOS OC | iOS Mach | Web |
|---|---|---|---|---|---|
| 触发源 | throw Throwable | CPU 非法指令 | @throw | CPU 非法指令 / trap | throw / Promise reject |
| OS/Runtime 投递 | JVM 栈展开 | 内核→signal | OC runtime | Mach exception port | V8 → Event dispatch |
| 应用监听 API | Thread.setDefaultUncaughtExceptionHandler | sigaction | NSSetUncaughtExceptionHandler | task_set_exception_ports | window.addEventListener('error') |
| 堆栈采集 API | Throwable.stackTrace / Thread.getAllStackTraces | _Unwind_Backtrace / backtrace | [exc callStackSymbols] / backtrace | thread_get_state + fp 链 | Error.stack |
| 符号化产物 | mapping.txt | NDK symbols(unstripped .so) | dSYM | dSYM | sourcemap |
| 符号化工具 | retrace.jar | ndk-stack / addr2line | atos / symbolicatecrash | 同 | source-map |
| handler 上下文限制 | 无(普通 Java 线程) | async-signal-safe only | 较少限制 | 无(独立线程) | 无 |
| 是否能再继续运行 | 否(应让进程死) | 否 | 否 | 否 | 是(应用继续) |
# 13.2 堆栈采集质量对比
| 维度 | Java | Native | OC/Mach | Web |
|---|---|---|---|---|
| 完整性 | ★★★★★(语言级) | ★★★(跨语言难) | ★★★★ | ★★★★(开发期)/ ★★(跨域) |
| 符号化复杂度 | 低 | 高 | 中 | 中 |
| 抓多线程能力 | ★★★★ getAllStackTraces | ★★(需 ptrace 类技巧) | ★★★★ Mach | ★(单线程模型) |
| 现场完整度 | 高(cause 链/locals 难) | 中(寄存器+栈) | 高 | 高(含 DOM/URL) |
# 13.3 统一启示
- 崩溃捕获本质是"在 runtime/OS 的强制终止前抢一步":每个平台都有固定的"最后机会"hook 点。
- 堆栈采集的难度递增:Java < Web < OC < Native(跨语言/跨进程难度最大)。
- 符号化是工程基建:mapping/dSYM/sourcemap 三件套必须 CI 自动归档。
- handler 上下文越严格的环境(Native signal),代码越要简单:参考 async-signal-safe 函数列表。
- "看不见的崩溃"路径:Android LMK/ANR 系统历史 + iOS Watchdog/Jetsam MetricKit 是补全 §02 案例 中"60% 漏报"的唯一答案。
# 14.归因方法
# 14.1 崩溃归因决策树
崩溃事件
│
├── 类型 A 异常型 ──▶ 看 stack 顶层
│ ├─ NPE → 找 nullable 来源
│ ├─ ClassCastException → 类型不一致
│ ├─ IndexOutOfBounds → 边界检查
│ └─ OOM → 走内存治理(卷二·03)
│
├── 类型 B 信号型 ──▶ 看 signal 类型
│ ├─ SIGSEGV → 空指针 / 野指针
│ ├─ SIGABRT → assert 失败 / 主动 abort
│ ├─ SIGBUS → 内存对齐 / mmap 失败
│ └─ SIGFPE → 除零 / 浮点异常
│
└── 类型 C 系统强杀 ──▶ 看 ProcessExitReason / MetricKit
├─ LMK → 内存压力(详见卷二·03)
├─ ANR → ANR 治理(详见卷三·04)
└─ Force Stop → 用户主动
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 14.2 符号化与还原
为什么需要符号化:线上 release 包的 stack 是地址 / 混淆后的名字,必须用 mapping 文件还原才能定位代码。
原始: com.app.a.b.c() at 0x12345
符号化后:UserListActivity.onCreate() at MainActivity.kt:42
2
符号化方案:
| 平台 | Mapping 文件 | 工具 |
|---|---|---|
| Android Java | mapping.txt(ProGuard / R8) | retrace / Bugly 自动 |
| Android Native | symbols / .so 含调试信息 | ndk-stack / addr2line |
| iOS | dSYM | symbolicatecrash / atos / Instruments |
| Web | sourcemap | Source Map Tools / Sentry 自动 |
关键约定:
- 每次发版必须保留 mapping / dSYM / sourcemap(按版本号归档)。
- 上报系统按版本自动符号化。
# 14.3 现场快照分析
崩溃时需要的"四件套":
- 完整 stack:所有线程,不只主线程。
- 设备信息:型号 / 系统 / RAM / CPU / 屏幕。
- 应用上下文:版本 / 当前页面 / 最近 N 条用户操作。
- 资源快照:内存 / CPU / 网络状态。
现场字段示例:
{
"type": "NullPointerException",
"thread": "main",
"stack": [...],
"all_threads": {...},
"device": {"model": "Pixel 6", "os": "Android 13", "ram": 8192, "free": 2300},
"app": {"version": "3.5.1", "build": 12345},
"context": {
"current_activity": "UserListActivity",
"last_actions": ["click_user", "scroll_list", ...]
},
"memory": {"java_heap": 64, "native": 32, "total_pss": 200}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 14.4 长尾崩溃归因
少数用户的崩溃往往是:
- 极端机型(如 SamsungA01 这类低端机)
- 极端系统版本(Android 5 / iOS 12 等老版本)
- 极端用户行为(罕见操作组合)
- 设备状态异常(root / 越狱 / 第三方 ROM)
长尾治理思路:
- 设定支持范围(minSdk / 最低 RAM / 不支持 root)。
- 范围外提示用户升级 / 友好降级。
- 不要追求"100% 用户都能用"。
# 15.求证实验 ⭐
# 15.1 实验一:捕获完整性
Step 1 — 原始观察:工程师都接 Crashlytics 等,但它们能捕获多少崩溃?还有什么漏的?
Step 2 — 提出疑问:常见崩溃监控库(仅 hook 异常)相比"全方案"(异常 + 信号 + 历史)覆盖率差异多少?
Step 3 — 设计实验:构造各类崩溃测试用例,测量各方案捕获率:
| 用例 | 仅异常 hook | 异常 + 信号 hook | 全方案 |
|---|---|---|---|
| Java NPE | ✅ | ✅ | ✅ |
| Java OOM | ✅ | ✅ | ✅ |
| Native SIGSEGV | ❌ | ✅ | ✅ |
| Native SIGABRT | ❌ | ✅ | ✅ |
| ANR | ❌ | ❌ | ✅(系统历史) |
| LMK 杀进程 | ❌ | ❌ | ✅ |
| 用户 Force Stop | ❌ | ❌ | ✅ |
Step 4 — 实测数据(某 App 实际数据):
| 方案 | 捕获率 |
|---|---|
| 仅异常 hook | 65% |
| 异常 + 信号 hook | 88% |
| 全方案(+ 系统历史) | 99% |
Step 5 — 提炼结论:
仅异常 hook 漏掉 35% 崩溃;必须三方案组合才能达到 99% 捕获率。
Step 6 — 边界:
- 系统历史 API 需要 Android 11+。
- iOS MetricKit 数据延迟 24 小时。
- Web 没有"系统历史"概念。
# 15.2 实验二:上报成功率
Step 1 — 原始观察:崩溃发生时网络往往不可用(启动期 / 弱网),多少崩溃能成功上报?
Step 2 — 提出疑问:不同上报策略下,崩溃数据到达服务端的比例是多少?
Step 3 — 设计实验:
| 策略 | 描述 |
|---|---|
| A 同步上报 | 崩溃时立即网络上报 |
| B 仅本地 + 下次启动上报 | 本地写文件,下次启动检查 |
| C 本地 + 启动上报 + 后台上报 | B + 应用进入后台时也尝试 |
Step 4 — 实测数据:
| 策略 | 上报成功率 |
|---|---|
| A 同步上报 | 60-70%(崩溃时网络不可靠) |
| B 启动上报 | 88-92% |
| C B + 后台上报 | 95-98% |
Step 5 — 提炼结论:
崩溃时同步上报成功率仅 60-70%。"本地优先 + 多次重试"能让到达率达到 95%+。
Step 6 — 边界:
- iOS 启动 watchdog 直接杀,本地写文件可能也来不及。
- Web 用户关闭 Tab 即丢,依赖 sendBeacon / unloadEvent。
# 15.3 实验三:现场抓取价值
Step 1 — 原始观察:只抓 stack 还是抓完整现场?完整现场对修复效率提升多少?
Step 2 — 提出疑问:完整现场(stack + device + 用户操作链 + 内存快照)相比仅 stack,定位效率提升多少?
Step 3 — 设计实验:
- A 仅 stack:可还原"哪行代码崩"。
- B 完整现场:A + device + 用户行为 + 资源状态。
Step 4 — 实测数据(随机选 100 个崩溃):
| 数据完整度 | 平均定位耗时 | 一次修复成功率 |
|---|---|---|
| A 仅 stack | 3.5 小时 | 55% |
| B 完整现场 | 0.8 小时 | 85% |
Step 5 — 提炼结论:
完整现场让定位耗时 -75%,一次修复成功率 +30%。是少有的"采集成本极低,效果极高"的工作。
Step 6 — 边界:
- 现场数据量增加 5-10×(仍可控)。
- 隐私敏感数据要脱敏。
# 15.4 实验四:静默崩溃补全
Step 1 — 原始观察:§02 案例 中应用 SDK 捕获不到 LMK 杀、jetsam 杀、启动 hang。系统级 API 能补全多少?
Step 2 — 提出疑问:Android getHistoricalProcessExitReasons + iOS MetricKit 能在多大程度上补全"静默崩溃"?
Step 3 — 设计实验:某 App 真实数据 1 个月:
| 崩溃源 | Crashlytics 捕获 | 加系统 API 后捕获 | 漏报率减少 |
|---|---|---|---|
| Android Java 异常 | 100% | 100% | 0 |
| Android Native | 0%(未接 NDK) | 接 NDK 后 98% | -98pp |
| Android LMK 杀 | 0% | getHistoricalProcessExitReasons 85% | -85pp |
| Android 启动早期崩溃 | 30%(SDK 未 init) | 自实现 SIGQUIT 早期 90% | -60pp |
| iOS OC/Mach | 95% | KSCrash 99% | -4pp |
| iOS 启动 hang | 0% | MetricKit 92% | -92pp |
| iOS OOM/jetsam | 0% | MetricKit 88% | -88pp |
| iOS 强杀(Watchdog) | 0% | MetricKit 90% | -90pp |
Step 4 — 结论:
系统级 API 能补全 60-98% 的"静默崩溃"。是从"看见 40%"到"看见 99%"的关键一跃。
Step 5 — 边界:
- 系统 API 仅在新版本可用(A11+ / iOS13+),老版本需其他手段。
- MetricKit 延迟 24h,不适合实时告警。
# 15.5 实验五:自动化符号化
Step 1 — 原始观察:崩溃栈是地址(如 0x4f8a23),需符号化才能读懂。手动 vs 自动符号化对治理速度影响多大?
Step 2 — 提出疑问:CI 自动化符号化 vs 出问题再手动符号化,从"发现崩溃"到"开始修复"的时长差距?
Step 3 — 设计实验:某团队对比两种模式:
| 模式 | 平均处理时长 |
|---|---|
| 手动符号化(出问题查 mapping/dSYM) | 4-12 小时(需找包→找 mapping→运行工具) |
| CI 自动符号化(每次发版自动上传) | < 5 分钟(看板直接展示可读栈) |
| 从崩溃发现到开发修复:手动 | 24-48 小时 |
| 从崩溃发现到开发修复:自动 | 2-4 小时 |
Step 4 — 结论:
自动符号化是崩溃治理的"基础设施"。手动模式让修复时长 6-10×。
Step 5 — 边界:
- 历史版本未上传符号则无法补救,需提前规划。
- 大型 SDK 的符号文件可能 100MB+,需 CI 优化。
# 15.6 五大实验启示
捕获完整性 → 三方案组合覆盖 99% ─┐
│
上报成功率 → 本地优先 + 启动重试 95%+ │
│
现场抓取 → 完整现场让定位 -75% ├─▶ 崩溃 = 多手段 + 高可靠 + 全现场 + 系统补全 + 自动符号化
│
系统 API 补全静默 → 补全 60-98% 静默崩溃 │
│
自动符号化 → 修复时长 6-10× ─┘
2
3
4
5
6
7
8
9
统一启示:
- "看不到的崩溃"是最大风险,必须三方案组合 + 系统补全。
- 崩溃时一切都不可信,本地优先 + 下次上报。
- 现场数据决定修复速度,性价比极高。
- 系统 API 是静默崩溃的解药:A11+/iOS13+ 必接。
- 自动符号化是基础设施:手动是反模式。
# 16.优化策略深化
本节回答四个递进问题:①如何让所有崩溃都被捕获?②如何让现场质量足以定位?③如何让上报数据到家?④如何让 Top 5 持续下降?
# 16.1 第一层捕获覆盖
核心命题:§02 案例 证明单一 SDK 漏 60% 崩溃。本层目标:三方案组合 + 系统 API 补全,覆盖率 99%。
策略 1.1:Android 三件套(Java + Native + 系统历史)
- 机理:§15.1 + §15.4 双重证据。
- 要点:Java 异常用
Thread.setDefaultUncaughtExceptionHandler+ Native 用 NDK 信号 hook(xCrash/Breakpad/Bugly NDK)+ Android 11+ 用getHistoricalProcessExitReasons补 ANR/LMK 数据。 - 收益:§02 案例 可见崩溃率 0.04% → 0.48%(暴露真相)。
- 边界:A11+ API;老版本只能依赖 SDK。
策略 1.2:iOS 三件套(NSException + Mach + MetricKit)
- 机理:§09 + §10 + §11 全章节理论支撑。
- 要点:
NSSetUncaughtExceptionHandler抓 OC 异常 + KSCrash 走 Mach Exception 抓 Swift trap/SIGABRT/SIGSEGV + iOS 13+ 接MXMetricManager补 Watchdog/OOM。 - 收益:iOS OOM/启动 hang 可见率 0% → 90%+。
- 边界:MetricKit 延迟 24h,不适合实时告警。
策略 1.3:Web 三件套(onerror + unhandledrejection + resource error)
- 机理:见 §12.3 三个 listener 的设计。
- 要点:
addEventListener('error', cb, true)(capture=true,同时覆盖 JS 错误和资源错误)+unhandledrejection监听 + 框架层 ErrorBoundary。 - 收益:Promise rejection / 资源加载失败可见。
- 边界:跨域脚本需
crossorigin+ CORS。
策略 1.4:早期注册(启动前的崩溃也要捕获)
- 机理:§02 案例 启动早期崩溃漏 70%。
- 要点:在
Application.attachBaseContext或main()第一行注册。 - 收益:启动早期崩溃可见率 30% → 90%。
- 边界:早期注册的 handler 必须极简(避免自身崩溃)。
# 16.2 第二层现场质量
核心命题:§15.3 实验 完整现场让定位 -75%。
策略 2.1:完整字段采集
- 机理:stack + device + 用户操作链 + 内存 + 网络。
- 要点:定义统一的
CrashReport数据结构,至少包含:栈、异常类型、设备型号、OS 版本、应用版本、空闲内存、网络类型、最近 30 条用户操作、是否前台、崩溃时间。 - 收益:定位时长 3.5h → 0.8h。
- 边界:上报体积 ×5-10(仍可控);隐私数据需脱敏。
策略 2.2:用户操作链记录
- 机理:知道用户做了什么才能复现。
- 要点:用循环 buffer(如 30 容量)记录用户行为——Activity 生命周期、点击、网络请求、关键状态变化都打点。崩溃时取 snapshot。
- 收益:复现率从 30% 提升到 70%。
- 边界:循环 buffer 防止内存膨胀;敏感操作脱敏。
策略 2.3:崩溃前 N 帧的方法 trace
- 机理:method trace 让"崩溃前几秒在做什么"可见。
- 要点:ASM 编译期插桩记录最近 50 个方法调用(同 §6.5 的 mapping 工具链)。
- 收益:定位"几乎不可能"的崩溃。
- 边界:每方法增 1-2μs;线上仅采样。
策略 2.4:内存/线程/资源快照
- 机理:OOM 类崩溃需要内存快照才能归因。
- 要点:崩溃时记录 Java/Native heap、线程数、FD 数、bitmap 总数。
- 收益:OOM 类崩溃归因效率 +80%。
- 边界:快照采集本身可能失败(崩溃后状态不可信)。
# 16.3 第三层上报可靠
核心命题:§15.2 实验 证明同步上报不可信,本地优先 + 启动重试是唯一可靠方案。
策略 3.1:本地写入优先(崩溃时不发网络)
- 机理:崩溃时一切都不可信(内存损坏、网络可能挂)。
- 要点:handler 内只做最小操作——把 report 序列化为 JSON 写到
filesDir/crash_<uuid>.json,然后立刻交还原 handler 让进程死。绝不在 handler 里调网络。 - 收益:上报成功率 60% → 95%+。
- 边界:本地写入需保证原子性;磁盘满时仍可能失败。
策略 3.2:下次启动时检查并上报
- 机理:应用重启后网络环境恢复,可靠上传。
- 要点:Application.onCreate 后扫描 crash 目录,发送成功则删除文件。失败保留待下次。
- 收益:跨启动可靠上报。
- 边界:用户不再打开应用则数据永远丢;可配合 WorkManager 在后台尝试。
策略 3.3:失败重试 + 指数退避
- 机理:同
卷四·02 §6.3 策略 3.2。 - 要点:1s/2s/4s/8s/16s 退避,超 24h 丢弃。
- 收益:弱网/服务端波动期数据不丢。
- 边界:必须有兜底丢弃,否则无限堆积。
策略 3.4:多通道(HTTP + sendBeacon + WorkManager)
- 机理:单通道失败时有备用。
- 要点:Web 优先 sendBeacon(页面 unload 时仍能发);Android 失败可入 WorkManager 排队重试。
- 收益:上报成功率再提 3-5%。
- 边界:sendBeacon 仅支持小体积;大体积仍需 HTTP。
# 16.4 第四层治理闭环
核心命题:捕获了数据不治理 = 资源浪费。本层目标:让 Top 5 崩溃每周下降。
策略 4.1:错误聚合 + Top 5 排序
- 机理:见
卷一·02 §5.5错误聚合粒度。 - 要点:fingerprint = hash(stack 前 5 帧 + error type)。
- 收益:80/20 法则,治 Top 5 解决 80% 崩溃。
- 边界:聚合粒度需调优。
策略 4.2:自动符号化(基础设施)
- 机理:§15.5 实验 修复时长 6-10×。
- 要点:CI 每次发版上传 mapping/dSYM/sourcemap 到崩溃平台。
- 收益:可读栈数据,治理速度大幅提升。
- 边界:未上传符号则无法补救,必须发版前阻断。
策略 4.3:每周 Top 5 治理 + 数据看板
- 机理:固化为研发流程。
- 要点:每周一自动生成 Top 10 崩溃报告,分配 owner,1 周内必修复或评估。
- 收益:Top 5 占比从 80% 持续下降到 50%(健康分布)。
- 边界:owner 必须有时间投入;管理层支持。
策略 4.4:版本对比 + 自动告警
- 机理:每次发版自动对比 Top 5 是否新增。
- 要点:对比新旧版本 Top 5,新增 fingerprint 自动告警分发到负责人。
- 收益:退化 24h 内被发现。
- 边界:误报需调阈值。
# 16.5 优先级判定(ROI)
| ROI | 优化项 | 收益 | 成本 | 风险 | 对应策略 |
|---|---|---|---|---|---|
| 极高 | 异常 + 信号 + 系统历史三方案 | 捕获率 60% → 99% | 1-2 周 | 低 | 1.1-1.3 |
| 极高 | 自动符号化 CI 集成 | 修复时长 6-10× | 1 周(基建) | 低 | 4.2 |
| 极高 | 完整现场抓取 | 定位 -75% | 1 周 | 低 | 2.1-2.2 |
| 极高 | 本地优先 + 启动重试 | 上报率 60% → 95%+ | 几天 | 低 | 3.1-3.2 |
| 高 | 早期注册(Application 第一行) | 启动期崩溃可见 30% → 90% | 几天 | 低 | 1.4 |
| 高 | MetricKit / getHistoricalProcessExitReasons | 静默崩溃可见 0 → 90% | 1 周 | 低 | 1.1+1.2 |
| 高 | Top 5 每周治理流程 | Top 占比持续下降 | 持续投入 | 中 | 4.3 |
| 中 | 用户操作链 | 复现率 30% → 70% | 1-2 周 | 中(隐私) | 2.2 |
| 中 | 方法 trace 插桩 | 复杂问题可见 | 2-3 周 | 中(性能) | 2.3 |
| 中 | 内存/线程快照 | OOM 类归因 +80% | 1-2 周 | 中 | 2.4 |
| 中 | 多通道上报(含 Beacon) | 上报率再 +3-5% | 几天 | 低 | 3.4 |
| 中 | 版本对比 + 自动告警 | 退化早发现 | 1-2 周 | 低 | 4.4 |
| 低 | 自实现 Crash SDK | 几乎无收益 | 极高 | 极高 | - |
避免反向收益:
- 现场字段过多:上报体积爆炸(>100KB)。
- 同步网络上报:成功率反而低。
- 未上传符号就发版:上线后崩溃无法定位。
- 只接 Java/OC 异常:§02 案例 漏 60% 的根因。
- 自实现 SDK:成熟方案已经覆盖 99% 场景。
# 17.实战案例
# 17.1 跨端同构案例
背景:某应用 Android / iOS / Web 的崩溃监控都用了主流 SDK,但仍有 15-20% 崩溃"看不到"(用户报但日志无)。
度量与归因:通过日活下降发现"隐性崩溃"问题。深度调查发现:
- Android:Native 信号 hook 未启用,Native crash 全漏。
- iOS:启动 hang(watchdog 杀)未监控。
- Web:unhandledrejection 未监听。
假设与求证:提出统一假设——"补齐每个平台的盲区"。
修复:
- Android:启用 Bugly NDK + ProcessExitReason。
- iOS:接入 MetricKit + KSCrash。
- Web:补 unhandledrejection + sendBeacon 上报。
验证:
| 平台 | 优化前可见崩溃率 | 优化后可见崩溃率 | 实际崩溃率(不变) |
|---|---|---|---|
| Android | 0.08% | 0.15% | 0.15% |
| iOS | 0.04% | 0.09% | 0.09% |
| Web | 0.05% | 0.07% | 0.07% |
可见崩溃率上涨意味着捕获更全面(不是问题恶化)。
统一启示:跨端崩溃监控通用法则 = 三方案组合覆盖盲区。
# 17.2 平台特异案例
背景:iOS 应用启动期偶发崩溃,但 Crashlytics 完全收不到。
现象:用户报告"打开就闪退",但崩溃数据为零。
度量与归因:
- 启动 watchdog 5 秒内强杀 → SIGKILL → 应用代码完全不响应。
- Crashlytics 也来不及上报。
假设与求证:假设:用 MetricKit 在下次启动时拿启动 hang 数据。
实验:接入 MetricKit MXLaunchHangDiagnostic,下次启动后 24 小时内收到详细 hang stack。
修复:
- 接入 MetricKit。
- 监控 hang stack,识别启动期同步重任务。
- 用分级初始化(详见
卷四·01)异步化。
验证:启动 hang 率从 0.08% 降到 0.02%。
边界:MetricKit 数据延迟 24 小时,不能实时响应,只能事后分析。
# 18.防劣化与长效治理
# 18.1 三道防线总览
开发期 ──▶ 编译期 / CI ──▶ 上线期 / 运行期
│ │ │
▼ ▼ ▼
[Lint+测试] [自动化] [监控+治理]
2
3
4
# 18.2 编码期 Lint
- 关键路径未做空值检查 → 错误。
- 使用过时危险 API → 警告。
- 全局未注册崩溃 handler → 错误。
- 未启用混淆但未配置 mapping 上传 → 警告。
# 18.3 CI 与 SLO
CI 卡口:
- 每次发版必须有 mapping / dSYM 归档。
- 自动化测试覆盖核心流程。
- 灰度阶段崩溃率 ≤ 上一版本。
线上 SLO:
- 总崩溃率 < 0.1%。
- Top 5 崩溃总占比 ≤ 50%(避免单点垄断)。
- 上报到达率 > 95%。
- 符号化成功率 > 90%。
# 19.跨平台对照速查
# 19.1 工具速查
| 平台 | 异常 hook | 信号 hook | 系统历史 | 符号化 |
|---|---|---|---|---|
| Android | Bugly / Crashlytics | Bugly NDK / Breakpad | getHistoricalProcessExitReasons | mapping + ndk-stack |
| iOS | Crashlytics / KSCrash | KSCrash | MetricKit | dSYM + atos |
| Web | Sentry | N/A | N/A | sourcemap |
| C/C++ | terminate_handler | sigaction | dmesg | DWARF + addr2line |
# 19.2 关键 API 速查
| 操作 | Android | iOS | Web |
|---|---|---|---|
| 注册全局异常 | Thread.setDefaultUncaughtExceptionHandler | NSSetUncaughtExceptionHandler | window.onerror+unhandledrejection |
| 注册信号 | NDK sigaction | C sigaction | N/A |
| 历史退出 | ActivityManager.getHistoricalProcessExitReasons | MetricKit | N/A |
| 上报兜底 | 本地文件 + 下次启动 | 同 | navigator.sendBeacon |
# 20.总结与延伸
# 20.1 五条核心原则
- 三方案组合 + 系统 API 补全:异常 + 信号 + 历史,从 60% 提到 99%(§15.1 + §15.4)。
- 现场必须完整:§15.3 让定位 -75%。
- 本地优先 + 启动重试:上报率 60% → 95%+。
- 自动符号化:§15.5 修复时长 6-10×。
- Top 5 持续治理:80/20 法则不是口号是制度。
# 20.2 五个常见误区
- "接了 Crashlytics 就够了":错(§02 案例 漏 60% 崩溃)。
- "崩溃时同步上报":错(成功率低)。
- "只采 stack 够用":错(定位效率差 4×)。
- "忽略系统 API":错(§15.4 MetricKit/getHistoricalProcessExitReasons 补全 60-98% 静默崩溃)。
- "出问题再补符号":错(§15.5 修复时长 6-10×;必须 CI 自动化)。
# 20.3 延伸阅读
- Effective Crash Handling(Apple 文档)
- Google: Stability and crashes(Android Vitals)
- KSCrash 文档(iOS 高级捕获)
- google-breakpad / crashpad 文档(C/C++)
- Sentry / Bugsnag SDK 源码
# 21.一句话总结
崩溃捕获是性能体系的最后一道防线;"看不到的崩溃"比可见崩溃更危险。 三方案组合 + 系统 API 补全让覆盖率从 60% 跃升到 99%,自动符号化让修复时长 6-10×。 完整现场让定位 -75%,本地优先让上报率 95%+,Top 5 每周治理让 Top 占比持续下降。 §02 那个"4 周经验派看 0.04% vs 7 天方法派看 0.48%"的反差,正是这条路径的最锋利证据。