三年磨一剑,货拉拉iOS用户端10万分位Crash率攻坚之战

摘要:

货拉拉iOS用户端经历了多年的迭代,作为近百万日活的App,Crash率经历了从千分位到万分位,再到十万分位的降率过程。本篇文章将深入探讨iOS平台下Crash治理的背景、收益,介绍Crash监控方案的优缺点,分享Crash治理的思路和经验,分析常见的Crash类型及其解决方案,并探讨iOS平台下常用的Crash防护方法,最后总结长期Crash治理的经验,旨在为前进中的开发者提供宝贵的技术方案和经验。

作者: Sherwin.Chen

一、背景

货拉拉iOS用户端作为一个拥有庞大用户群体的应用,日活量接近百万,在Crash治理方面的投入和努力直接影响到用户满意度和业务稳定性。其App的Crash经历了从千分位到万分位,再到十万分位的Crash率降低过程。在这期间,我们上线了过flutter、mPaaS混合开发方案,也经历过App首页大重构,遇到BaiduMapSDK大范围的崩溃。经过3年左右时间,我们终于达到Crash率10万分位目标,这个过程中我们积累了丰富的Crash治理经验和技术方案。

Crash治理收益可包括:

  • 提升用户体验:减少Crash可以降低用户的不满和流失,提高用户满意度。减少引发的卸载行为。
  • 保护品牌声誉:稳定的应用会增强品牌的信誉,吸引更多用户。
  • 降低维护成本:减少崩溃导致的客服投诉和bug修复时间。
  • 提高开发效率:减少Crash可以减少开发人员的繁重维护工作,使他们能够更专注于新功能和性能优化。

下表列举了我们治理过程中几个重要的里程碑事件:

货拉拉iOS用户端App Crash率 App版本 时间
节点一(千分位): 0.270% 6.4.88 2020/11
节点二(万分位): 0.030% 6.5.77 2021/10
节点三(十万位分): 0.008% 6.8.8 2023/08

二、Crash监控方案

iOS Crash监控平台是开发中非常重要的工具,它可以帮助我们及时发现和解决应用程序的崩溃问题。在iOS平台下,Crash监控方案有多种选择,如KSCrash、PLCrashReporter、Firebase Crashlytics、友盟、Bugly等。在技术层面本文就不展开解释,如有兴趣可阅读开源项目KSCrash:github.com/kstenerud/K...

在选择监控方案时会考虑以下因素:

  • 实时性:监控能否及时发现Crash。
  • 稳定性:监控工具本身不应成为引发Crash的因素。
  • 数据分析:提供详细的Crash报告,有助于问题定位。
  • 集成难度:方案是否容易集成到现有开发流程中。
  • 成本:方案是否符合预算。

我们对于Crash监控方案,选择经历了友盟、Bugly以及现在所使用的自建HadesCrash。之所以选择自建,有以下几个方面的思考:

1. 信息安全: 目前App的UV、PV、崩溃等数据暴露给第三方Crash监控平台,存在数据安全风险;

2. 数据能力: 打破数据壁垒,结合公司体系完善崩溃管理流程,增加日报、周报、告警等能力;

3. 提高人效: 崩溃自动分配,符号表自动上传,发布自动回滚,定位问题提效;

4. 生态闭环: 打通飞书账号、CI发布、用户反馈、qamp系统以及日志系统;

监控主面板如下:

本文就不过多介绍自建Crash平台的相关技术,有兴趣的可以了解:www.xuyanlan.com/2019/01/14/...

三、 Crash治理思路

在Crash治理过程中,关键是持续改进和优化。以下是一些经验分享:

  • 分级治理:将Crash按照严重性分级,优先处理严重崩溃,逐步降低Crash率。
  • 版本追踪:监控Crash与应用版本关联,确保新版本没有引入新的Crash。
  • 定期分析:定期分析Crash数据,了解发生频率最高的Crash类型,以便有针对性地解决。
  • 团队合作:Crash治理需要跨部门合作,开发、测试、运维等团队需要密切协作。

根据这几年的经验和实践,总结一个有效的方案,核心是"共建共治":

  • 建立Crash攻坚专项小组(虚拟团队),拉上相关人:本组成员、其它业务成员、二方组件维护者
  • 每个迭代版本定期收集Crash数据,记录在任务管理文档中,可包括: 任务名称、Crash链接、累计发生次数、分派人、业务分类、进度....etc
  • 每两周在攻坚专项小组花30分钟(Crash双周会),过一下近期Crash任务进度和治理情况
  • 每周周会上可分享Crash治理经验和过程,让更多同事了解此问题。以及编写代码时避免入坑,写出更优秀的代码。
  • 每个季度总结当前Crash率数据与成果,总结经验并复盘整体收益,让所有参与者有使命感、责任心。

