崩溃率从 0.3% 到 0.04%:我们做对的不是修 bug,是让每个崩溃只发生一次

这篇讲的是一套流程,不是一堆技巧。技巧在中间,流程在两头。文中代码按线上修复整理,未逐行重新编译验证;崩溃率两个数字是面板曲线两端的大致位置,不是精确统计。

你大概经历过这样的周一。

打开 Crashlytics,崩溃率曲线从周五开始往上翘。Top 1 是 -[__NSArrayI objectAtIndexedSubscript:]: index 3 beyond bounds [0 .. 2],堆栈落在一个时间轴布局类里。你加了个判断,下午出热修,周三曲线下来了。周四 Top 1 换成了另一条,-[UICollectionView _updateVisibleCellsNow:],堆栈里没有一行是你的代码。再修,再发。一个月后你回头看曲线:它在 0.25% 到 0.35% 之间抖,从来没有真正往下走。

我在一个视频编辑产品线上干了十年,这条抖动的曲线我盯了前几年。后来它从 0.3% 一路走到 0.04%,而且不再弹回去。回头看,决定性的不是哪一次修复写得多漂亮,而是我们换掉了「发现一个修一个」这套工作方式,换成一套让每个崩溃只允许发生一次的流程。

这篇把这套流程拆开讲,中间穿插六个真实的坑,都是我们自己踩的。

一、先算账:0.3% 到底是多少次崩溃

崩溃率这个数太小,容易让人觉得「还行」。换成次数就不一样了。

假设一个产品每天有 100 万次会话。0.3% 的会话崩溃率就是每天 3,000 次闪退。闪退的人里,有多少是正在导出一个剪了半小时的视频?不知道,但肯定不是零。而 App Store 的评分区里,「闪退」是出现频率最高的差评关键词之一,这一条我们在自己的评论区里数过。

降到 0.04% 之后,同样的 100 万次会话,是 400 次。少掉的 2,600 次里,没有一次是靠「运气好」少掉的。

这笔账算清楚以后,团队对崩溃的态度就变了:它不是「发版前顺手看一眼」的事,而是一个有 KPI 的工程项目。

二、救火模式为什么不收敛

我们原来的工作方式,跟多数团队一样:每次发版前看 Top 崩溃,挑几个修掉。这个模式有三个结构性问题,不解决它们,修多少个都没用。

第一,按堆栈修,不按来源修。 堆栈告诉你崩在哪一行,不告诉你为什么会走到那一行。按堆栈修,你修的是症状。同一个来源会换着堆栈反复出现,每次看起来都像新问题。

第二,修复没有验收标准。 「修好了」的定义是什么?代码合进去了?还是线上同一条崩溃归零了?如果是前者,一个修复可能三个版本都在「修」。

第三,多个 App 共享代码谱系,修一处漏三处。 我们有几款 App 是从同一套代码长出来的,时间轴、布局、第三方 SDK 的接入方式都一样。一个崩溃在 A 修了,B 和 C 还在崩,而且因为版本节奏不同,B 要几个月后才会在面板上冒出来。

下面六个坑,每一个都踩中了上面至少一条。

三、六个坑,按来源分

坑 1:「防御性」的占位对象,比 nil 更危险

症状 :时间轴的 UICollectionView 偶发崩溃,堆栈全在 UIKit 内部,_updateVisibleCellsNow: 一类。我们的代码一行不在栈上。

第一次修复(2022 年) :我在布局的 layoutAttributesForElementsInRect: 里加了 isnan 检查,把 frame 是 NaN 的 attributes 过滤掉。曲线确实下来了一点。然后这条崩溃以每个版本几十次的频率,安静地活了四年。

根因(2026 年):布局类里有这样一段代码:

objc 复制代码
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    if (self.dataArray.count <= indexPath.section) {
        return [UICollectionViewLayoutAttributes new];   // 「防御」
    }
    NSArray *section = self.dataArray[indexPath.section];
    if (indexPath.row >= section.count) {
        return [UICollectionViewLayoutAttributes new];   // 「防御」
    }
    ...
}

写这两行的人(也可能就是我)的想法很直接:越界了,返回个空对象总比崩了强。但 [UICollectionViewLayoutAttributes new] 的 indexPath 是 nil,frame 是零矩形。这个对象被返回给 UICollectionView,UIKit 拿它去匹配 cell、建索引,然后在它自己的内部崩掉。你看到的堆栈里没有你的代码,是因为崩溃点在「消费」这个假对象的地方,不在「生产」它的地方。

而 isnan 检查只是把一部分假对象拦住了。frame 为零的那些照样漏过去。

