趣谈云集iOS APP的Crash治理之路

趣谈云集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异常。

  1. 未捕获的Objective-C异常

在OC层面(iOS原生库、第三方库出现错误抛出)的异常称为Objective-C Exception异常,iOS开发中常见的OC异常包括以下几种:

(1)NSInvalidArgumentException:非法参数异常

(2)NSRangeException:越界异常

(3)NSInternalInconsistencyException:内部不一致导致出现的异常

(4)NSFileHandleOperationException:磁盘空间不足的异常

(5)NSMallocException:可用内存不足的异常

(6)NSGenericException:通用异常,当没有指定特定类型异常时会抛出通用异常

  1. 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的异常

场景:

  1. 某个自定义的子线程中操作UI;
  2. 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堆栈信息很难直接定位问题。下面是常见的解决思路:

  1. 尝试找到造成Crash的可疑代码,看看是否有特别的API或者调用方式不当导致的,尝试修改代码逻辑来进行规避;
  2. 通过Hook来解决,主要是为了解决系统方法的实现里面没有进行异常判断。Hook是利用runtime来实现更改相应API的行为,需要尝试找到可以Hook的点,一般可以直接交换方法,同时需要注意系统类是类族的情况,要做好兼容性工作。Hook是个高危险操作,一定要做好线上可开关的控制。
  3. 如果通过前两种方式都无法解决的话,那我们只能给苹果开发者官网提交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平台上,任何应用都会非常频繁使用到的功能就是加载图片。本地加载图片一般都会经过三步:

  1. 从磁盘读取原始压缩的图片数据(png/jpeg格式等等)缓存到内存;
  2. CPU解压成未压缩的图片数据 (imageBuffer);
  3. 渲染图片(会生成frameBuffer,帧缓存,最终显示到手机屏幕)。

对于网络图片的加载,我们一般会使用到SDWebImage或者YYWebImage等框架,它们下载图片主要简化流程可以如下所示:

  1. 从网络下载图片源数据,默认放入内存和磁盘缓存中;
  2. 异步解码,解码后的数据放入内存缓存中;
  3. 回调主线程渲染图片;
  4. 内部维护磁盘和内存的cache,支持设置定时过期清理,内存cache的上限等。

从以上所知,加载图片都会经历图片解码的过程,其中占用内存最多的对象大都是CGBitmapContext对象。随着手机屏幕尺寸越来越大,屏幕分辨率也越来越高,为了达到更好的视觉效果,我们往往需要使用大量高清图片,同时也为OOM埋下了祸根。 对于图片内存优化,我们有几个常用的思路:

  • 尽量使用成熟的图片库,比如SDWebImage,图片库会提供很多通用方面的保障,减少人为失误。
  • 超大图加载在一个小的view上,使用苹果推荐的缩略图DownSampling方案即可。
  • 全屏加载大图,通过拖动来查看不同位置图片细节,可以使用苹果的CATiledLayer去加载,滑动时通过指定目标位置,通过映射原图指定位置的部分图片数据解码渲染。
  • 根据实际需要,也就是View尺寸来加载图片,可以在分辨率较低的机型上尽可能少地占用内存。除了常用的本地绘制图片方法之外,我们的图片CDN服务器也支持图片的实时缩放,可以在服务端进行图片缩放处理,从而减轻客户端的内存压力。
  • 分析App内存的详细情况是解决问题的第一步,我们需要对App运行时到底占用了多少内存、哪些类型的对象有多少个有大致了解,并根据实际情况做出预测,这样才能在分析时做到有的放矢。

展望未来


智能化测试平台

在体量百万级以上的的App中几乎不可能实现毫无瑕疵的技术方案和组件,而我们认为Crash对用户来说是最糟糕的体验,尤其是涉及到交易的场景,所以我们必须本着每一单都很重要的原则,尽最大努力保证用户顺利操作流程。为了保障线上APP的稳定性,我们需要APP产品在测试阶段或者灰度阶段,尽最大能力的提前发现问题,以及可跟踪问题。所以要求一套智能化的测试平台,可以实现如下功能:

  1. 将APP中的场景脚本化,并在海量手机上自动执行,从安装、启动、运行、功能、UI等多维度,深度发现并定位APP兼容性问题;
  2. 进行多人次、多维度的探索性功能测试,覆盖真实用户场景,发现隐蔽缺陷;
  3. 可以在短时间内执行大量的重复性测试任务和多终端测试任务,提供7×24小时的服务,提高测试效率和产能,确保App在功能回归、兼容、性能等各方面的可靠性;
  4. 可随时跟踪测试进度,可输出测试评价表,以便逐一验证功能完整性、正确性及适用性。

可自动捞回指定crash类型日志

即使我们在开发时使用各种工具、措施来避免Crash的发生,但Crash还是不可避免。线上某些怪异的Crash发生后,我们除了分析Crash堆栈信息之外,还可以使用本地日志等工具来还原Crash发生时的场景,帮助开发同学定位问题,但是这两种方式都有它们各自的问题。

可以通过改造本地日志,实现记录的日志等到发生指定类型的Crash时才上报,这样一来可以减少日志服务器压力,同时也可以极大提高定位问题的效率,因为我们可以确定上报日志的设备最后都真正发生了该类型Crash,再来分析日志就可以做到事半功倍。

总结


Crash作为App最重要的指标之一,如何才能保证我们在Crash治理之路上离目标越来越近呢?

团队需要长期从工作中遇到的一个个Crash个例,去探究每一个Crash发生的最本质原因,找到最合理解决这类Crash的方案,并且建立解决这一类Crash的长效保护机制。只有这样,随着版本的不断迭代,APP的用户体验才能越来越好。

你可能感兴趣的:(趣谈云集iOS APP的Crash治理之路)