第2章 常见启动崩溃类型

启动崩溃这事儿,我入行头两年简直被它折磨得够呛。你想想,用户刚点开App,一秒不到就闪退,连个完整的欢迎页都看不到——体验直接归零。后来我渐渐摸清了门道,发现大多数启动崩溃其实就四类:信号、异常、内存、主线程阻塞。

咱们一个个拆开看。我自己带团队时,写完启动阶段的崩溃捕获方案后,第一件事就是让所有人把这四个“元凶”背下来。嗯,不夸张,这比记什么设计模式实在多了。

一、信号崩溃:系统级的“粗暴警告”

信号崩溃,说白了就是操作系统觉得你的进程不老实,直接一棍子打死。常见的信号有 SIGSEGV(段错误)、SIGABRT(调用abort)、SIGBUS(总线错误)。

典型场景:

  • 野指针解引用 — 我见过最离谱的一次,一个全局变量在新版本里被提前释放了,结果启动时Data Race直接引发 SIGSEGV。
  • 访问已释放的内存 — 用 malloc 分配的缓冲区,某次分支合并后忘记检查边界就写入了。
  • 栈溢出 — 递归太深或者局部变量超大,比如某个SDK初始化时创建了1MB的栈上数组。

捕获方法:

  • 注册 signal(SIGSEGV, handler) 或使用 sigaction。
  • 在回调里写反向符号化 (Symbolicate) 调用栈。
  • iOS/macOS 下建议用 NSSetUncaughtExceptionHandler 兜底(主要处理异常),但信号还是要用C层函数。

我习惯在回调里先写一个简单的 backtrace() 输出到日志文件,同时尝试调用系统的 os_log 或者写沙盒。注意信号处理函数里不要用 printf 或 malloc —— 那可能会把自己搞死。

二、异常崩溃:语言层面的“试图挽救”

异常是由程序逻辑主动抛出的,比如 Objective-C 的 NSException 或 C++ 的 throw。系统在抛出异常时,会尝试通过 @catch 或 try-catch 捕获,但如果没有处理,最终也会转成信号杀掉进程。

常见场景:

  • 数组越界 — NSArray[100] 拿不到东西直接抛异常。我曾经碰到一个奇妙Bug:启动时从网络配置中读Key,结果Key是nil,直接 -[__NSPlaceholderArray initWithObjects:count:]: attempt to insert nil object。
  • KVO不匹配 — 注册了观察者但在 dealloc 前没移除,或者被观察对象已经被释放。
  • 不合法参数 — 比如给 UILabel 的 attributedText 传了一个 nil。

避坑指南:我曾经因为一个 nil 值在启动链路上未被检查,导致线上连续两天崩溃率上升0.3%。后来我在 NSSetUncaughtExceptionHandler 里不仅记录堆栈,还额外存储了异常发生时的关键变量快照(用 NSUserDefaults 临时写),排查快了一倍。

捕获异常在 iOS 上很简单:

NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);

注意,C++ 异常和 OC 异常是两套体系。如果项目里混用了 try-catch,最好各设一个 Handler。

三、内存问题:慢性毒药,急性发作

内存问题在启动阶段有个特点:突然升高而不是平稳增长。比如一次性加载几张大图、初始化一个巨大的 DateFormatter、或者某个第三方库在启动时做了大块内存分配。

分类:

  • OOM (Out of Memory) — 系统无法分配更多内存,直接杀掉进程。启动时最容易触发,因为启动逻辑往往集中加载资源。
  • 内存泄漏 — 循环引用或忘记释放,虽然不会立刻崩溃,但积累下来会让App越来越卡。
  • 野指针/指针越界 — 释放后仍然继续用,结果导致写坏其他对象,间接引发信号或异常。

我习惯在开发阶段用 malloc 钩子或者 Instruments 的 Allocations 工具看启动时的最高内存峰值。有一个 黄金法则:启动完成后(比如 viewDidAppear 回调)的峰值不要超过设备总内存的30%,否则低端机必崩。

注意:OOM 是无法通过代码捕获的。进程已被杀掉,你拿不到任何堆栈。唯一能做的是在后台记录启动时的内存消耗趋势,并用 os_proc_available_memory() (iOS 13+) 粗略判断剩余内存,提前降级。

四、主线程阻塞:用户看不见的“假死”

你想想,如果一个App启动后主线程被某个同步IO或复杂的计算卡住了超过20秒,系统会直接 Watchdog 杀掉。这类崩溃在日志里看不到 signal,只显示 0xdead10cc 或 0xbaaaaaad 之类的神秘码。

典型案发现场:

  • 在主线程加载大文件 — 第一次启动时解压某个100MB+的数据库文件。
  • 同步网络请求 — 我踩过的一个坑:某个SDK的 +initialize 里用了 dispatch_sync(dispatch_get_main_queue(), ^{...}) 导致死锁。
  • 复杂的 UI 布局计算 — 启动时一次性创建两百多个 Cell 并设置约束。

检测与捕获:

  • 在启动时埋一个 pthread_create 的监控线程,每隔1秒检测主线程的 RunLoop 是否还在处理事件。
  • 如果连续N秒没有处理,就记录当前的 backtrace 并保存到本地。
  • 第三方库如 FBMemoryProfiler 或 PLCrashReporter 也能帮忙。

我个人用的方法是:启动后30秒内,如果主线程连续2秒没跑 RunLoop,就记一次警告,累计3次就自动生成一个“假死”日志,并加上时间戳。效果不错,线上排查出好几个第三方初始化的死锁。

四种崩溃类型对比

我整理了一张表,你可以贴到墙上或存在笔记里:

类型 触发原因 典型表现 是否可代码捕获 推荐捕获方式
信号崩溃 SIGSEGV, SIGABRT, SIGBUS 闪退,无异常堆栈 是(C语言层) sigaction + backtrace
异常崩溃 NSException, throw 控制台打印异常信息 是(语言层) NSSetUncaughtExceptionHandler
内存问题 OOM, 野指针, 内存泄漏 OOM无日志;野指针导致信号 部分(OOM不能) 内存监控、malloc钩子
主线程阻塞 同步IO、死锁、复杂计算 App卡死20秒后闪退 是(监控线程) RunLoop超时检测
常见启动崩溃类型 APP 启动崩溃 信号崩溃 SIGSEGV/SIGABRT sigaction + backtrace 异常崩溃 NSException/C++ throw NSSetUncaughtException 内存问题 OOM/野指针/泄漏 内存监控 + malloc钩子 主线程阻塞 Watchdog/死锁 RunLoop超时检测

你看这张图,基本把启动崩溃的四大类型和它们对应的捕获方法串起来了。实际项目中,经常是多种问题并存。比如一个野指针导致信号,而信号处理函数里又因为用了不可重入函数引发二次崩溃——我称之为“崩溃链”。

我的建议:别指望一个捕获方案解决所有问题。我给团队定的策略是:信号用C层 sigaction,异常用OC层Handler,内存做个启动阶段的 dispatch_source 定时监控,主线程阻塞单独开线程检测。四管齐下,启动崩溃率基本能压到0.1%以下。

信号、异常、内存、主线程阻塞——这四类就好比四个木桶板,哪一块短了都会漏水。下一步你要做的,就是根据你的App特性,给每块板加个“捕获探测器”。