凌晨两点,值班群甩过来一张崩溃率曲线,二十分钟前还是 0.02%,现在 1.3%,报告里清一色的EXC_BAD_ACCESS (SIGSEGV),堆栈长这样:
0 MyApp 0x0000000104c3a1b0 0x104bfc000 + 0x3e1b0 1 MyApp 0x0000000104c3a2f0 0x104bfc000 + 0x3e2f0 2 MyApp 0x0000000104c3a4c8 0x104bfc000 + 0x3e4c80x104bfc000 + 0x3e1b0到底是什么?说实话,第一次看到这串数字的时候我完全懵,第一反应是"这日志是不是坏了"。后来才明白,这不是日志坏了,是没符号化——它只是地址和偏移,没有任何人类可读的信息。而 iOS-APP崩溃分析 这件事,八成的时间都卡在这一步:你手上有一份报告,却读不出它在说什么。
这一篇想聊的就是从这串十六进制数字到"第 137 行那个array[index]越界了"的完整链路。内容偏实战,覆盖崩溃日志结构、异常类型判断、dSYM 符号化、寄存器读法、自建采集机制,以及减少崩溃的工程习惯。不管你是刚开始接手线上问题的新人,还是已经埋了一堆埋点却不知道怎么收口的老手,应该都能从里面挑到能直接抄的东西。下面按我实际排查的顺序展开,不按教科书目录走。
1. 崩溃报告里真正值得看的字段其实只有几个
1.1 一份 .ips 文件的信息密度差异极大
iOS 的崩溃报告现在主流是.ips格式,本质是两段拼起来:第一段是 JSON 格式的头信息,第二段是纯文本的线程与二进制镜像列表。第一段看着字段一大堆,Incident Identifier、CrashReporter Key、Hardware Model、Process、Path、Identifier、Version、Code Type、Role、Parent Process、Coalition、Date/Time、Launch Time、OS Version、Report Version……但我自己实际会先扫的只有五六个。
Exception Type决定往哪个方向查,是内存问题还是逻辑问题。Termination Reason决定这是不是被系统干掉的,比如看门狗或者内存超限。Version和OS Version决定影响面,只在新版本出现还是全量都有。Crashed Thread告诉你崩在哪个线程,是主线程还是某条子线程。Binary Images段的 UUID 决定你能不能符号化成功。剩下的像Coalition、Report Version这些,平时基本不用看。
有个很实用的小习惯:把Identifier(Bundle ID)、Version(构建号)、OS Version三个字段拼成一个 key,和Exception Type一起做聚合。这样几千条原始崩溃在几分钟内就能收敛成十几个"问题簇",接下来要逐个分析的对象就只剩这十几个了。不做聚合直接看原始列表,人会疯。
1.2 Exception Type 和 Termination Reason 是两个维度的信息
很多人把这两个字段当一回事,其实它们回答的是两个不同问题。Exception Type说的是"进程因为什么异常而终止",Termination Reason说的是"系统出于什么理由把它杀了"。有 Termination Reason 的报告,往往意味着不是你自己代码里那行出错,而是系统外部施加的终止。
举个典型例子,报告里Exception Type: EXC_CRASH (SIGKILL),Termination Reason: Namespace SPRINGBOARD, Code 0x8badf00d。这个0x8badf00d是开发者圈子里很出名的一个值,读起来像 "ate bad food",它代表看门狗超时——主线程在限定时间内没有完成启动或者没有响应系统事件,系统直接把进程 kill 掉。这种情况下你翻遍所有线程栈也找不到"崩在哪一行",因为压根没有崩,是被杀的。
再看0xdeadfa11,这个一般表示用户手动上滑强杀。0xc00010ff是设备过热降频保护。0xdead10cc比较有意思,它表示进程在被挂起的时候还持有系统需要的锁(比如 SQLite 连接、文件锁),系统等不及了直接终止。这个坑特别容易出现在后台任务里,我在做数据库批量写入的时候踩过一次,表现为"退到后台几秒后崩",本地完全不复现。
还有一类Namespace JETSAM,配合Reason: per-process-limit,那就是内存超限被系统回收。这种情况下报告里通常还有一份独立的 JetsamEvent 记录,里面会写清楚进程占了多少页、系统当时压力如何。如果只盯着崩溃报告看,永远找不到原因,必须去翻 Jetsam 事件。
1.3 Binary Images 段的 UUID 才是能不能符号化的判据
崩溃报告最底部那一大段Binary Images:是很多人会忽略的关键信息。它长这样:
0x104bfc000 - 0x104c8bfff MyApp arm64 <8a3e1c2d9f4b3a7e8c1d2e3f4a5b6c7d> /var/containers/Bundle/Application/.../MyApp.app/MyApp尖括号里那一长串就是这台设备上运行的二进制的 UUID。符号化的本质,是找到同一个 UUID的 dSYM 文件,把地址翻译成函数名和行号。UUID 不匹配,你用再多工具也是白搭,atos会直接告诉你unable to load,symbolicatecrash则会原样输出未符号化的堆栈,看起来像"工具坏了",实际上是拿错了文件。
所以排查线上的第一步永远不是打开堆栈看,而是拿报告里的 UUID 去比对构建产物。这个习惯我建议尽早养成,能省掉大量"为什么符号化不出来"的无效时间。
2. 崩溃分成几大类,判断错方向会白忙一整晚
2.1 Mach 异常、BSD 信号、运行时异常这三层结构
iOS 上的崩溃不是同一套机制产生的,大致可以分成三层,理解这个分层对定位帮助很大。
最底层是Mach 异常,由内核 Mach 层抛出,代表进程触碰了非法内存或者执行了非法指令,常见的有EXC_BAD_ACCESS、EXC_BREAKPOINT、EXC_GUARD、EXC_RESOURCE。Mach 异常继续往下传,会被转换成BSD 信号,这就是你看到的SIGSEGV、SIGBUS、SIGABRT、SIGTRAP。所以报告里经常是EXC_BAD_ACCESS (SIGSEGV)这种成对出现的形式,前者是 Mach 层的分类,括号里是转换后的信号。
最上层是运行时异常,也就是 Objective-C 的NSException和 Swift 的运行时 trap。NSException被捕获后,系统默认会调用abort(),最终体现为EXC_CRASH (SIGABRT)。所以当你看到SIGABRT,大概率是某处抛了未捕获的 NSException,或者是assert、NSAssert失败了,去报告里找Last Exception Backtrace和Application Specific Information两个字段,里面通常写着具体的异常名和 reason。
2.2 EXC_BAD_ACCESS 背后可能是三种完全不同的成因
EXC_BAD_ACCESS是最常见也最容易被误判的一类。它看起来都长一个样,但成因差别巨大。
第一种是访问已释放对象,也就是野指针或者悬垂指针,常见场景是__unsafe_unretained属性、assign修饰的对象属性、以及 C 指针在对象释放后继续使用。第二种是真正的空指针解引用,比如 C 函数里访问了一个 NULL 结构体指针。第三种是栈溢出,递归太深或者局部变量开得太大,报告里会看到访问地址非常接近栈顶。
区分方法之一看崩溃地址。报告里Exception Codes后面会跟一个十六进制地址,如果是一个很小很小的值,比如0x0000000000000010,那基本是空指针附近的偏移访问。如果地址看起来像是一个正常的堆地址,那就是悬垂指针。如果地址落在0x000000016f...这种区间,很可能跟栈相关。
悬垂指针还有一个很有效的验证手段:开启僵尸对象检测。Xcode 里设置环境变量NSZombieEnabled=YES,或者在 Scheme 的 Diagnostics 里勾选 Zombie Objects,原本的野指针崩溃会变成一条非常友好的日志:-[MyClass doSomething]: message sent to deallocated instance 0x...,直接告诉你哪个对象被提前释放了。这招在本地复现偶现崩溃时极其好用。
2.3 那些"没有堆栈"的崩溃其实是另一种问题
有一类报告打开之后会让你怀疑人生,线程栈全是系统库,主线程停在mach_msg2_trap,看不出任何业务代码。这种情况大概率不是代码崩溃,而是被系统终止。前面提到的看门狗超时、内存超限、后台持锁被杀,都属于这一类。
判断方法还是回到Termination Reason。有一个经验值值得记下来:如果报告里存在 Termination Reason,就不要死磕线程栈了,优先去看 JetsamEvent、看主线程耗时、看后台任务的生命周期。我见过团队花了两天在堆栈上找 bug,最后发现是启动阶段同步读了一个大文件,超过启动窗口期被看门狗干掉,改成异步加载之后直接就好了。
另一类"没堆栈"的崩溃是启动期 dyld 错误,报告里会直接写Library not loaded或者Symbol not found,这类反而好办,看缺失的符号属于哪个库就行,通常和依赖管理配置、编译宏、或者架构裁剪有关。
2.4 Swift 的 trap 为什么看起来像信号崩溃
Swift 在遇到强制解包 nil、数组越界、整数溢出、fatalError这些情况时,会触发运行时 trap,最终体现为EXC_BREAKPOINT (SIGTRAP)。它和 Objective-C 的NSException完全不同,不会给你一个清晰的 reason 字符串,堆栈里常常能看到swift_unexpectedError、Swift runtime failure之类的帧。
我个人的经验是,看到SIGTRAP先在堆栈里找Swift runtime failure这一帧,它所在的上一层往往就是出事的业务函数。行号在符号化正确的前提下是准的,顺着往上翻两帧就能定位到具体代码。另外 Xcode 14 之后 Report Navigator 里能直接看 Crash 面板,前提是用户开启了共享分析数据,覆盖率不算高,只能当补充手段。
3. 符号化的完整链路:从 dSYM 到可读堆栈
3.1 dSYM 是怎么产生的,为什么最容易丢
dSYM 是构建过程中由编译器生成的调试符号文件,和可执行文件一一对应。是否生成由 Build Settings 里的Debug Information Format决定,Release 配置下应设为DWARF with dSYM File。如果这里被改成DWARF,产物里就没有 dSYM,后面所有符号化都无从谈起。我在接手别人项目时第一件事就是确认这个配置,因为它出错的时候是完全静默的。
dSYM 会丢的原因有好几种。一是构建配置改过没同步到 CI。二是有人本地归档后直接把.xcarchive删了。三是用了某些加固或重签名流程,改变了二进制但没保留原始 dSYM。四是构建产物只上传了 ipa,没把 dSYM 一起归档。这些情况我都遇到过,所以后来在所有项目里都强制执行一条规则:只要出包,dSYM 必须进归档目录,和版本号绑定。
3.2 三条命令搞定手动符号化
本地符号化其实不需要任何图形工具,三条命令足够。
第一条,比对 UUID:
dwarfdump --uuid MyApp.app/MyApp dwarfdump --uuid MyApp.app.dSYM两行输出的 UUID 必须完全一致,不一致就不用往下走了,先去找正确的 dSYM。
第二条,如果知道 dSYM 在哪,直接用atos翻译单个地址。报告里0x0000000104c3a1b0是实际地址,0x104bfc000是模块加载基址,0x3e1b0是偏移:
atos -arch arm64 \ -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \ -l 0x104bfc000 \ 0x104c3a1b0输出就是-[OrderListCell layoutSubviews] (in MyApp) (OrderListCell.m:137)这种格式,一眼就能看到文件和行号。atos的好处是快,适合只关心某一帧的场景。
第三条,如果报告很长懒得逐帧翻,用官方脚本整份符号化:
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" SYMBOLICATE="/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash" "$SYMBOLICATE" -o symbolicated.crash raw.crash MyApp.app.dSYMDEVELOPER_DIR这个环境变量很容易漏,不设置的话脚本会报找不到工具链。另外这个脚本要求报告文件和 dSYM 在可访问路径下,路径里有空格时记得加引号,不然会莫名其妙失败。
3.3 把 dSYM 归档写进流水线
手动操作只适合救火,真正靠谱的是自动化。我在 CI 里加过这样一段脚本,作用是抽取每个 dSYM 的 UUID 并按 UUID 命名归档,后续查问题只给 UUID 就能自动找到文件:
#!/bin/bash set -e DSYM_ROOT="$BUILD_DIR/dSYMs" ARCHIVE_DIR="$BUILD_DIR/dsym-archive" mkdir -p "$ARCHIVE_DIR" find "$DSYM_ROOT" -name "*.dSYM" -maxdepth 2 | while read -r dsym; do uuid=$(dwarfdump --uuid "$dsym" | head -n 1 | awk '{print $2}') name=$(basename "$dsym" .dSYM) zip -qr "$ARCHIVE_DIR/${name}_${uuid}.zip" "$dsym" echo "archived: ${name}_${uuid}.zip" done这样命名之后,从崩溃报告里复制 UUID,去归档目录里搜一下就能命中,比按时间翻压缩包高效太多。后来我们在归档服务上加了一层索引,输入 UUID 返回下载链接,整个定位流程从十几分钟缩短到几十秒。
3.4 符号化失败的对照排查
符号化不成功的原因其实高度集中,下面是这几年踩出来的对照表,遇到问题先按表查,能省很多时间。
| 现象 | 大概率根因 | 处理方式 |
|---|---|---|
堆栈全是0x104bfc000 + 0x3e1b0 | dSYM 缺失或 UUID 不匹配 | dwarfdump --uuid双向比对 |
| 业务帧有符号,系统帧没有 | 只加载了自己的 dSYM | 补上系统符号或用官方符号缓存 |
atos报unable to load | 架构参数写错或二进制被裁剪 | 换成 arm64 并检查二进制完整性 |
| 行号缺失但函数名正常 | 优化等级过高,内联严重 | 排查 Release 优化配置 |
| 只有部分帧可读 | 二进制被二次处理 | 使用构建机原始归档产物 |
再补一个提效小技巧,用 Spotlight 直接按 UUID 找 dSYM:
mdfind "com_apple_xcode_dsym_uuids == 8A3E1C2D-9F4B-3A7E-8C1D-2E3F4A5B6C7D"前提是这台机器上索引过 Xcode 的归档目录。团队里如果大家都用同一台构建机归档,这个命令比翻目录快得多。
4. 从堆栈读到代码行之间还差哪些判断
4.1 先确认崩在哪个线程,再看崩在哪一帧
符号化完成之后,第一件事不是从第一帧往后读,而是找Crashed Thread那一行。它可能写着Thread 0 Crashed,也可能写着Thread 7 Crashed。这个信息决定了你后面所有推理的方向:主线程崩,多半跟 UI 刷新、布局、生命周期回调有关;子线程崩,多半跟并发访问、后台任务、网络回调有关。
找到崩溃线程后,看它的第 0 帧。第 0 帧是崩溃发生时的指令位置,通常落在系统库或者运行时里,比如objc_msgSend、swift_retain。不要停在这一帧,继续往下看第 1、2、3 帧,第一个属于你自己 App 的帧才是真正的"案发现场"。我一般会继续往下多读两三帧,因为第 1 帧有时是某个框架的封装,真正的业务调用在第 3 帧。
4.2 读寄存器:pc、lr、fp 各自告诉你什么
崩溃报告里会有一段Thread N crashed with ARM Thread State,列出了崩溃瞬间所有寄存器的值。对 arm64 来说,几个关键寄存器值得重点关注。
pc是程序计数器,就是崩溃时正在执行的那条指令地址,和第 0 帧是同一个位置。lr是链接寄存器,保存的是函数返回地址,也就是"谁调用了当前函数",它在符号化之后往往能直接指向上一层的调用点,当堆栈看起来断了的时候,lr常常是救命的那根线。fp是帧指针,指向当前栈帧的起始位置,顺着它一路回溯可以手工重建调用栈,在栈被破坏的情况下这是唯一办法。x0到x7在方法调用中通常承载参数,判断是不是空指针时,x0的值特别有参考价值。
举个实际例子,一次崩溃报告里第 0 帧是objc_msgSend,第 1 帧符号化失败显示为???,但lr的值符号化之后指向了-[CartViewController refreshTotal]。这就说明是refreshTotal里某个消息发送给了一个坏对象,范围一下就缩小到十几行代码。
4.3 复现路径的设计:把偶现降到必现
分析出代码位置只是第一步,能不能复现决定了修复效率。我的习惯是先把崩溃按"必现 / 高频 / 低频 / 一次性"分层,高频的最多花半天去构造复现路径,一次性的先记录证据等复现样本累积。
构造复现路径有几个常用手法。最小化输入,把触发操作简化到最少步骤,比如把"打开列表-滚动-点击-返回"压缩成"滚动后立刻返回"。制造时序压力,偶现崩溃多半和时序有关,用慢速网络、连续快速点击、后台切换这些手段放大竞争窗口。固定环境,线程数、内存压力、设备型号都可能是变量,把变量收敛到最小集合。加日志验证,在怀疑的代码路径上打点,观察是否真的按预期顺序执行。
有一次排查一个 KVO 崩溃,报错是观察者未移除。本地怎么都复现不了,后来用慢速网络加上频繁的前后台切换,几轮就复现了,原因是某个请求回调在页面销毁之后才返回,导致在 dealloc 之后仍然添加了观察者。
4.4 三个真实崩溃的拆解过程
案例一:数组越界。报告是EXC_CRASH (SIGABRT),Application Specific Information里写着NSRangeException,reason 是index 3 beyond bounds [0 .. 2]。符号化后定位到某个 cell 配置方法里用了list[indexPath.row],而这个list在数据刷新过程中被清空了,但reloadData还没执行完。修复方式是在取数据前加边界判断,同时把数据源更新和 UI 刷新放到同一个串行队列里。
案例二:后台持锁被杀。表现为退到后台几秒后崩溃,Termination Reason: Namespace SPRINGBOARD, Code 0xdead10cc。原因是后台任务里做数据库批量写入,事务还没提交系统就要求挂起。修复方式是把长事务拆成小批次,每批提交后检查剩余时间,接近超时就保存现场,下次继续。
案例三:Swift 强制解包。EXC_BREAKPOINT (SIGTRAP),堆栈里有Swift runtime failure: Unexpectedly found nil while unwrapping an Optional value。顺着往上找两层,定位到一段配置解析代码里用了!。修复方式是改guard let,并且补了一个默认值分支,因为这份配置本来就是可选的。
这三个案例的共同点是,一旦符号化到位,定位过程都不超过半小时,真正耗时的是复现和验证。
5. 自建一套崩溃采集机制需要注意什么
5.1 系统能力和自建方案的边界
iOS 本身提供了崩溃收集能力,用户开启共享分析后,开发者可以在开发者后台或者 Xcode 的崩溃面板里看到数据。它的优点是零代码接入、符号化由系统处理,缺点是覆盖率依赖用户授权、上报延迟长、样本不全,做线上问题追踪不够用。
所以实际项目里通常都会接入一套自建或第三方的崩溃采集。开源方案里比较常见的是基于PLCrashReporter这类崩溃报告库做二次封装,它的原理是在进程内注册异常与信号处理器,崩溃发生时把线程上下文和内存信息写到磁盘,下次启动时上报。自建的好处是数据完全可控、可以自定义附加信息、可以对接自己的聚合系统;代价是要自己处理符号化、去重、聚合展示。
5.2 异常处理与信号捕获的实际写法
Objective-C 未捕获异常可以通过注册回调拿到,写法大致如下:
static NSUncaughtExceptionHandler *gPreviousHandler = NULL; static void MyExceptionHandler(NSException *exception) { NSString *name = [exception name]; NSString *reason = [exception reason]; NSArray<NSString *> *symbols = [exception callStackSymbols]; NSArray<NSNumber *> *returnAddresses = [exception callStackReturnAddresses]; // 只做最小化记录,避免在异常路径上再触发新问题 WriteRecordToPreallocatedBuffer(name, reason, symbols, returnAddresses); if (gPreviousHandler) { gPreviousHandler(exception); } }注册的时候记得保存原处理器,处理完再转交,否则可能影响其他框架的异常监听。信号捕获则要用sigaction而不是老的signal,因为需要SA_SIGINFO才能拿到更多上下文:
static struct sigaction gPreviousAction[NSIG]; static void SignalHandler(int sig, siginfo_t *info, void *context) { // 只把信号号、fault address、pc/lr 写进预分配缓冲区 CaptureMinimalContext(sig, info, context); } static void InstallSignalHandlers(void) { struct sigaction action; memset(&action, 0, sizeof(action)); action.sa_sigaction = SignalHandler; action.sa_flags = SA_SIGINFO | SA_ONSTACK; sigemptyset(&action.sa_mask); sigaction(SIGSEGV, &action, &gPreviousAction[SIGSEGV]); sigaction(SIGBUS, &action, &gPreviousAction[SIGBUS]); sigaction(SIGABRT, &action, &gPreviousAction[SIGABRT]); sigaction(SIGTRAP, &action, &gPreviousAction[SIGTRAP]); sigaction(SIGILL, &action, &gPreviousAction[SIGILL]); sigaction(SIGFPE, &action, &gPreviousAction[SIGFPE]); }5.3 崩溃处理函数里绝对不能做的事
这是最容易翻车的地方,我得单独强调。信号处理函数运行在一个非常受限的环境里,只能调用异步信号安全的函数。这意味着里面不能malloc、不能创建 NSObject、不能做字符串格式化、不能写日志框架、不能加锁、不能用NSLog。
违反这些规则带来的后果很隐蔽:原本只是偶现崩溃,加了采集之后变成高频崩溃,甚至出现死循环卡死。原因就是处理函数里调用了会加锁或者分配内存的接口,而崩溃发生时锁的状态本来就是不确定的。
正确做法是提前分配一块固定大小的缓冲区,处理函数里只做memcpy和简单的赋值,把信号号、故障地址、寄存器快照按固定格式写进去,落盘操作留给下次启动时做。如果确实需要处理栈溢出(SIGSEGV由栈溢出引起),还要用sigaltstack申请一块独立的信号栈,并在sa_flags里加上SA_ONSTACK,否则处理函数本身也会因为没有栈空间而失败。
5.4 上报、聚合、去重:让几千条崩溃收敛成十个问题
采集到原始数据只是开始,真正的工程难点在于聚合。我的做法是给每条崩溃生成一个指纹,输入是崩溃线程的前若干帧符号加上异常类型,输出是一个稳定的哈希值。同一指纹的崩溃归为一组,展示的时候只显示组,展开才看原始记录。
指纹计算要注意稳定性。用符号化之后的函数名而不是地址,因为地址每次启动都不一样。要跳过系统库帧,因为系统版本变化会影响它们。要限制参与计算的帧数,通常取崩溃线程的前 5 到 8 帧就够了,取太多会让同一问题因为递归深度不同而被拆成多组。
聚合之后,列表按影响设备数排序,而不是按出现次数。因为一条崩溃出现一万次但只影响一台设备,和一条崩溃出现一百次影响一百台设备,优先级完全不同。这个排序策略调整之后,我们的修复效率提升非常明显,因为终于能把精力放在真正影响面广的问题上。
6. 让崩溃变少的那些工程习惯
6.1 高频崩溃清单与对应防御写法
做了几年线上问题,高频崩溃翻来覆去就那么几种。整理成表放在这里,做代码评审的时候可以当检查清单用。
| 崩溃现象 | 常见触发 | 防御写法 |
|---|---|---|
NSRangeException | 数组下标越界 | 取值前判断索引范围,或用firstObject/objectAtIndexedSubscript:的安全封装 |
unrecognized selector | 调用了不存在的方法 | 协议方法加respondsToSelector:判断 |
| KVO 未移除 | 观察者生命周期长于被观察者 | 在dealloc中确保移除,或改用 block 型 KVO 并绑定生命周期 |
SIGTRAP强制解包 | Swift 中用了! | 一律改guard let/if let,必要时给默认值 |
| 看门狗超时 | 主线程同步 I/O、密集计算 | 耗时操作移到子线程,启动阶段尤其注意 |
| 内存超限 | 大图未降采样、循环引用 | 图片按显示尺寸降采样,闭包内用[weak self] |
| 后台被杀 | 挂起时仍持有锁 | 长事务拆分,及时提交并释放资源 |
6.2 多线程与内存管理里最容易翻车的地方
并发问题是崩溃的高发区,而且往往偶现。最常见的模式是"在子线程改了数据源,主线程正在读"。这种问题的修复不能靠加锁了事,加锁只解决了内存安全,没解决 UI 状态不一致。更稳的做法是把数据源的读写收敛到一个串行队列上,UI 层只消费快照。
另一个高发区是 block 和闭包里的循环引用。表现为页面退出后对象不释放,内存持续增长,最终触发热降频或者被系统回收。排查方法是给关键类加dealloc打点,如果退出页面后不打点,基本可以确定有强引用环。修复就是老老实实加[weak self],并在闭包内做一次guard let self = self else { return }。
还有一类容易被忽视的是NSTimer和CADisplayLink持有 target 导致的循环引用,尤其是定时器没有在合适时机invalidate。我的习惯是把定时器的持有方和被持有方画一遍引用关系图,确认没有环再提交。
6.3 灰度发布与崩溃率告警的阈值设置
技术再稳也挡不住新代码带来的新问题,所以灰度是必须的。我一般按 1%、5%、20%、50%、100% 分档放量,每一档观察一个完整的活跃周期。观察的指标里,崩溃率只占一部分,还要看启动耗时、页面停留时长、关键路径转化,因为有些问题不表现为崩溃,但同样致命。
崩溃率告警的阈值设置很讲究。绝对值不能一刀切,一个日活十万的应用和一个日活一千的应用,对同一个崩溃的敏感度完全不同。我的做法是设两级阈值:相对增幅和影响设备绝对数。相对增幅超过基线两倍触发预警,影响设备数超过某个绝对量触发告警。两个条件只要满足一个就通知,避免小应用因为基数小导致相对值失真。
告警还要绑定版本号,避免旧版本的历史问题反复打扰。以及告警要带上下文,直接附上聚合后的指纹和堆栈,值班的人点开就能看,不用再去翻后台。
6.4 修复之后的回归验证怎么才算到位
修复完不等于结束。我见过太多次"改了但没改对"或者"改出了新问题"。回归验证至少要做三件事。
第一,构造针对性的测试用例,把当初的触发路径固化成自动化用例或者手工用例,每次发版前跑一遍。第二,观察灰度版本的崩溃率曲线,确认对应的指纹不再出现,而不是整体崩溃率下降就完事,因为可能被其他新增问题掩盖。第三,检查修复是否引入了副作用,比如加了判空之后某处逻辑走了默认分支,导致展示异常,这类问题在崩溃数据里看不到,得看业务埋点。
最后分享一个我一直在用的习惯:每次修完一个线上崩溃,在提交信息里写清楚崩溃指纹、根因、修复方式和验证结论。半年之后回头看这些提交记录,会发现高频问题的模式其实高度重复,而这份记录本身就成了团队里最好用的排错手册。崩溃分析这件事,说到底拼的不是工具多高级,而是每一次都留下可复用的结论。