趣谈云集iOS APP的Crash治理之路
如果把crash比作一头狼,那么优化人员则是一名猎人,在这一场“狼人杀”活动中,要学会与狼共舞。如果忽略了它的存在,它就会愈演愈烈,最后造成大量用户的流失,进而给公司带来无法估量的损失。本文讲述云集iOS团队在优化过程中如何让崩溃率低于千分之一所做的大量实践工作,希望能够抛砖引玉,为其他团队提供一些经验和启发。
Crash治理背景
云集作为一个精品会员电商的APP,背后有10+业务线。
在Crash治理过程中面对的挑战有三项:体量大、迭代快和日活高。这三项挑战带来的直接影响是沟通成本上升和防范难度加大。因此在实际治理过程,主要围绕基础能力、治理效率两个层面进行探索和优化建设。
- 基础能力
Crash治理的基础能力主要体现在三个层面:可发现、可定位和可修复。
在发现能力层面,云集有一套守望监控系统,可以发现接口、页面、业务链等类型的异常情况。在定位能力层面,有可提供崩溃路径信息以及APP防御机制日志的监控体系。除此以外,还有本地日志系统提供额外的方法调用链及参数信息。
- 治理效率
为了提高治理效率,实际治理过程逐渐形成PR检查、机器化检查和crash治理平台。
PR检查流程主要针对预备提审阶段进行代码规范性检查、重点业务逻辑检查;机器化检查平台针对代码扫描和内存检查;crash治理平台是整个稳定性治理的核心,在建设中主要表现为快速定位问题,解决问题并且提出预防措施,通过可复用的能力快速接入并管理几乎所有稳定性相关的问题。
Crash治理原则
对于Crash的治理,我们尽量遵守以下三点原则:
1、提前扼杀:尽可能的提前预防Crash的发生,将Crash消灭在萌芽阶段;
2、全面预防:解决完Crash后,要去反思这一类Crash怎么去解决和预防;
3、规避为辅:不能随意的使用try-catch,否则隐蔽业务的真正问题,要根据业务场景去兜底,保证后续的流程正常。
Crash类型总结
总体来说,Crash分为两种类型:未捕获的Objective-C异常和Mach异常。
- 未捕获的Objective-C异常
在OC层面(iOS原生库、第三方库出现错误抛出)的异常称为Objective-C Exception异常,iOS开发中常见的OC异常包括以下几种:
(1)NSInvalidArgumentException:非法参数异常
(2)NSRangeException:越界异常
(3)NSInternalInconsistencyException:内部不一致导致出现的异常
(4)NSFileHandleOperationException:磁盘空间不足的异常
(5)NSMallocException:可用内存不足的异常
(6)NSGenericException:通用异常,当没有指定特定类型异常时会抛出通用异常
- Mach异常
Mach Exception是指最底层的内核级异常,Mach异常最终会转化成Unix Signal Exception信号异常投递到出错的线程。常见的Mach异常包括以下几种:
(1) SIGABRT:调用abort函数生成的信号。
(2) SIGBUS:非法地址, 包括内存地址对齐(alignment)出错。比如访问一个四个字长的整数, 但其地址不是4的倍数。它与SIGSEGV的区别在于后者是由于对合法存储地址的非法访问触发的(如访问不属于自己存储空间或只读存储空间)。
(3) SIGFPE:在发生致命的算术运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等其它所有的算术的错误。
(4) SIGKILL:用来立即结束程序的运行,本信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了,可尝试发送这个信号。
(5) SIGSEGV:试图访问未分配给自己的内存,或试图往没有写权限的内存地址写数据。
(6) SIGPIPE:管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。此外用Socket通信的两个进程,写进程在写Socket的时候,读进程已经终止。
Crash治理实践
常规的crash治理
常规Crash产生的原因主要是由于开发人员编写代码不小心导致的。解决这类Crash需要根据Crash引发的原因和业务本身,统一集中解决。常见的Crash类型包括但不限于如下:
(1)方法不存在异常:Unrecognized Selector Sent to Instance;
(2)后台返回空对象的异常:NSNull(多见于Java做后台服务器开发语言);
(3)数组越界,key-value参数异常:NSArray、NSMutableArray、NSDictonary、NSMutableDictionary;
(4)没有及时移除keypath导致的异常:KVO;
(5)访问野指针异常:Zombie Pointer;
(6)定时器没有及时移除导致内存泄漏:NSTimer;
(7)通知观察者忘记移除导致异常:NSNotification;
(8)下标越界以及参数nil异常:NSString、NSMutableString、NSAttributedString、NSMutableAttributedString;
(9)NSURL的初始化,不能传入nil;
(10)MRC时,对象被提前release的异常;
(11)for in循环中修改所遍历的数组,例如add或remove,会导致异常;
(12)存储空间不足的异常:缓存文档或视频;
以上所列举的Crash类型是App中最为常见的Crash,也是最容易反复出现的。在获取Crash堆栈信息后,解决这类Crash一般比较简单,更多考虑的应该是如何避免。下面介绍几个比较经典的Crash。
(1)多线程中访问NSMutableArray或NSMutableDictionary对象,偶现访问对象地址的异常
场景:
多线程访问可变数组或可变字典,并且伴随着for循环中对可变数组或可变字典的操作。
原因:
NSMutableArray、NSMutableDictionary它们不是线程安全类,在多线程访问中,无法预料访问到的对象地址是否被release释放,容易造成SIGSEGV异常。
解决方案:
自定义一个线程安全的的可变数组子类或可变字典子类,在多线程中替换使用。自定义的子类中涉及更改数组中元素的操作,使用异步派发+栅栏块;读取数据使用同步派发+并行队列。
关键代码如下:
- (NSUInteger)count{
__block NSUInteger count;
dispatch_sync(_syncQueue, ^{
count = _array.count;
});
return count;
}
- (id)objectAtIndex:(NSUInteger)index{
__block id obj;
dispatch_sync(_syncQueue, ^{
if (index < [_array count]) {
obj = _array[index];
}
});
return obj;
}
- (NSEnumerator *)objectEnumerator{
__block NSEnumerator *enu;
dispatch_sync(_syncQueue, ^{
enu = [_array objectEnumerator];
});
return enu;
}
- (void)insertObject:(id)anObject atIndex:(NSUInteger)index{
dispatch_barrier_async(_syncQueue, ^{
if (anObject && index < [_array count]) {
[_array insertObject:anObject atIndex:index];
}
});
}
- (void)addObject:(id)anObject{
dispatch_barrier_async(_syncQueue, ^{
if(anObject){
[_array addObject:anObject];
}
});
}
- (void)removeObjectAtIndex:(NSUInteger)index{
dispatch_barrier_async(_syncQueue, ^{
if (index < [_array count]) {
[_array removeObjectAtIndex:index];
}
});
}
- (void)removeLastObject{
dispatch_barrier_async(_syncQueue, ^{
[_array removeLastObject];
});
}
- (void)replaceObjectAtIndex:(NSUInteger)index withObject:(id)anObject{
dispatch_barrier_async(_syncQueue, ^{
if (anObject && index < [_array count]) {
[_array replaceObjectAtIndex:index withObject:anObject];
}
});
}
- (NSUInteger)indexOfObject:(id)anObject{
__block NSUInteger index = NSNotFound;
dispatch_sync(_syncQueue, ^{
for (int i = 0; i < [_array count]; i ++) {
if ([_array objectAtIndex:i] == anObject) {
index = i;
break;
}
}
});
return index;
}
(2)渲染图片的异常
场景:
使用GPUImage,进行图片的合成,绘制等操作。
原因:
渲染图片的尺寸超过当前设备GPU上限。
解决方案:
在方法的实现中限制最大图片尺寸。
关键代码如下:
+ (UIImage *)gpuBlurImage:(UIImage *)image withBlurNumber:(CGFloat)blur{
if (!image) {
return nil;
}
CGRect imgFrame = CGRectMake(0, 0, image.size.width, image.size.height);
NSInteger maxSize = [GPUImageContext maximumTextureSizeForThisDevice] / [UIScreen mainScreen].scale;
if (image.size.width > maxSize || image.size.height > maxSize) {
CGFloat scale = MIN(maxSize / image.size.width, maxSize / image.size.height);
CGFloat xInset = (image.size.width - scale * image.size.width) / 2.0;
CGFloat yInset = (image.size.height - scale * image.size.height) / 2.0;
CGRect frame = UIEdgeInsetsInsetRect(imgFrame, UIEdgeInsetsMake(yInset, xInset, yInset, xInset));
imgFrame = frame;
}
GPUImageGaussianBlurFilter *filter = [[GPUImageGaussianBlurFilter alloc] init];
filter.blurRadiusInPixels = blur;
[filter forceProcessingAtSize:imgFrame.size];
GPUImagePicture *pic = [[GPUImagePicture alloc] initWithImage:image];
[pic addTarget:filter];
[pic processImage];
[filter useNextFrameForImageCapture];
return [filter imageFromCurrentFramebuffer];
}
(3)子线程刷新UI的异常
场景:
- 某个自定义的子线程中操作UI;
- webview中js调用原生代码,没有主动回归到主线程中执行。
原因:
主线程又名UI线程,凡是刷新UI的操作,都应放到主线程中。
解决方案:
涉及到子线程的代码中,如果有刷新UI的代码,都应主动切换到主线程中来执行。
关键代码如下:
if ([NSThread isMainThread]) {
//执行UI代码
} else {
dispatch_async(dispatch_get_main_queue(), ^{
//执行UI代码
});
}
(4)dispatch_group不当使用造成的异常
场景:
监听多个异步操作,等待所有异步结果返回后执行自定义的逻辑,这里面使用到GCD的dispatch_group。
原因:
1、当dispatch_group_enter次数比dispatch_group_leave次数多的时候,不会产生崩溃,但是dispatch_group_notify不会执行;
2、当dispatch_group_enter次数比dispatch_group_leave次数少的时候,会直接崩溃,因为从dispatch_group_leave的源码中可以知道:当old_value已经为0的时候,再执行dispatch_group_leave调用,就会触发"Unbalanced call to dispatch_group_leave()"的崩溃;
3、dispatch_group_enter与dispatch_group_leave不严格匹配,但是个数匹配,不会产生问题。
解决方案:
这类问题,需要结合具体业务逻辑去查问题,综合考虑各种情况下dispatch_group_enter与dispatch_group_leave的个数匹配问题,以及可能的内存泄漏导致个数不匹配问题。
系统级crash治理
iPhone相比Android的手机来说,手机型号和系统都比较少,iPhone因为闭源,所以没有Android手机所谓的定制化ROM。但是不管iOS系统如何优秀,在某些系统版本上都会存在一些bug容易造成APP的崩溃,发现这类Crash,主要靠测试平台,配合云测平台,以及线上监控,这种情况下的Crash堆栈信息很难直接定位问题。下面是常见的解决思路:
- 尝试找到造成Crash的可疑代码,看看是否有特别的API或者调用方式不当导致的,尝试修改代码逻辑来进行规避;
- 通过Hook来解决,主要是为了解决系统方法的实现里面没有进行异常判断。Hook是利用runtime来实现更改相应API的行为,需要尝试找到可以Hook的点,一般可以直接交换方法,同时需要注意系统类是类族的情况,要做好兼容性工作。Hook是个高危险操作,一定要做好线上可开关的控制。
- 如果通过前两种方式都无法解决的话,那我们只能给苹果开发者官网提交Radar,等待解决的办法。
下面列举了几个常见的系统方面的crash,以及相应的解决办法。
(1)UICollectionView的异常
场景:
UICollecitonView在改变数据源后,需要刷新UI时,会偶现崩溃情况,并且不同的系统版本表现不太一样。
原因:
UICollecitonView控件在刷新布局时,可能沿用旧的布局导致获取不到数据,导致崩溃。
解决方案:
UICollecitonView在刷新前,先使旧的布局失效,重新执行新的布局数据。
关键代码如下:
[self.collectionView.collectionViewLayout invalidateLayout];
[self.collectionView reloadData];
(2)可变数组的遍历异常
场景:
在遍历数组的时候,又同时修改这个数组里面的内容,可能导致崩溃。
原因:
此类崩溃通常出现在iOS 10-10.2之间。
解决方案:
在遍历前将数组拷贝,遍历拷贝的数组。
关键代码如下:
NSMutableArray *dictTemp = dict;
NSArray *array = [NSArray arrayWithArray:dictTemp];
for (NSObject *model in array) {
if (condition){//满足某个条件时,可以对临时数组dictTemp操作
[dictTemp removeObject:model];
}
}
dict = dictTemp.mutableCopy;
(3)CoreData库的异常
场景:
使用CoreData查询数据时,持久化存储协调者有可能获取不到指定的实体类,表现为:NSPersistentStoreCoordinator for searching for entity name 'xxxx'。
原因:
CoreData库本身存在的问题。
解决方案:
利用try-catch捕获NSInvalidArgumentException异常,进行容错处理,保证后面的流程不崩溃。
关键代码如下:
@try {
NSString *tableName = NSStringFromClass([xxxx class]);
NSEntityDescription *entity = [NSEntityDescription entityForName:tableName
inManagedObjectContext:context];
} @catch (NSException *exception) {
//异常处理
}
(4)进入后台后,sqlite操作时的异常
场景:
APP进入后台之后,APP利用UIBackgroundTask或其他延迟后台执行时间的方法,对sqlite进行存取数据时,偶现崩溃。
原因:
此问题,很可能与数据保护NSFileProtectionKey有关联,苹果在iOS13.2.2系统已修复此问题。
解决方案:
对于数据库文件(.db、.wal、.shm)的文件保护属性设置为NSFileProtectionNone,不能保证完全有效。
关键代码如下:
NSDictionary *protection = [NSDictionary dictionaryWithObject:NSFileProtectionNone forKey:NSFileProtectionKey];
[[NSFileManager defaultManager] setAttributes:protection ofItemAtPath:newFilePath error:nil];
OOM治理
OOM是Out Of Memory的简称,通常会分为FOOM和BOOM。在常见的iOS Crash疑难排行榜上,OOM绝对可以名列前茅,至今仍然经久不衰。因为它发生时的Crash堆栈信息往往不是导致问题的根本原因,而只是压死骆驼的最后一根稻草。 导致OOM的原因大部分如下:
- 内存泄漏,大量无用对象没有被系统回收,导致后续申请内存失败。
- 大内存对象过多,最常见的大对象就是CGBitmapContext,几个大图同时加载很容易触发OOM。
内存泄漏
内存泄漏指系统未能及时释放已经不再使用的内存对象,一般是由错误的代码逻辑引起的。在iOS平台上,最常见也是最严重的内存泄漏就是Block的循环引用泄漏。Block对象的泄漏同时也意味着它持有的对象都无法被回收,如果泄漏的对象不停地增加与积累,那么会造成OOM。常见会造成Block循环引用泄漏的原因有:
- Block块没有正确使用Weak-Strong-Dance,导致持有的Block对象无法回收。
- 单例类与Block混合使用时,单例对象没有使用正确的初始化方法,嵌套Block时可能会产生内存泄漏。
对于Block泄漏,Facebook有提供FBRetainCycleDetector 这个工具来帮助监测循环引用问题,但是这个工具不能完全保证准确性。另外我们可以使用Xcode下Instrument工具(Allocations、Leaks)来检查内存泄露等问题,Xcode还有一个新功能来检测内存:内存图分析(memory graph)。
大对象
在iOS平台上,任何应用都会非常频繁使用到的功能就是加载图片。本地加载图片一般都会经过三步:
- 从磁盘读取原始压缩的图片数据(png/jpeg格式等等)缓存到内存;
- CPU解压成未压缩的图片数据 (imageBuffer);
- 渲染图片(会生成frameBuffer,帧缓存,最终显示到手机屏幕)。
对于网络图片的加载,我们一般会使用到SDWebImage或者YYWebImage等框架,它们下载图片主要简化流程可以如下所示:
- 从网络下载图片源数据,默认放入内存和磁盘缓存中;
- 异步解码,解码后的数据放入内存缓存中;
- 回调主线程渲染图片;
- 内部维护磁盘和内存的cache,支持设置定时过期清理,内存cache的上限等。
从以上所知,加载图片都会经历图片解码的过程,其中占用内存最多的对象大都是CGBitmapContext对象。随着手机屏幕尺寸越来越大,屏幕分辨率也越来越高,为了达到更好的视觉效果,我们往往需要使用大量高清图片,同时也为OOM埋下了祸根。 对于图片内存优化,我们有几个常用的思路:
- 尽量使用成熟的图片库,比如SDWebImage,图片库会提供很多通用方面的保障,减少人为失误。
- 超大图加载在一个小的view上,使用苹果推荐的缩略图DownSampling方案即可。
- 全屏加载大图,通过拖动来查看不同位置图片细节,可以使用苹果的CATiledLayer去加载,滑动时通过指定目标位置,通过映射原图指定位置的部分图片数据解码渲染。
- 根据实际需要,也就是View尺寸来加载图片,可以在分辨率较低的机型上尽可能少地占用内存。除了常用的本地绘制图片方法之外,我们的图片CDN服务器也支持图片的实时缩放,可以在服务端进行图片缩放处理,从而减轻客户端的内存压力。
- 分析App内存的详细情况是解决问题的第一步,我们需要对App运行时到底占用了多少内存、哪些类型的对象有多少个有大致了解,并根据实际情况做出预测,这样才能在分析时做到有的放矢。
展望未来
智能化测试平台
在体量百万级以上的的App中几乎不可能实现毫无瑕疵的技术方案和组件,而我们认为Crash对用户来说是最糟糕的体验,尤其是涉及到交易的场景,所以我们必须本着每一单都很重要的原则,尽最大努力保证用户顺利操作流程。为了保障线上APP的稳定性,我们需要APP产品在测试阶段或者灰度阶段,尽最大能力的提前发现问题,以及可跟踪问题。所以要求一套智能化的测试平台,可以实现如下功能:
- 将APP中的场景脚本化,并在海量手机上自动执行,从安装、启动、运行、功能、UI等多维度,深度发现并定位APP兼容性问题;
- 进行多人次、多维度的探索性功能测试,覆盖真实用户场景,发现隐蔽缺陷;
- 可以在短时间内执行大量的重复性测试任务和多终端测试任务,提供7×24小时的服务,提高测试效率和产能,确保App在功能回归、兼容、性能等各方面的可靠性;
- 可随时跟踪测试进度,可输出测试评价表,以便逐一验证功能完整性、正确性及适用性。
可自动捞回指定crash类型日志
即使我们在开发时使用各种工具、措施来避免Crash的发生,但Crash还是不可避免。线上某些怪异的Crash发生后,我们除了分析Crash堆栈信息之外,还可以使用本地日志等工具来还原Crash发生时的场景,帮助开发同学定位问题,但是这两种方式都有它们各自的问题。
可以通过改造本地日志,实现记录的日志等到发生指定类型的Crash时才上报,这样一来可以减少日志服务器压力,同时也可以极大提高定位问题的效率,因为我们可以确定上报日志的设备最后都真正发生了该类型Crash,再来分析日志就可以做到事半功倍。
总结
Crash作为App最重要的指标之一,如何才能保证我们在Crash治理之路上离目标越来越近呢?
团队需要长期从工作中遇到的一个个Crash个例,去探究每一个Crash发生的最本质原因,找到最合理解决这类Crash的方案,并且建立解决这一类Crash的长效保护机制。只有这样,随着版本的不断迭代,APP的用户体验才能越来越好。