修法 :越界就返回 nil。UIKit 的契约允许 layoutAttributesForItemAtIndexPath: 返回 nil,并且在 layoutAttributesForElementsInRect: 里跳过它:

objc 复制代码
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    if (indexPath.section < 0 || indexPath.section >= (NSInteger)self.dataArray.count) {
        return nil;
    }
    NSArray *section = self.dataArray[indexPath.section];
    if (indexPath.row < 0 || indexPath.row >= (NSInteger)section.count) {
        return nil;
    }
    ...
}

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSMutableArray *result = [NSMutableArray array];
    for (NSInteger s = 0; s < self.dataArray.count; s++) {
        NSInteger n = [(NSArray *)self.dataArray[s] count];
        for (NSInteger r = 0; r < n; r++) {
            UICollectionViewLayoutAttributes *attrs =
                [self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:r inSection:s]];
            if (!attrs) continue;
            if (CGRectIntersectsRect(rect, attrs.frame)) [result addObject:attrs];
        }
    }
    return result;
}

顺带说一句,另一个布局类里还有一处 [UICollectionViewLayoutAttributes new],用来给「没有 cell 的 section」造一个高度为零的占位。那里改成了 layoutAttributesForCellWithIndexPath:,让占位对象至少有一个合法的 indexPath。

这个坑的教训是全文最重要的一条:用一个「看起来没问题」的假对象替代 nil,不是防御,是把崩溃从你能看见的地方挪到你看不见的地方。nil 会在你的代码里崩,堆栈清清楚楚;假对象会在框架内部崩,你要花四年才找到它。

坑 2:客户端把服务端的契约写死了

症状 :签到弹窗偶发 Index out of range,Swift 的 fatal error,用户只要打开签到页就必崩。只影响一小部分账号。

根因 :签到是七天一轮,UI 画了七个格子。代码里到处是 % 7、signInItemViews[signedIndex]。服务端某次调整后,某些账号的 detail 数组返回了超过七条记录。七个格子的视图数组,拿第八个下标去取,崩。

这是典型的「服务端契约漂移」:接口没有改字段,只是数据的形状变了,客户端的隐含假设就破了。

修法:把「七」从隐含假设变成显式边界,所有数组访问都走边界内的视图:

swift 复制代码
private static let maxDisplayDays = 7

private var signInDaysCount: Int {
    min(signInTask.detail?.count ?? 0, Self.maxDisplayDays)
}

private var totalPoints: Int {
    signInTask.detail?
        .prefix(Self.maxDisplayDays)
        .reduce(0) { $0 + ($1.point ?? 0) } ?? 0
}

func markSigned(at index: Int) {
    guard signInEntityList.indices.contains(index) else { return }
    signInEntityList[index].isSigned = true
    if signInItemViews.indices.contains(index) {
        signInItemViews[index].playSignIconAnimation()
    }
}

indices.contains 比 index < count 多挡住一种情况:负数。continuousCount 这种来自服务端的整数,也要 max(_, 0) 一下再取模,负数取模在 Swift 里的结果不是你以为的那个。

这个坑的闭环动作不只是改代码。我们在修复记录里标了来源「服务端数据形状变化」,并且给接口文档补了一句「detail 最多七条,客户端只消费前七条」。下次服务端再改,至少有人能查到这句话。

坑 3:删除顺序错了,状态比对象活得久

症状:删除一个马赛克贴纸后,再点时间轴的某个位置,崩。不是每次。

根因 :删除马赛克有两条路径,一条从底部工具栏的「取消」进来,一条从贴纸选中框的删除按钮进来。前者把 currentMosaicObj 置空了,后者没有。贴纸从时间轴里删掉了,currentMosaicObj 还指着它,下一次有人读这个属性,拿到的是一个已经不在任何数组里的对象,再往下走就崩。

修法:删除的状态清理集中到一处,两条路径都调它:

objc 复制代码
- (void)removeCurrentMosaic {
    [self.timelineView hideSelectedBorderView];
    self.currentMosaicObj = nil;
    [self.timelineView updateOverlayVideoArray];
    [self.mosaicView hiddenView];
}

这类崩溃的来源我们归为「状态顺序」。它跟数据越界不一样:数据越界是输入不可信,状态顺序是自己的代码在不同入口做了不一致的事。防复发的手段也不一样:前者靠边界检查,后者靠「同一个动作只有一个入口」。

坑 4:七年前的调试钩子,在线上活到了今天

症状 :某个版本之后,统计 SDK 相关的崩溃突然上了 Top 5。堆栈很奇怪,进了 SDK 的 event: 方法,然后在一个 block 里崩。