四、常见Crash类型与解决方案

在iOS平台上,对于OC项目很多都是野指针问题导致,对于Swift项目很多都是强解包导致。

常见的信号量类型本文就展开聊了,有兴趣的可参考: juejin.cn/post/700101...

遇到的常见Crash类型主要有以下几种:

1. 空指针引用(NULL Pointer Dereference)

问题描述: 当尝试访问或操作一个空指针时,会导致空指针引用Crash。

解决方案/思路:

  • 使用条件判断(if语句)在访问指针之前检查其是否为nil。
  • 在使用可选类型时,使用可选绑定(optional binding)或空合并运算符(??)来安全地处理可能为空的值。案例分析:
csharp 复制代码
var someObject: SomeClass? = nil
someObject!.someMethod() // 会导致空指针引用Crash
// 解决方案
if let object = someObject {
    object.someMethod() // 只有当 someObject 不为 nil 时才调用方法
}

2.野指针访问(Dangling Pointer Access)

问题描述: 当尝试访问已经被释放或无效的内存地址时,会导致野指针访问Crash。

解决方案/思路:

  • 在释放对象后,将指针设置为nil,以避免访问已释放的对象。
  • Xcode开发时,在Debug阶段开启僵尸模式,Release时关闭僵尸模式
  • 使用弱引用(weak references)来避免强引用环(retain cycle)导致的野指针问题。案例分析:
  • 更详细的方案可参考: [iOS 野指针定位:野指针嗅探器] www.jianshu.com/p/9fd4dc046...
go 复制代码
var strongReference: SomeClass? = SomeClass()
var weakReference: SomeClass? = strongReference
strongReference = nil // 此时 weakReference 变成了野指针
// 解决方案
weakReference = nil // 在释放 strongReference 后,将 weakReference 设置为 nil

3.内存泄漏(Memory Leaks)

问题描述: 当应用中的对象没有被正确释放或释放时机不当,会导致内存泄漏,最终导致应用崩溃或占用过多内存。

解决方案/思路:

  • 使用ARC(自动引用计数)来管理内存,避免手动释放对象。
  • 使用工具如Instruments来检测和分析内存泄漏问题。
  • 在适当的时候,使用弱引用或无主引用(unowned references)来防止循环引用。
kotlin 复制代码
class ViewController: UIViewController {
    // ...
    @IBAction func showModal() {
        let modalVC = ModalViewController()
        modalVC.onClose = {
            // 在这里更新UI
        }
        present(modalVC, animated: true, completion: nil)
    }
}

class ModalViewController: UIViewController {
    var onClose: (() -> Void)?
    // ...
    @IBAction func close() {
        // 执行网络请求等操作
        //......
        // 关闭模态视图控制器
        dismiss(animated: true) {
            // 在这里执行闭包
            self.onClose?()
        }
    }
}
//这个案例中,ModalViewController包含一个闭包属性onClose,当模态视图控制器被关闭时,它会执行这个闭包。在ViewController中,我们在按钮点击事件中设置onClose闭包,以便在模态视图控制器关闭时更新UI。
//然而,这个代码存在潜在的内存泄漏问题。当ModalViewController被弹出时,它会保留对ViewController的引用,因为onClose闭包捕获了self。
//如果用户在ModalViewController显示期间旋转设备或者执行其他操作,可能会导致ViewController无法被释放,从而造成内存泄漏。

//为了避免这种内存泄漏,你可以使用Swift的弱引用来解决问题。修改ModalViewController的onClose闭包如下:
class ModalViewController: UIViewController {
    weak var onClose: (() -> Void)?
    // ...
}

4.主线程阻塞(Main Thread Blocking)

问题描述: 当主线程被长时间阻塞(例如,耗时操作在主线程上执行)时,会导致应用无响应或崩溃。

解决方案/思路:

  • 将耗时操作移到后台线程以避免主线程阻塞。
  • 使用GCD(Grand Central Dispatch)或操作队列来管理并发任务。
  • 使用异步操作来执行网络请求、文件读写等可能耗时的操作。案例分析:
