第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超时检测 |
你看这张图,基本把启动崩溃的四大类型和它们对应的捕获方法串起来了。实际项目中,经常是多种问题并存。比如一个野指针导致信号,而信号处理函数里又因为用了不可重入函数引发二次崩溃——我称之为“崩溃链”。
我的建议:别指望一个捕获方案解决所有问题。我给团队定的策略是:信号用C层 sigaction,异常用OC层Handler,内存做个启动阶段的 dispatch_source 定时监控,主线程阻塞单独开线程检测。四管齐下,启动崩溃率基本能压到0.1%以下。
信号、异常、内存、主线程阻塞——这四类就好比四个木桶板,哪一块短了都会漏水。下一步你要做的,就是根据你的App特性,给每块板加个“捕获探测器”。