根因 :工程里有一个 2019 年写的分类,用 runtime 把友盟 MobClick 的 +event: 和 +event:attributes: 两个类方法换成了自己的实现,目的是在 Debug 包里 toast 一下埋点名,方便测试看埋点有没有发出去。

objc 复制代码
Method m = class_getClassMethod(self, @selector(event:));
_VIMP originalIMP = (_VIMP)method_getImplementation(m);
method_setImplementation(m, imp_implementationWithBlock(^(id target, NSString *text) {
    originalIMP(target, @selector(event:));   // 少传了 text
    [self showMessage:text];
}));

_VIMP 被定义成可变参数函数指针 void (*)(id, SEL, ...)。调用原实现的时候,eventId 参数没传。在 arm64 上,可变参数的传递规则和固定参数不同,原实现从寄存器里读到的 eventId 是什么,取决于那一刻寄存器里恰好剩了什么。这段代码能跑七年不崩,纯粹是因为它读到的垃圾值大多数时候恰好是个对象。SDK 升级,内部实现变了,垃圾值不再「恰好」了。

修法:整个文件删掉。121 行删除,零行新增,崩溃归零。

这个坑的教训 :崩溃最密集的地方,往往是零业务价值的代码。调试钩子、临时的 runtime 补丁、「先这样跑着」的 workaround,没人维护,没人敢删,SDK 一升级就炸。我们之后给工程做了一次专项:所有 +load 里的 method_exchangeImplementations 和 method_setImplementation 列成清单,每一条写清楚「为什么还需要它」,写不出来的删。

还有一个后续。这个分类同时存在于另一款同谱系的 App 里。那边四个月后才在面板上冒出同一条崩溃,同事按我们的修复记录把文件删掉。这就是前面说的「修一处漏三处」。之后我们的修复记录多了一个必填项:「同谱系 App 是否同步」。

坑 5:系统版本带来的崩溃,修法往往是「删」

这一类我们归为「系统版本」,有三个典型案例。

iOS 18 与 iAd :启动时调用 [[ADClient sharedClient] requestAttributionDetailsWithBlock:] 拿归因,这是 iOS 14.5 就废弃的 API。iOS 18 发布后它进了崩溃列表。修法是删掉这段调用。废弃 API 的正确处理时机是它被废弃的那个版本,拖到它崩的那个版本就是被动的。

iPad 加 Apple Pencil :登录弹窗关闭时崩,只在 iPad 上,只在插着 Pencil 的设备上。堆栈在 PKTextEffectsWindowObserver 附近。原来的代码在 deinit 里对两个 UITextField 调了 resignFirstResponder。iPad 上启用了 Scribble 的输入框会挂一个 PencilKit 的观察者到文字特效窗口上,在 deinit 里再去动响应链,这个观察者正处在被拆的状态,就崩了。修法:deinit 里只移除通知观察者,不碰响应链;在 dismiss 动画开始之前就把响应者安全地 resign 掉。

iOS 12 的 unrecognized selector :同谱系的另一款 App 当时最低支持 iOS 12,用户量极小,崩溃率却显著高于均值,大头是 iOS 12 上不存在的系统 API。修法是把 Deployment Target 提到 13.0,顺手删掉所有 @available(iOS 13, *) 的兼容分支。

这三个案例的共同点:没有一个是「写了新代码修好的」。全是删。系统版本类的崩溃,最有效的治理动作是定期砍掉过时的东西,而不是给过时的东西打补丁。

坑 6:扩展进程里的 fatalError,Spike 成功不等于生产成功

症状:iOS 18 的控制中心 Control 里加了一个「一键录音」开关。Spike 验证通过,正式实现一上真机就崩:

less 复制代码
AppIntents/PerformActionExecutorTask.swift:669: Fatal error:
AudioRecordingIntent was performed with an active audio session but without a Live Activity

根因 :AudioRecordingIntent 协议有一条文档里写得不显眼的契约:perform() 返回时如果音频会话处于 active,就必须存在一个活跃的 Live Activity。而且它检查的是进程的全局会话状态,不是「本次 perform 有没有碰会话」。主 App 进程里只要有残留的 active session,这个 Intent 一执行就触发 fatalError。

Spike 为什么没崩:探针录三秒就 setActive(false) 了,返回时会话已关,绕过了检查点。正式实现是持续录音,返回时会话必然 active。

修法 :去掉 AudioRecordingIntent 这条 conformance,只保留 SetValueIntent + LiveActivityIntent。后者才是把 perform() 路由进主 App 进程的关键,前者声称的后台特权从来没被单独验证过,但崩溃风险是确定的。