rust 复制代码
// 错误示例:在主线程上执行耗时操作
DispatchQueue.main.async {
    for _ in 0..<1_000_000 {
        // 长时间的计算
    }
}
// 解决方案
DispatchQueue.global().async {
    for _ in 0..<1_000_000 {
        // 在后台线程上执行计算
    }
}

5.数组越界(Array Out of Bounds)

问题描述: 当尝试访问数组的索引超出有效范围时,会导致数组越界Crash。

解决方案/思路:

  • 使用合适的边界检查来防止数组越界,例如使用count属性来判断数组的大小。
  • 在访问数组元素之前,确保索引值在有效范围内。案例分析:
ini 复制代码
let numbers = [1, 2, 3]
let index = 5
let value = numbers[index] // 会导致数组越界Crash
// 解决方案
if index < numbers.count {
    let value = numbers[index] // 只有在索引有效时才访问数组元素
}
  • 如上面所列的问题,都是初级的Crash,能够在Crash监控平台看到相应的业务函数调用堆栈。
  • 对于iOS开发老手来说,千分位的Crash所展示或看到的,都是能逐个解决的。困扰着我们开发的是那些只有系统堆栈的Crash,只能通过个人经验、以及日志埋点去验证性的尝试修复.

疑难杂症之Crash

当前货运iOS用户端也遇到一些项目实际中比较难解的Crash问题,我搜罗整理了一下比较经典的,为各位读者提供思路或解决方案。

1. Can't add self as subview

swift 复制代码
Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x00000000 at 0x0000000000000000
Crashed Thread:  0
Application Specific Information:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Can't add self as subview
UserInfo:(null)'
// 崩溃线程
Thread 0 Crashed:
0      CoreFoundation                        ___exceptionPreprocess
1      libobjc.A.dylib                       _objc_exception_throw
2      CoreFoundation                        ___CFDictionaryCreateGeneric
3      UIKitCore                             -[UIView(Internal) _addSubview:positioned:relativeTo:]
4      UIKitCore                             ___53-[_UINavigationParallaxTransition animateTransition:]_block_invoke_2
5      UIKitCore                             +[UIView(Animation) performWithoutAnimation:]
6      UIKitCore                             ___53-[_UINavigationParallaxTransition animateTransition:]_block_invoke
7      UIKitCore                             +[UIView _performBlockDelayingTriggeringResponderEvents:forScene:]
8      UIKitCore                             -[_UINavigationParallaxTransition animateTransition:]
9      UIKitCore                             -[UIPercentDrivenInteractiveTransition startInteractiveTransition:]
10     UIKitCore                             -[_UINavigationInteractiveTransitionBase startInteractiveTransition:]
11     UIKitCore                             ____UIViewControllerTransitioningRunCustomTransition_block_invoke_3
12     UIKitCore                             +[UIKeyboardSceneDelegate _pinInputViewsForKeyboardSceneDelegate:onBehalfOfResponder:duringBlock:]
13     UIKitCore                             ____UIViewControllerTransitioningRunCustomTransition_block_invoke_2
14     UIKitCore                             +[UIView(Animation) _setAlongsideAnimations:toRunByEndOfBlock:]
15     UIKitCore                             __UIViewControllerTransitioningRunCustomTransition
16     UIKitCore                             -[UINavigationController _startCustomTransition:]
17     UIKitCore                             -[UINavigationController _startDeferredTransitionIfNeeded:]
18     UIKitCore                             -[UINavigationController __viewWillLayoutSubviews]
19     UIKitCore                             -[UILayoutContainerView layoutSubviews]
20     UIKitCore                             -[UIView(CALayerDelegate) layoutSublayersOfLayer:]
...........
38     Huolala                               main        main.m:14
39     (null)                                0x0 + 7672682824

Crash原因:

Push和Pop时如果Animated参数是YES,那么Push和Pop不是立马完成的。如果在Push、Pop动画完成前又有新的Push、Pop,此时会产生SIGABRT信号异常。

Crash分析:

  1. 分析页面生命周期事件

通过大量的Crash数据分析发现,崩溃前的页面生命周期统计占比数据如下:

  • ViewWillDisappear\]WelfareVC \*14(82%)

  • ViewWillDisappear\]SearchVC \*1(6%)

发现10s以内占比27%,也就是刚启动没多久;结合分析用户日志,看到有用户点击跳过开屏广告的几乎同时,app也打开了福利中心的页面[WelfareVC]。

