美文网首页
趣谈云集iOS APP的Crash治理之路

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

作者: YYSky | 来源:发表于2020-06-30 18:43 被阅读0次

    趣谈云集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治理之路

          本文链接:https://www.haomeiwen.com/subject/mitfqktx.html