这个坑的教训:扩展进程的崩溃有两个特殊性。一是 Crashlytics 对 Widget、Intent 这类进程的覆盖不如主 App 完整,有些 fatalError 你在面板上根本看不到,只能靠真机。二是 Spike 的「用完即关」会掩盖协议契约。验证一个系统能力,探针的形态必须和生产形态一致,否则验证的是另一个东西。

四、防复发闭环:什么叫「修好了」

六个坑讲完,回到流程。崩溃治理的核心是给「修好了」一个可验证的定义,然后围绕这个定义建闭环。

我们现在的规则:

  1. 先归因,再排序。 每条 Top 崩溃先标来源:数据边界、状态顺序、系统版本、底层引擎、零价值代码、扩展进程。同一来源的合并处理。排序看三个量:影响用户数、路径是否在核心流程上、可修复程度。次数多但在边缘功能的,排后面。
  2. 根因加复现路径,缺一不能提交修复。 只有堆栈没有复现路径,不算定位。这一条挡住了大量「加个判空试试」的修复。
  3. 无法复现的,允许加防守代码,但必须标「待监控」。 防守代码上线后七天内同一条崩溃归零,才改成「已修复」。没归零,回到排查。
  4. 七天归零才算关闭。 「修好了」的定义是面板上的曲线,不是代码合入。
  5. 修复引入新崩溃,先回滚。 不在带病的修复上继续打补丁。
  6. 同谱系 App 同步。 修复记录里必填「其他 App 是否同步」,没同步的进下个版本的清单。
  7. 长尾不修。 crash-free 已经到 99.9% 以上的长尾,不投入。治理是为了把曲线压到一个稳定的低位,不是为了清零。

这七条里最难执行的是第二条和第四条,因为它们都在「拖慢」修复。但正是它们让曲线不再弹回去。

五、上报侧:为什么 Crashlytics 之外还要自己接一套

我们同时跑两套上报。Crashlytics 负责符号化堆栈和面板,这块自己做不划算。另一套是自研埋点 SDK 里的异常处理器,改造自一个开源数据分析 SDK 的异常模块,职责很窄:崩溃发生时把原因和堆栈当作一个普通埋点事件发出去,和用户当时的会话、操作序列落在同一张表里。

Crashlytics 回答「崩在哪」,这一套回答「崩之前用户在干什么」。坑 3 那种只有特定操作顺序才触发的崩溃,光看堆栈很难猜出复现路径,有了前面十步的操作序列就容易了。

自己接异常处理器有几个必须做对的细节,不然它本身就是一个崩溃来源:

objc 复制代码
static int YZSignals[] = {SIGILL, SIGABRT, SIGBUS, SIGSEGV, SIGFPE, SIGPIPE, SIGTRAP};
static volatile atomic_int_fast32_t YZExceptionCount = 0;
static const atomic_int_fast32_t YZExceptionMaximum = 9;

- (void)setupHandlers {
    // 1. 记住前一个处理器,自己处理完必须转发,否则 Crashlytics 就瞎了
    _lastExceptionHandler = NSGetUncaughtExceptionHandler();
    NSSetUncaughtExceptionHandler(&YZHandleException);

    struct sigaction action;
    sigemptyset(&action.sa_mask);
    action.sa_flags = SA_SIGINFO;
    action.sa_sigaction = &YZSignalHandler;
    for (int i = 0; i < sizeof(YZSignals) / sizeof(int); i++) {
        struct sigaction prev;
        if (sigaction(YZSignals[i], &action, &prev) == 0) {
            memcpy(_signalHandlers + YZSignals[i], &prev, sizeof(prev));  // 2. 同样记住前一个信号处理器
        }
    }
}

static void YZHandleException(NSException *exception) {
    YZFaultHandler *handler = [YZFaultHandler sharedHandler];
    // 3. 计数上限:处理器自己抛异常会递归,9 次以后放弃
    if (atomic_fetch_add_explicit(&YZExceptionCount, 1, memory_order_relaxed) <= YZExceptionMaximum) {
        [handler handleUncaughtException:exception];
    }
    if (handler.lastExceptionHandler) {
        handler.lastExceptionHandler(exception);
    }
}

- (void)handleUncaughtException:(NSException *)exception {
    [self trackCrashEvent:exception];
    [self flush];
    // 4. 最多等 1 秒让网络队列把事件发出去,然后把处理器交还系统
    dispatch_semaphore_t sema = dispatch_semaphore_create(0);
    dispatch_async(self.networkQueue, ^{ dispatch_semaphore_signal(sema); });
    dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));

    NSSetUncaughtExceptionHandler(NULL);
    for (int i = 0; i < sizeof(YZSignals) / sizeof(int); i++) {
        signal(YZSignals[i], SIG_DFL);
    }
}