这时候有两个猜想:

  1. 用户点击跳过的时候,点击事件穿透了广告页,直接打开了福利中心的页面,导致福利中心页面push的时候,同时广告页在dismiss导致的。
  2. 用户点击跳过广告的时候,一般是没有耐心的,很可能高频次重复点击,第一次点击触发了关闭广告页,第二次点击触发了打开福利中心页(福利中心页面的入口跟跳过按钮在屏幕上的坐标接近);后续的点击触发了重复push福利中心页(正好我们查到代码中,福利中心的入口按钮是没有做防抖处理的)。

接下来是验证这两个猜想:

  • 模仿用户的行为,启动时在开屏广告页面,高频重复多次点击「跳过按钮」,看是否能复现。
  • 如果不好复现,可以在代码中,将点击事件延迟1秒响应,然后重复点击。

解决方案

1. 临时方案

查阅当前Crash数据,WelfareVC、QrcVC 这两个页面占比最多,结合重复点击福利中心、扫码下单按钮可复现crash,所以第一期修复是对这两个按钮做了1s的防抖处理;上线后,新版本相关crash数量下降明显WelfareVC、QrcVC 相关已消失,但还剩零星的其他页面有crash上报。此次实验证明重复push确实是crash的根因。

2. 彻底解决方案

在 UINavigationController 基类中添加正在push的标记位,在动画结束之后,再重置这个标志位,然后,用这个标志位判断push和pop操作是否能够执行。

objectivec 复制代码
-(void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    if (animated) {
        if (self.isSwitching) {
#if defined(DEBUG) && DEBUG
            NSArray *array = self.viewControllers;
            [SHDialogManager showDialogWithMessage:[NSString stringWithFormat:@"重复跳转, %@",array] confirmTitle:@"确定"];
#else
#endif
             return; // 1. 如果是动画,并且正在切换,就不执行当次转场动画,避免重复push引起的crash
        }
        self.isSwitching = YES; // 2. 否则修改状态
    }
    [super pushViewController:viewController animated:animated];
}

当导航控制器通过视图控制器堆栈的推入、弹出或设置显示新的顶部视图控制器时重置标记位:

objectivec 复制代码
- (nullable NSArray<__kindof UIViewController *> *)popToRootViewControllerAnimated:(BOOL)animated{
    self.isSwitching = NO;
    return [super popToRootViewControllerAnimated:animated];
}

此次处理在版本 6.7.32上线后,此Crash得以完全治理.

参考资料:

github.com/Instagram/I... stackoverflow.com/questions/1... lengmolehongyan.github.io/blog/2015/1...

2. NSLayoutConstraint for xxView: Location attributes must be specified in pairs.

less 复制代码
Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x00000000 at 0x0000000000000000
Crashed Thread:  0
Application Specific Information:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'NSLayoutConstraint for xxView: A multiplier of 0 or a nil second item together with a location for the first attribute cre
// 崩溃线程
Thread 0 name:  Tmcom-MapRender
Thread 0 Crashed:
0      CoreFoundation                        ___exceptionPreprocess
1      libobjc.A.dylib                       _objc_exception_throw
2      CoreAutoLayout                        _ResolveConstraintArguments
3      CoreAutoLayout                        +[NSLayoutConstraint constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:]
4      Masonry                               -[MASViewConstraint install]        MASViewConstraint.m:328
5      Masonry                               -[MASCompositeConstraint install]        MASCompositeConstraint.m:174
6      Masonry                               -[MASConstraintMaker install]        MASConstraintMaker.m:46
7      Masonry                               -[UIView(MASAdditions) mas_remakeConstraints:]        View+MASAdditions.m:34
....
22     Huolala                               main        main.m:14
23     libdyld.dylib                         _start

Crash原因:

NSLayoutConstraint针对xxView:乘数为0或第二项为nil,以及第一属性的位置,将创建一个非法的约束,该位置等于一个常数。位置属性必须成对指定。在具体的业务里,基于约束的UI布局中,做动画的时候使用了Masonry的mas_remakeConstraints函数,来重置UI视图的约束,而此时视图的superview已经被释放,导致约束添加时没有父视图,造成crash产生. 而superview 被释放原因是因为网络数据回来后会执行0.2s的折叠动画,而此时忽略了用户可能刚进来,就立即就点击了返回,将当前页面Pop出去了。此时在执行动画的时候,vc的内存刚好被回收,约束就会找不到superview而发生crash。

