三年磨一剑,货拉拉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]QrcVC *2 (12%)
  • [ViewWillDisappear]SearchVC *1(6%)

也就是说,很可能是什么原因触发了WelfareVC 在被push或pop的时候,还有其他vc也正在被push或pop。接着分析用户行为日志: 发生crash时,app的使用时长:

发现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

相关推荐
多彩电脑3 小时前
SwiftUI里的ForEach使用的注意事项
macos·ios·swiftui·swift
sziitjin4 小时前
IOS 25 实现歌单详情(UITableView)列表 ②
ios·uitableview
yanling20235 小时前
camtasia2024绿色免费安装包win+mac下载含2024最新激活密钥
macos·ios·camtasia·camtasia2024
万兴丶9 小时前
Unnity IOS安卓启动黑屏加图(底图+Logo gif也行)
android·unity·ios
2401_8524035512 小时前
探索iPhone一键删除重复照片的方法
ios·iphone
pf_data1 天前
手机换新,怎么把旧iPhone手机数据传输至新iPhone16手机
ios·智能手机·iphone
键盘敲没电2 天前
【iOS】KVC
ios·objective-c·xcode
吾吾伊伊,野鸭惊啼2 天前
2024最新!!!iOS高级面试题,全!(二)
ios
吾吾伊伊,野鸭惊啼2 天前
2024最新!!!iOS高级面试题,全!(一)
ios
不会敲代码的VanGogh2 天前
【iOS】——应用启动流程
macos·ios·objective-c·cocoa