四个细节对应四种自己把自己搞崩的方式:不转发前一个处理器,Crashlytics 收不到;不保存前一个信号处理器,信号链断掉;不设计数上限,处理器里的异常无限递归;不设等待上限,进程挂在崩溃处理里不退出,系统看门狗再补一刀。

一个我们自己留下的盲区

录屏那款 App 的工程里有一个 2021 年引入的 NSArray 分类,+load 里把 __NSArrayI、__NSArrayM、__NSArray0 的 objectAtIndex: 和下标访问全部换成了带边界检查的版本:越界时 Debug 断言,Release 返回 nil。

这类「全局兜底」确实能立刻把越界崩溃压下去。但它有一个我们当时没想清楚的代价:Release 包里,越界之后只有一行 NSLog,没有上报。崩溃率下来了,bug 还在,而且从此在面板上消失了。坑 2 那种服务端契约漂移,如果发生在这款 App 里,我们大概率到今天都不知道。

现在的做法是:兜底可以留,但兜底触发的地方必须走非致命上报,把它当一条「本来会崩的事件」记下来。全局 swizzle 系统类这件事本身,我不建议新项目再做,原因见坑 4:runtime 补丁的维护成本在它写下的那一刻是零,在三年后是无穷大。

六、如果你的团队不是这个规模

诚实地说,上面这套流程是为「几款 App、共享代码谱系、日活足够让 0.1% 的变化有统计意义」的团队设计的。不是所有团队都需要。

  • 单人或两三人的团队、单一 App:Crashlytics 默认接入,每周看一次 Top 3,修复时坚持「根因加复现路径」这一条就够了。不要自己接异常处理器,不要全局 swizzle。
  • 有 QA 但没有专门的客户端基建:加上第四条「七天归零才算关闭」和第五条「引入新崩溃先回滚」。这两条不需要任何工具,只需要在修复记录里多两个字段。
  • 多 App、共享代码:第六条「同谱系同步」是你最大的杠杆。我们在这一条上吃的亏,比任何单个崩溃都大。

七、三个反直觉的观察

第一,「防御性」的占位对象比 nil 更危险。 nil 让崩溃发生在你的代码里,堆栈直指根因。假对象让崩溃发生在框架内部,堆栈里没有一行是你的。四年和四天的差别就在这里。

第二,崩溃率下降最大的几刀,都不是「写代码」。 删掉七年前的调试钩子,删掉废弃的 iAd 调用,砍掉 iOS 12。每一刀都是减法。治理的第一个动作应该是列出工程里所有「没人能说清为什么还在」的东西。

第三,Spike 成功不等于生产成功。 探针为了快而「用完即关」,恰好绕过了协议的检查点。验证系统能力时,探针的形态必须和生产形态一致,否则你验证的是另一个程序。

八、可迁移的原则

把这套东西从 iOS 崩溃里抽出来,它其实是一条通用原则:任何一类反复出现的故障,救火不收敛的原因都是「修好了」没有定义。

给「修好了」一个可观测的定义(七天归零),给每个故障一个来源标签(而不是一个堆栈),给每个修复一个必须回答的问题(根因是什么、怎么复现、其他地方有没有同样的),曲线就会往下走,而且不再弹回去。

这套流程我们在崩溃上验证过一次,后来在 ANR 和导出失败率上又用了一次,结构完全一样。


这类线上稳定性问题,我提供 48 小时内的书面诊断:你给堆栈、复现路径和版本信息,我给来源归因、修复建议和防复发清单。联系方式见个人主页。

相关推荐
2501_915921434 小时前
详细教程:使用Perfdog工具测量iOS应用的功耗性能与优化方法
android·macos·ios·小程序·uni-app·cocoa·iphone
风雨_5 小时前
RTK (Rust Token Killer) 安装、使用与卸载指南
ios
茶底世界之下5 小时前
夜景暗部要提亮多少?用 Awaking 给雪夜街道留一点层次
ios
黑科技iOS上架6 小时前
cocos游戏ios上架前加固流程(小蟹iOS混淆)
ios·审核·ios混淆·执行程序差异化
东坡肘子7 小时前
当 Build in Public 重新转入地下 -- 肘子的 Swift 周报 #157
人工智能·swiftui·swift
QWsin1 天前
「轻漾」App隐私政策
ios·个人开发
season_zhu1 天前
近段时间,AI加持下,我的开发感悟
ios
用户38034165882971 天前
Flutter 迁 SwiftUI:AI 把页面"搬"过来只用了两周,我们补结构花了两个月
ios·swift