编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

    • README
    • 公共方法论

    • 体系建设篇

    • 资源专项篇

    • 流水线专项

    • 业务专项篇

    • 交付防御篇

      • 崩溃捕获设计实践
        • 01.阅读说明
        • 02.贯穿案例
          • 2.1 案例背景
          • 2.2 经验派 4 周折腾
          • 2.3 方法派 7 天闭环
          • 2.4 上线效果
          • 2.5 案例串联全文
        • 03.问题域定义
          • 3.1 现象与代价
          • 3.2 度量准则
          • 3.3 行业基准与目标
          • 3.4 反直觉问题清单
        • 04.第一性原理
          • 4.1 崩溃本质定义
          • 4.2 三类崩溃模型
          • 4.3 跨平台同构原理
          • 4.4 平台差异点矩阵
        • 05.度量与采集
          • 5.1 三类捕获方案
          • 5.2 各方案的可见盲区
          • 5.3 跨平台采集对照表
          • 5.4 数据可信度评估
        • 06.Java 崩溃全链路
          • 6.1 崩溃如何产生
          • 6.2 JVM 投递流程
          • 6.3 监听点设计
          • 6.4 堆栈采集
          • 6.5 符号化原理
        • 07.Native 崩溃全链路
          • 7.1 崩溃如何产生
          • 7.2 信号投递流程
          • 7.3 监听点设计
          • 7.4 堆栈采集
          • 7.5 符号化原理
        • 08.ANR 全链路
          • 8.1 ANR 如何产生
          • 8.2 系统投递流程
          • 8.3 三种监听方式
          • 8.4 抓全线程栈
          • 8.5 归因三步法
        • 09.iOS 异常全链路
          • 9.1 OC 与 Swift 差异
          • 9.2 OC 投递流程
          • 9.3 监听点设计
          • 9.4 堆栈与符号化
        • 10.Mach 异常全链路
          • 10.1 双层异常机制
          • 10.2 Mach 投递流程
          • 10.3 监听点设计
          • 10.4 堆栈采集
        • 11.Watchdog 与 OOM
          • 11.1 SIGKILL 不可捕获
          • 11.2 系统强杀机制
          • 11.3 MetricKit 兜底
          • 11.4 异常退出排除法
        • 12.Web 错误全链路
          • 12.1 Web 四类故障
          • 12.2 V8 投递流程
          • 12.3 三个监听点
          • 12.4 堆栈与符号化
        • 13.跨端流程对照
          • 13.1 端到端流程对照表
          • 13.2 堆栈采集质量对比
          • 13.3 统一启示
        • 14.归因方法
          • 14.1 崩溃归因决策树
          • 14.2 符号化与还原
          • 14.3 现场快照分析
          • 14.4 长尾崩溃归因
        • 15.求证实验 ⭐
          • 15.1 实验一:捕获完整性
          • 15.2 实验二:上报成功率
          • 15.3 实验三:现场抓取价值
          • 15.4 实验四:静默崩溃补全
          • 15.5 实验五:自动化符号化
          • 15.6 五大实验启示
        • 16.优化策略深化
          • 16.1 第一层捕获覆盖
          • 16.2 第二层现场质量
          • 16.3 第三层上报可靠
          • 16.4 第四层治理闭环
          • 16.5 优先级判定(ROI)
        • 17.实战案例
          • 17.1 跨端同构案例
          • 17.2 平台特异案例
        • 18.防劣化与长效治理
          • 18.1 三道防线总览
          • 18.2 编码期 Lint
          • 18.3 CI 与 SLO
        • 19.跨平台对照速查
          • 19.1 工具速查
          • 19.2 关键 API 速查
        • 20.总结与延伸
          • 20.1 五条核心原则
          • 20.2 五个常见误区
          • 20.3 延伸阅读
        • 21.一句话总结
      • 包体积与资源治理
      • 应用安全性能权衡
      • 弱网极端环境治理
  • 程序编程原理

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 性能优化实践
  • 交付防御篇
杨充
2026-05-27
目录

崩溃捕获设计实践

# 崩溃捕获设计实践

📊 学习成本预估 | 难度:⭐⭐⭐⭐⭐(5/5)| 阅读:约 50 分钟 | 实操:3 小时 🔗 前置阅读:卷一·02, 卷二·03 | ➡️ 后续延伸:—