objectivec 复制代码
 [UIView animateWithDuration:0.3
                     animations:^{
        self.detailView.alpha = 0;
        self.alpha = 1;
        [self.detailView mas_remakeConstraints:^(MASConstraintMaker *make) {
            make.left.right.equalTo(self.superview);
            make.top.equalTo(self.superview).offset(6);
            make.bottom.equalTo(self.superview).offset(-6);
            make.height.mas_equalTo([self smallStyleHeight]);
        }];
        [parentView layoutIfNeeded];
    }];

解决方案:

动画进行时,判断一下要执行视图以及视图父类是否为nil。如果为nil,直接返回,不做动作处理.

objectivec 复制代码
[UIView animateWithDuration:0.3
                     animations:^{
        if (self.detailView == nil || self.detailView.superview == nil) {
            return;
        }
        .....
}];

Crash预防

  • 警惕特殊场景

类似场景在处理动画和和UI约束的时候一定要警惕,特别是一些需要在某个时机自动触发的,要考虑到代码执行到那个时机时,上下文的一些变量是否发生变化。

  • 善用mas_remakeConstraints 方法

因为这个方式是会把之前的约束给清理掉,而重新添加约束,一般不到万不得已可以不用该方法,并且该方法移除约束的时候,对于一些复杂页面可能会造成其他约束冲突。 比如:某个页面在迭代的过程中,新增了一个A控件,如果A控件在某个地方设置约束equalTo了B控件,那么B控件如果调用mas_remakeConstraints方法,此时如果没考虑到A控件,就会造成A控件约束报错。

3. MMKV clearAll

arduino 复制代码
EXC_BAD_ACCESS (SIGSEGV) MMKVCore mmkv::MMKV::oldStyleWriteActualSize
Error stack:
1 MMKVCore | mmkv::MMKV::oldStyleWriteActualSize(unsigned long) + 72
2 MMKVCore | mmkv::MMKV::oldStyleWriteActualSize(unsigned long) + 56
3 MMKVCore | mmkv::MMKV::writeActualSize(unsigned long, unsigned int, void const*, bool) + 48
4 MMKVCore | mmkv::MMKV::clearAll() + 256
5 MMKV     | [mmkvObj clearAll]
6 xxxx     | [self.mProductValueStore clearAll]

Crash原因:

在我们项目里,有一些在线配置需要动态下发,因此使用了配置下发服务。其底层存储管理使用到了MMKV三方库。在App启动时,会主动获取最新的数据,并更新本地已有的存储数据。而此时会有一定概率会触发崩溃问题。

去MMKV项目的issues,也可看到有其它人遇到:github.com/Tencent/MMK...,相关使用MMKV代码如下:

ini 复制代码
MMKV *mProductValueStore = [MMKV mmkvWithID:kVisEventRelationIndexStore
                                            rootPath:[HLLActDataTool getForeverPathWithName:kVisEventRelationIndexStore]];
[self.mProductValueStore clearAll]; 

Crash分析:

堆栈最后一个方法为 MMKV::oldStyleWriteActualSize,我们直接查看源码如下:

ini 复制代码
void MMKV::oldStyleWriteActualSize(size_t actualSize) {
    MMKV_ASSERT(m_file->getMemory());

    m_actualSize = actualSize;
#ifdef MMKV_IOS
    auto ret = guardForBackgroundWriting(m_file->getMemory(), Fixed32Size);
    if (!ret.first) {
        return;
    }
#endif
    memcpy(m_file->getMemory(), &actualSize, Fixed32Size);
}

通过Crash堆栈,我们将报错的代码定位在了 memcpy(m_file->getMemory(), &actualSize, Fixed32Size); 这一行,对memcpy(void *__dst, const void *__src, size_t __n)三个入参进行预判发现,m_file->getMemory()是有可能为空的,再深入源码:

javascript 复制代码
void *getMemory() { return m_ptr; }

会发现 m_ptr 是指向了内存影射对象mmap,是否这块的值初使化失败了,导致指针是空引起的问题 ,还需要继续排查。思路有了,经过详细的排对、对比各类Crash的实时日志发现:

  • Crash的场景在App启动时触发
  • 内存剩余值: 20Mb~100Mb
  • 磁盘剩余值: 无特别规律

此时发现内存可用值特别的不健康,mmap是否会在这场景出现异常,不得而知。因对这块的技术深入不足,也无法在本地机器上复现这样场景。只能另辟蹊径,那我们是否可以减少此MMKV::oldStyleWriteActualSize()方法的调用,就能规避这个问题呢?

