摘要:
货拉拉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分析:
- 分析页面生命周期事件
通过大量的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]。
这时候有两个猜想:
- 用户点击跳过的时候,点击事件穿透了广告页,直接打开了福利中心的页面,导致福利中心页面push的时候,同时广告页在dismiss导致的。
- 用户点击跳过广告的时候,一般是没有耐心的,很可能高频次重复点击,第一次点击触发了关闭广告页,第二次点击触发了打开福利中心页(福利中心页面的入口跟跳过按钮在屏幕上的坐标接近);后续的点击触发了重复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了,给大家介绍经常使用的妙招:
-
可通过Crash监控后台,对版本维度、系统维度、手机型号维度,去准备当前Crash复现的场景。
-
Crash复现思路
- 观察当前的Crash堆栈,选取核心的Api调用。
- 通过日志分析大概的页面路径,在项目对应版本跑起来。
- 通过Xcode 符号断点,触发时观察堆栈相似度,判断项目代码中应用场景。
-
XCode Organize平台Crashes栏目上如有类似的Crash,可通过解析,直接用当前版本的项目代码打开,更直观的观察当前所有的线程堆栈信息。
- 基于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