# 目录介绍

  • 01.阅读说明
  • 02.贯穿案例
    • 2.1 案例背景
    • 2.2 经验派 4 周折腾
    • 2.3 方法派 7 天闭环
    • 2.4 上线效果
    • 2.5 案例串联全文
  • 03.问题域定义
    • 3.1 现象与代价
    • 3.2 度量准则
    • 3.3 行业基准与目标
    • 3.4 反直觉问题清单
  • 04.第一性原理
    • 4.1 崩溃本质定义
    • 4.2 三类崩溃模型
    • 4.3 跨平台同构原理
    • 4.4 平台差异点矩阵
  • 05.度量与采集
    • 5.1 三类捕获方案
    • 5.2 各方案的可见盲区
    • 5.3 跨平台采集对照表
    • 5.4 数据可信度评估
  • 06.Java 崩溃全链路 ⭐
    • 6.1 崩溃如何产生
    • 6.2 JVM 投递流程
    • 6.3 监听点设计
    • 6.4 堆栈采集
    • 6.5 符号化原理
  • 07.Native 崩溃全链路 ⭐
    • 7.1 崩溃如何产生
    • 7.2 信号投递流程
    • 7.3 监听点设计
    • 7.4 堆栈采集
    • 7.5 符号化原理
  • 08.ANR 全链路 ⭐
    • 8.1 ANR 如何产生
    • 8.2 系统投递流程
    • 8.3 三种监听方式
    • 8.4 抓全线程栈
    • 8.5 归因三步法
  • 09.iOS 异常全链路 ⭐
    • 9.1 OC 与 Swift 差异
    • 9.2 OC 投递流程
    • 9.3 监听点设计
    • 9.4 堆栈与符号化
  • 10.Mach 异常全链路 ⭐
    • 10.1 双层异常机制
    • 10.2 Mach 投递流程
    • 10.3 监听点设计
    • 10.4 堆栈采集
  • 11.Watchdog 与 OOM ⭐
    • 11.1 SIGKILL 不可捕获
    • 11.2 系统强杀机制
    • 11.3 MetricKit 兜底
    • 11.4 异常退出排除法
  • 12.Web 错误全链路 ⭐
    • 12.1 Web 四类故障
    • 12.2 V8 投递流程
    • 12.3 三个监听点
    • 12.4 堆栈与符号化
  • 13.跨端流程对照
    • 13.1 端到端流程对照表
    • 13.2 堆栈采集质量对比
    • 13.3 统一启示
  • 14.归因方法
    • 14.1 崩溃归因决策树
    • 14.2 符号化与还原
    • 14.3 现场快照分析
    • 14.4 长尾崩溃归因
  • 15.求证实验 ⭐
    • 15.1 实验一:捕获完整性
    • 15.2 实验二:上报成功率
    • 15.3 实验三:现场抓取价值
    • 15.4 实验四:静默崩溃补全
    • 15.5 实验五:自动化符号化
    • 15.6 五大实验启示
  • 16.优化策略深化
    • 16.1 第一层捕获覆盖
    • 16.2 第二层现场质量
    • 16.3 第三层上报可靠
    • 16.4 第四层治理闭环
    • 16.5 优先级判定(ROI)
  • 17.实战案例
    • 17.1 跨端同构案例
    • 17.2 平台特异案例
  • 18.防劣化与长效治理
    • 18.1 三道防线总览
    • 18.2 编码期 Lint
    • 18.3 CI 与 SLO
  • 19.跨平台对照速查
    • 19.1 工具速查
    • 19.2 关键 API 速查
  • 20.总结与延伸
    • 20.1 五条核心原则
    • 20.2 五个常见误区
    • 20.3 延伸阅读
  • 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 反直觉问题清单

带着这些问题阅读:

  1. Java 崩溃和 Native 崩溃捕获方式一样吗?
  2. iOS 崩溃栈为什么是地址而不是函数名?
  3. 崩溃时再上报数据可靠吗?
  4. 静默崩溃如何发现?
  5. SIGABRT 和 SIGKILL 应用都能捕获吗?
  6. ANR 和崩溃是同一类问题吗?
  7. 用户主动杀死也算"崩溃"吗?
  8. 崩溃时还能写文件 / 发网络吗?

# 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)      │
   │    无法应用层捕获                                │
   │    捕获:启动时检查"上次未正常退出"               │
   └────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

关键认知:A 类容易捕获(运行时提供 hook);B 类需要 Native 层信号处理;C 类应用代码无法响应(已被 SIGKILL),只能下次启动时推断。

# 4.3 跨平台同构原理

所有平台的崩溃捕获本质都是"在异常发生时拦截 + 记录 + 持久化":

   [应用代码] ──[抛异常]──▶ [运行时/OS]
                                  │
                                  ▼
                           [捕获 hook]
                                  │
                                  ▼
                           [收集现场]
                                  │
                                  ▼
                           [本地持久化]
                                  │
                          下次启动时上报
1
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 层)
   ③ 系统级历史(启动时回查上次)
1
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。

这一定义有三个推论:

  1. 崩溃永远是"线程级"事件:不是"应用崩了",而是"某个线程把异常抛到了栈顶"。但 Android 默认所有线程的未捕获异常都会让进程死——这是 ART 的策略,不是语言规定。
  2. Error 和 Exception 都算:OutOfMemoryError、StackOverflowError 这些"系统级"错误也走同样路径,没有特殊待遇。
  3. 代码里 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 处理 → 弹"应用已停止"对话框
1
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
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11

为什么必须回调原 handler?原 handler 做了三件你不能省的事:

  1. 打印 logcat:开发者还能从 logcat 看到栈,否则线上 release 包"什么都没有"。
  2. 通知 ActivityManager:让 AMS 弹"应用已停止"对话框、清理 Activity 栈。
  3. 真正 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)