可以想到的方案:

  • 获取mmkv对象所有的key,一个个删除掉。不触发 clearAll方法调用 oldStyleWriteActualSize 的路径。且多线程改为单线程。
  • 需要清除数据时,清掉此对象,删除对应的文件,再重新初使化,再装载对应的数据,不触发 clearAll方法调用。

解决方案:

报着尝试的心态,每次启动时数据要更新,直接删除mmkv文件,再重新生成对应的数据。代码变成如下:

方案一:

ini 复制代码
//如需要更新,先将原本地对应的磁盘数据删除
if ([[NSFileManager defaultManager] fileExistsAtPath:pvs_fileName]) {
    [[NSFileManager defaultManager] removeItemAtPath:pvs_fileName error:nil];
}
//再生成新的MMKV对象
self.mProductValueStore = [MMKV mmkvWithID:kProductValueStore
                                  rootPath:pvs_fileName];

上线后验证,App放量后,60万的设备,没有一个相关的上报,也没有引发其它业务问题。于是这个Crash问题得解决。后面在做性能耗时优化时发现方案一会存在启动耗时的问题,于是尝试:

方案二:

ini 复制代码
//主动清空mmkv对象内所有数据

self.mProductValueStore = [MMKV mmkvWithID:kProductValueStore
                                  rootPath:pvs_fileName];
NSArray *allKeys = [self.mProductValueStore allKeys];
if(allKeys.count > 0){
    [self.mProductValueStore removeValuesForKeys:allKeys];
} 

经过验证,线上未有相关的Crash上报,同时在性能上也有新的提升。但底层次的原因还是无法解释,如有读者了解详细原因,欢迎留言指教!

4. libnetwork.dylib _nw_endpoint_flow_copy_path

先来看Crash堆栈信息:

markdown 复制代码
Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000010
// 崩溃线程
Thread 36 Crashed:
0      libnetwork.dylib                      _nw_endpoint_flow_copy_path
1      libnetwork.dylib                      _nw_endpoint_flow_copy_path
2      libnetwork.dylib                      _nw_endpoint_flow_connected
3      libnetwork.dylib                      _nw_flow_connected
4      libnetwork.dylib                      _nw_socket_connect
5      libnetwork.dylib                      _nw_endpoint_flow_connect
6      libnetwork.dylib                      _nw_endpoint_flow_setup_protocols
7      libnetwork.dylib                      -[NWConcrete_nw_endpoint_flow startWithHandler:]
8      libnetwork.dylib                      _nw_endpoint_handler_path_change
9      libnetwork.dylib                      _nw_endpoint_handler_start
10     libnetwork.dylib                      ___nw_connection_start_block_invoke
11     libdispatch.dylib                     __dispatch_call_block_and_release
12     libdispatch.dylib                     __dispatch_client_callout
13     libdispatch.dylib                     __dispatch_lane_serial_drain
14     libdispatch.dylib                     __dispatch_lane_invoke
15     libdispatch.dylib                     __dispatch_workloop_invoke
16     libdispatch.dylib                     __dispatch_workloop_worker_thread
17     libsystem_pthread.dylib               __pthread_wqthread

说到这个Crash,我们是真的头疼,先上图:

  • 上图为历史崩溃数据,总共发生7.2万次,影响到2.9万用户设备,其中发生Crash设备的系统集中在了iOS-14.6。
  • Crash列表中占到了Top1,是Crash治理专项的大头,此时我们App的崩溃率还处于千分位级别。
  • 同时也了解到在苹果论坛关于该问题的讨论:developer.apple.com/forums/thre...

Crash原因:

这是一个很明显的野指针崩溃,具体原因由于iOS 在14.5、14.6系统动态库libnetwork.dylib 内部API Bug导致的,而外部诱因是因为GCDAsyncSocket使用到了CFSocketStream相关的API,刚好触发了系统内部的Bug导致的崩溃。

Crash分析:

  • 1.通过大量Crash的日志排查,发现App在启动、进入后台时,崩溃量占比特别高。
  • 2.通过对所有代码、二进制库搜过排查,发现只有 个推SDK包含 "xxxAsyncSocket" 字符串,与个推技术开发确认,他们SDK确实使用了CFSocketStream相关的API。
  • 3.分析设备型号、系统类型,发现Crash集中在了iOS14.x系统下产生,其它版本号的系统无此问题。

