美文网首页
iOS崩溃保护方案

iOS崩溃保护方案

作者: 环宇飞杨 | 来源:发表于2020-02-10 18:09 被阅读0次

    前言

    软件在运行时遇到不能理解的异常时会中断执行,iOS系统下给出的解决方案是强制结束应用并回到桌面,该方案不仅丢失内存信息,还会阻断用户操作流程,对业务影响极其严重,所以线上应用当极力避免此类情况,开发过程中应当对iOS系统下会引发崩溃的所有点牢记在心。对于无法预料的编码bug,可通过多种方式接管系统异常,以更温和的、低成本的弹窗或提醒等方案保证用户的其它正常操作。等后续通过排查线上日志定位异常后,再进行修改。

    系统崩溃事件

    • 方法未找到(类或者实例执行方法出错)
    • 空值nil (数组插入nil,setObject值为nil)
    • 下标越界(数组操作,字符串操作)
    • 野指针 (assign修饰非基本数据,和CF框架交互释放错误)
    • KVO观察者未释放 (持有和释放未一一对应)
    • NAN错误
    • 线程异常 (子线程操作UI)
    • 内存暴涨,超出系统阈值 (内存泄漏,递归调用等)

    对应接管方案

    1. iOS系统的方法查找流程

    消息转发流程图.png

    可以看到消息转发阶段有三个接管时机,具体该在哪个时机接管,各有优势,不再赘述。
    此类异常的接管可通过重写消息转发方法来实现:

    • NSObject下增加分类,用于全局替换
    • 新增方法 swizze_xxx 与系统转发方法 xxx 方法交换 (xxx 为消息转发三个时机中的方法)
    • 在新方法中跳过崩溃后,上报异常到服务器。

    2.空值&下标越界

    空值

    先补充下各种空值的意义:

    • nil
      nil具体指的是oc下实例的空指针,nil 调用方法并不会发生异常,但因为nil在容器类中有特殊含义(NSArray,NSDictionary中nil代表结束位),所以使用 addObject 或setObject时 参数为nil时会报被系统当做严重异常。
    • Nil
      类对象的空指针,类对象在OC中是以单例存在的,所以基本不会遇到,无需考虑。
    • Null
      C类型的空指针,暂不考虑
    • NSNull
      OC下的标准空值类型,常见的场景就是与服务器数据交互中,模型中对象类型的属性如果遇到空值就会被重置成NSNull类型,此时如果不注意会出现很多 unrecognized selector 的崩溃,因为对象类型已经变了。

    空值涉及的类有很多,主要包含各种可变容器的空值插入,解决方案为将所有的插入空值会引起崩溃的方法全部替换为新方法。

    //NSArray
    - (void) addObject:(id)anObject;
    - (void) insertObject:(id)anObject atIndex:(NSUInteger)index;
    - (void) replaceObjectAtIndex:(NSUInteger)index withObject:(id)anObject;
    - (void) setObject:(id)object atIndexedSubscript:(NSUInteger)index;
    //NSDictionary
    + (instancetype) dictionaryWithObject:(id)object forKey:(id)key;
    - (void) setObject:(id)anObject forKey:(id)aKey;
    - (void) setObject:(id)object forKeyedSubscript:(id<NSCopying>)key;
    //NSSet
    + (instancetype)setWithObject:(id)object;
    - (void) addObject:(id)object;
    - (void) removeObject:(id)object;
    
    • 新建类并重写 + load 方法。 (+load 方法优先于main函数且只执行一次),或者可以保证整个App生命周期只调用一次即可。
    • 新增对应hook方法。
    • 用runtime 的method swizzing API 进行方法交换。
      例如以下:
    swizzleInstanceMethod([NSArray class], @selector(addObject:), @selector(hookAddObject:));
    
    - (void) hookAddObject:(id)objc {
        if (objc) {
            [self hookAddObject];
        }
        handleCrashException(@"hookAddObject object is nil");
    }
    
    #pragma mark - 方法交换
    - (void)swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector{
        Class class = [self class];
        
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
        
        BOOL didAddMethod = class_addMethod(class,
                                            originalSelector,
                                            method_getImplementation(swizzledMethod),
                                            method_getTypeEncoding(swizzledMethod));
        
        if (didAddMethod) {
            class_replaceMethod(class,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    }
    

    下标越界

    下标越界涉及众多类和API,涉及数组操作,字符串操作,UITableViewCell的添加删除等等。
    好在原理一致,替换对应的方法后在内部做判断即可。

    3.野指针、僵尸对象

    野指针是对象释放过程出现了错误,导致对象失去了控制权,此时的对象就成了僵尸对象,其对应的指针便称作野指针,当继续操作该指针时,其所指向的内存地址可能已经被分配给了其它对象,所以在此时时会产生很多意想不到的错误。

    一个典型的引发野指针的场景就是: 非基本类型的对象用 assign修饰 (常见为delegate属性修饰),因为assign属性的对象不会随着对象释放而释放。常见的判断野指针的方式为:查看日志中是否频繁出现某个类的实例调用了一堆不相关的方法。


    野指针可能出现的问题

    解决方案:
    在ARC模式下,野指针出现的场景已经很少了,非基本类型的对象用assign修饰在编译器就会报错,所以基本只要注意 id类型的属性不要用 assign修饰即可。
    野指针定位很困难,因为僵尸对象何时被分配怎样被分配是很随机的,导致野指针的崩溃完全成了人品问题。
    网上比较高阶的解决方案主要是在开发阶段将野指针暴露出来,尽力提高野指针复现场景,其原理为:hook dealloc 方法,阻止对象正常释放(内存使用会飙升),同时将对应内存地址重写为0x55(不可操作),当野指针再次操作僵尸对象时,因为对应内存已不可操作就会发生崩溃,再通过一些更底层的函数,获取崩溃时调用的堆栈信息,定位到野指针调用的类名和方法,继而通过改代码解决问题。
    过程很复杂,探究的精神很是佩服,但即便这样仍旧不能完全解决问题,首先工作方式会有一些限制,比如内存飙升的问题,解决方式为当占用快满时主动释放掉一些内存,然后让系统重新分配,但属于该片内存的野指针就会被漏诊,所以内存越大的设备加操作更可能出现的业务场景,才能有更多的几率找到野指针。

    4.KVO、NSNotificationCenter

    1. KVO原理

    2.如何引发?为什么会系统被认作是严重错误?
    KVO、通知,造成崩溃的一个共性原因就是持有对象未释放,但看起来这就是普通的内存泄漏问题,为什么会被系统认为是严重错误,为什么循环引用就不认为是严重错误呢?
    3.如何解决
    先明确几个概念,用以下代码举例:

    Person *per = [[Person alloc] init];
    [per addObserver:self
           forKeyPath:@"name"
              options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
              context:nil];
    per.name = @"zhangsan";
    

    1.观察者observer,也就是其中的self,当变化发生时负责接收回调事件。
    2.被监听者per,调用addObserver后自身遍和观察者产生了关联,当观察者dealloc时,如果 per 没有调用过 removeObserver ,就会发生崩溃。
    3.键值keypath,此处为number属性,监听后再修改per.name就会通知到观察者self,当keypath已经被添加过,或者removeObserverFromKeypath时移除了多次都会发生崩溃。

    这里吐槽下KVO的设计,要是局部变量想监听下属性变化必须得改成全局变量,要不然都没法在dealloc中写移除观察者的代码,这不是很坑嘛,其次一个键值被监听后再次添加观察者会崩溃,移除次数不对也会崩溃。那你好歹告知下这个键有没有被监听才是啊,提供个相关属性多好(例如isObserved?),现在好了,搞几个大坑出来,要想正常使用就必须封装下搞两个容器看对象的某个keypath是不是已经被监听过,观察者释放还后要看被监听者是不是被移除了....

    推荐个靠谱封装 https://github.com/facebook/KVOController

    主要原理为:

    • 建立两个容器,一个放置keypath对象,用于判断其添加和移除次数是不是一一对应。另外一个放置被监听对象,用于观察者对象释放时,移除对应的监听对象。
    • 新建类xxx创建实例作为观察者的运行时的associated对象,重写其dealloc方法,这样当观察者对象时,就会执行dealloc,监听对象的移除操作放到此处即可。

    完美。

    通知崩溃在iOS9以上已经没有了,也不过多讨论,再说通知本就已经是单例在全局操作观察者,比KVO更好处理崩溃问题,为毛还要开发者手动移除呢,可能我们作为开发者尤其重视崩溃而苹果作为语言设计者根本就不在乎吧。

    JJException框架内部对于KVO保护的原理颇为复杂,为了实现无感知保护,hook了系统KVO的三个方法

    + (void)jj_swizzleKVOCrash{
        swizzleInstanceMethod([self class], @selector(addObserver:forKeyPath:options:context:), @selector(hookAddObserver:forKeyPath:options:context:));
        swizzleInstanceMethod([self class], @selector(removeObserver:forKeyPath:), @selector(hookRemoveObserver:forKeyPath:));
        swizzleInstanceMethod([self class], @selector(removeObserver:forKeyPath:context:), @selector(hookRemoveObserver:forKeyPath:context:));
    }
    

    内部原理和KVOController框架实现基本一致,但有个弊端是对某些使用系统KVO的第三方有影响,因为内部hook了dealloc方法主动执行了removeObserver操作,无需外部再调用,但第三方的remove操作无法修改,App运行时总是停在@try内部,日常调试很受影响。


    截屏2020-02-12下午4.00.42.png

    5.NaN错误

    这个没有一劳永逸的方案,特别注意算数计算中 除数不能为0
    另外可以通过isnan(x)函数来判断。

    6.线程异常

    • 子线程操作UI

    • 线程锁问题

    7.内存泄漏

    • NSTimer 操作不当导致持续运行
      NSTimer 如果初始化为局部变量,那么和target容易造成循环引用,导致timer不能释放,对应的selector不断执行,很可能引发内存暴涨。
    • 循环引用导致的内存泄漏

    相关文章

      网友评论

          本文标题:iOS崩溃保护方案

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