1
2

这个还原能成立,依赖一份关键文件:mapping.txt——R8 编译时输出,记录"原名 → 混淆名"的双向映射,包括类、方法、字段、行号。

核心约定(不可商量):

  1. 每次 release 构建归档 mapping.txt(按 versionCode 命名)。
  2. CI 自动上传到崩溃平台(Bugly/Crashlytics/Sentry)。
  3. 不要混淆 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
(硬件层)  (内核层)   (用户态)   (应用层)
1
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 处理
1
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);
}
1
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),还需要做两件事:

  1. dladdr(addr, &info):定位到属于哪个 so(info.dli_fname)和库基址(info.dli_fbase)。
  2. 计算 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
1
2
3

跨语言栈的两大难点:

  1. C → Java(JNI 调用):unwind 到 JNICall 边界后无法继续——Java 层栈在 JVM 的内部数据结构里,不在 native 栈上。需要在 handler 里额外抓 Java 栈(通过 JNIEnv 反射调用 Thread.getStackTrace,但信号上下文不能调 JNI,需要拷出关键数据到独立线程再调)。
  2. 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 后,体积小
1
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 种:

  1. 同步 IO:主线程读文件 / 查 DB / 跨进程查询。
  2. 锁等待:主线程 wait 一个被后台线程持有的锁。
  3. Binder 同步调用:跨进程调用对方进程很慢。
  4. 死循环 / 忙等:业务 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 / 静默记录
1
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())
            }
        }
    }
}
1
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()) }
1
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)
1
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" ...];   // 主动抛
1
2
3
4

Swift fatalError 与"陷阱":编译器在如下场景插入 trap 指令:

let arr: [Int] = []; _ = arr[5]   // 数组越界
let opt: Int? = nil; _ = opt!     // 强解包 nil
fatalError("biz")                 // 主动 fatalError
preconditionFailure(...)          // precondition 失败
1
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 路径
1
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 第一行
1
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
1
2
3
4

但这有两个边界:

  1. 应用方法在开发期是符号化的(dyld 加载时携带符号表),在 App Store release 包是地址(如 MyApp 0x100002a8c)——苹果会 strip 应用的符号表。
  2. 不能在信号 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)
1
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 数组越界编译成此指令)
1
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 崩溃日志,进程被杀
1
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
// ... 其他信号
1
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);  // 抓栈、写本地
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

为什么 Mach 比 BSD Signal 显著更好:

  1. 独立线程执行:Mach handler 不在崩溃线程内跑——崩溃线程的寄存器/栈保持原状,抓栈数据更完整。BSD signal handler 是同步打断崩溃线程,破坏了原始状态。
  2. 不受 async-signal-safe 限制:在独立线程,可以用 malloc、Objective-C 消息发送——handler 能写得更安全更完整。
  3. 早于 BSD signal 触发:第一手数据。
  4. 可以兜底转交系统:处理完后把 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
    }
}
1
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 完全没有响应机会
1
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 报告
1
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 注册
1
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(正常退出)
1
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 监听
1
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
1
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(冒泡)阶段
1
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)
1
2
3

但生产环境压缩后:

Error: x is not a function
    at i (main.abc123.js:1:54221)
1
2

每个函数都被压成单字母变量,所有代码挤到一行。需要 sourcemap 还原。

核心约定:

  1. 构建时生成 sourcemap,与压缩 JS 一一对应。
  2. 不部署到 CDN——sourcemap 暴露完整源码,等于源代码泄漏。
  3. 上传到错误监控平台(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 });
    }
}
1
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 → 用户主动
1
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
1
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 现场快照分析

崩溃时需要的"四件套":

  1. 完整 stack:所有线程,不只主线程。
  2. 设备信息:型号 / 系统 / RAM / CPU / 屏幕。
  3. 应用上下文:版本 / 当前页面 / 最近 N 条用户操作。
  4. 资源快照:内存 / 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}
}
1
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×                ─┘
1
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+测试]   [自动化]        [监控+治理]
1
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 五条核心原则

  1. 三方案组合 + 系统 API 补全:异常 + 信号 + 历史,从 60% 提到 99%(§15.1 + §15.4)。
  2. 现场必须完整:§15.3 让定位 -75%。
  3. 本地优先 + 启动重试:上报率 60% → 95%+。
  4. 自动符号化:§15.5 修复时长 6-10×。
  5. Top 5 持续治理:80/20 法则不是口号是制度。

# 20.2 五个常见误区

  1. "接了 Crashlytics 就够了":错(§02 案例 漏 60% 崩溃)。
  2. "崩溃时同步上报":错(成功率低)。
  3. "只采 stack 够用":错(定位效率差 4×)。
  4. "忽略系统 API":错(§15.4 MetricKit/getHistoricalProcessExitReasons 补全 60-98% 静默崩溃)。
  5. "出问题再补符号":错(§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%"的反差,正是这条路径的最锋利证据。

上次更新: 2026/06/07, 10:26:12
功耗与电量优化
包体积与资源治理

← 功耗与电量优化 包体积与资源治理→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式