解决方案:

首先确定整体的治理思路:

  • 推动个推停止使用GCDAsyncSocket,转变为使用Network.framework库,完成问题的修复。
  • 分析崩溃的场景,减少libnetwork.dylib 崩溃的机率。

为此我们做了如下优化动作:

  • 针对后台运行的崩溃,我们主动禁止掉了后台刷新能力。
  • 针对App启动时崩溃,我们将个推SDK初使化延迟到首页加载完后。

完成上述两项目优化措施后,我们每日的Crash由原来的 300个降到每日50个。

为了完成10万分位的目标,我们又做了如下动作:

  • 跟进个推SDK升级情况,建立技术沟通桥梁。优先解决我方公司提出的问题,定制化开发问题修复版本。
  • 自研消息推送方案,对个推SDK进行相应的替换,以达到完全控制问题的产生源。

自研消息推送因人力成本问题,还在试验阶段,未在应用侧上线。但个推SDK升级多个版本后,终于在v2.7.4定制化版本终结了此问题,解决的方案也很简单,使用苹果推荐的Network.framework库,做socket通信。

到此我们的Crash率进入了10 分位段位了。

排查妙招

在此,就不花过多的篇幅介绍其它的Crash了,给大家介绍经常使用的妙招:

  1. 可通过Crash监控后台,对版本维度、系统维度、手机型号维度,去准备当前Crash复现的场景。

  2. Crash复现思路

    1. 观察当前的Crash堆栈,选取核心的Api调用。
    2. 通过日志分析大概的页面路径,在项目对应版本跑起来。
    3. 通过Xcode 符号断点,触发时观察堆栈相似度,判断项目代码中应用场景。
  3. XCode Organize平台Crashes栏目上如有类似的Crash,可通过解析,直接用当前版本的项目代码打开,更直观的观察当前所有的线程堆栈信息。

  1. 基于KSCrash的Crash监控组件,上报的Crash寄存器可以保持最近的一些函数符号,可以根据当前所有寄存器的信息,尽可能的提供我们解题思路。

五、Crash防护技术方案

快速的业务的迭代(一周一版)总避免不了出现一些无可预料的问题,特别是Crash率已处理万分位或者十万分位,每天几个新增的Crash,就能让当日的Crash率像A股一样奇幻。为此,我们想着是否可以做一些安全防护措施,在发生Crash时,能够兜底住。

在Crash防护方面,我们主要采用了了以下几种技术方案:

  • 首先,我们会使用静态分析工具,如Clang Static Analyzer,进行代码检查,以便及时发现潜在的问题;
  • 其次,我们会使用单元测试和UI测试,确保代码的质量和稳定性;
  • 最后,针对于基于Runtime的工具类或容器类,我们可以做些AOP层面的异常处理,将这样一个处理组件称之为:安全气囊, 我们开发并应用到我们项目中。

安全气囊

通过 Runtime 机制可以避免的常见 Crash :

  • unrecognized selector sent to class/instance(找不到类/对象方法的实现)
  • KVO Crash
  • NSNotification Crash
  • NSTimer Crash
  • Container Crash(集合类操作造成的崩溃,例如数组越界,插入 nil 等)
  • NSString Crash (字符串类操作造成的崩溃)

大概的功能架构如下,详细请参考网易大神方案: neyoufan.github.io/2017/01/13/...

针对于我们所认知的,当前Crash虽然被防护住 了,是不是会给业务流程带来更致命的错误。站在业务产品的角度来说,这确实是需要考虑的,为此我们加入"安全防护提示弹窗"。

目的: 核心页面产生了安全防护动作时,给予用户提示,引导用户操作。

  • 引导用户重新进入当前页面,可解决一些偶现的Crash。
  • 根据当前页面的crash的次数阈值,尝试引导用户重启APP,规避此次crash。
  • 如果当前页面的crash的次数严重超标,引导用户截图当前的提示信息,通过用户反馈流程进行上报客服后台。

对于越成熟的团队,防护方案带来的效果会越小。因为成熟团队的代码质量相对更高,一些低级错误出现的概率极小。但对于小团队,或者历史比较久的项目而言,这套方案带来的帮助会比较大,毕竟坑总是防不胜防的。

六、 长期治理的总结和复盘

在不断追求降低Crash率的过程中,总结和复盘可以帮助我们团队更好地了解问题的本质、发现潜在的改进点,并制定未来的策略。我总结了一些经验,详细步骤如下,供大家参考:

1. 收集和分析Crash数据

  • 使用Crash监控工具/平台(如KSCrash、Bugly等)持续收集Crash数据。
  • 分析Crash数据,了解Crash的类型、频率、发生场景等信息。
  • 确保Crash数据的及时性和准确性。

2. 确定优先级

  • 识别并优先处理高频率的Crash,特别是那些影响用户体验的Crash。
  • 根据Crash的影响范围(是否影响核心功能)、Crash的危害性(是否导致数据丢失或安全问题)以及修复的复杂性来确定优先级。

3. 制定治理计划

  • 为每个高优先级的Crash制定治理计划,明确责任人和截止日期。
  • 确保治理计划是可执行的,包括必要的资源和工具支持。
  • 考虑使用敏捷开发方法,将Crash修复纳入每个迭代周期。

4. 进行Crash修复

  • 使用经验丰富的开发人员负责Crash修复工作,确保修复方案是可靠和稳定的。
  • 在修复Crash时,不仅要解决当前的Crash问题,还要防止类似问题再次发生。进行根本性的问题排查。
  • 在修复Crash后,进行单元测试和集成测试,以确保修复没有引入新问题。
  • 兜地方案,如果能接入配置下发管理平台,可通过开关控制,线上运行出现异常时可下发关闭。

5. 验证和监控

  • 部署修复后,继续监控Crash率,确保修复有效。
  • 如果Crash率下降,确保不会引入性能问题或其他不良影响。

6. 复盘和总结

  • 在完成Crash修复后,组织一个复盘会议,讨论整个治理过程,包括问题的根本原因、解决方案和修复效果。
  • 确认哪些操作是成功的,哪些是不成功的,以及如何改进。
  • 制定下一步的计划,包括如何预防类似Crash问题的再次发生,以及如何提高团队对Crash治理的敏感性。

7. 持续改进

  • 在Crash治理的基础上,建立一个持续改进的机制,确保不断改进应用的稳定性。
  • 定期审查Crash数据,检测新的Crash问题,并重复上述步骤来处理它们。
  • 保持团队的学习和更新,以跟进最新的iOS开发技术和工具。

总结和复盘是一个迭代的过程,可以帮助团队不断改进Crash治理策略,提高应用的稳定性和用户体验。长期的治理和持续改进将有助于减少Crash率,增强应用的可靠性,提高用户满意度,同时降低维护成本。

七、总结

在长期Crash治理过程中,需定期进行复盘,总结经验教训,不断改进。回顾过去3年,我们成功将Crash率从万分位降至十万分位。这个过程中,我们不仅提高了用户体验,也积累了宝贵的技术经验。

在Crash治理的道路上,不断学习、不断进步,与其他开发者分享经验,将有助于整个iOS生态系统的质量提升。希望这些经验和技术方案对于攻克Crash问题的开发者们有所帮助。愿大家的应用能够持续稳定运行,用户满意度不断提高。

因作者水平有限,本文有错误之处或者技术上讨论交流的,欢迎评论区留言。

八、货运iOS用户组成员:

Shilry、Elina、Stephen、Jeff、Jesse、Connor、Sherwin、Jun、Carl

相关推荐
无知的前端4 小时前
Flutter开发,GetX框架路由相关详细示例
android·flutter·ios
大熊猫侯佩4 小时前
iOS 18 中全新 SwiftData 重装升级,其中一个功能保证你们“爱不释手”
数据库·ios·swift
大熊猫侯佩4 小时前
SwiftUI 6.0(iOS 18)新容器视图修改器漫谈
ios·swiftui·wwdc
Digitally6 小时前
如何将 iPhone 中的短信导出为 PDF
ios·pdf·iphone
帅次1 天前
Flutter Container 组件详解
android·flutter·ios·小程序·kotlin·iphone·xcode
SoaringHeart1 天前
SwiftUI组件封装:仿 Flutter 原生组件 Wrap实现
ios·swiftui
I烟雨云渊T1 天前
iOS 抖音首页头部滑动标签的实现
ios
十月ooOO1 天前
uniapp 云打包 iOS 应用上传到 app store 商店的过程
ios·uni-app
帅次1 天前
Flutter setState() 状态管理详细使用指南
android·flutter·ios·小程序·kotlin·android studio·iphone
kymjs张涛1 天前
前沿技术周刊 2025-06-03
android·前端·ios