美文网首页iOS Crash防护iOS常用
如何建立iOS crash防护机制

如何建立iOS crash防护机制

作者: 帅得不太明显 | 来源:发表于2021-04-01 10:04 被阅读0次

    1、前言

      闪退,指用户在使用app的过程中异常中断执行,被系统强制结束应用并回到桌面。不仅内存信息丢失,还会阻断用户操作流程,对业务影响及其严重。
      当然,避免崩溃的最好办法就是不产生崩溃;在开发过各中就要尽可能地保证程序的健壮性。但是,人又不是机器,不可能不犯错,不可能存在没有BUG的程序。
      如果能够利用一些语言机制和系统方法,设计一套防护系统,使之能够有效的降低APP的崩溃率,那么不仅APP的稳定性得到了保障,最重要的是可以减少不必要的加班。
      当然我们不可能强大到把所有类型的crash都处理掉,但是我们会对一些高频的crash进行一一的处理,我们的目的就是降低crash率。

    2、对RunTime的初识

      C语言作为一门静态类语言,在编译阶段就已经确定了所有变量的数据类型,同时也确定好了要调用的函数,以及函数的实现。
       而Objective-C语言是一门动态语言。在编译阶段并不知道变量的具体数据类型,也不知道所真正调用的哪个函数;在编译阶段,OC可以调用任何函数,即使这个函数只声明未实现,或直接未声明(performSelector:);只有在运行时间才检查变量的数据类型,同时在运行时才会根据函数名查找要调用的具体函数。这样在程序没运行的时候,我们并不知道调用一个方法具体会发生什么。
      实现Objective-C语言运行时机制的一切基础就是RuntimeRuntime实际上是一个库,这个库使我们可以在程序运行时动态的创建对象、检查对象,修改类和对象的方法。

    3、消息机制的基本原理

       Objective-C语言中,方法调用都是类似[receiver selector];的形式,其本质就是让对象在运行时发送消息的过程。
    我们来看看方法调用[receiver selector];『编译阶段』『运行阶段』分别做了什么?

    1. 编译阶段:[receiver selector];方法被编译器转换为:
       1.1. objc_msgSend(receiver,selector) (不带参数)
       1.2. objc_msgSend(recevier,selector,org1,org2,…)(带参数)

    OC代码示例:

     id num = @123;
     NSMutableString *str = [NSMutableString stringWithString:@"Hello"];
     [str appendString:@"World"];
    

    通过clang命令查看OC代码编译后的样子:

    //xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp
    id num = ((NSNumber *(*)(Class, SEL, int))(void *)objc_msgSend)(objc_getClass("NSNumber"), sel_registerName("numberWithInt:"), 123);
    NSMutableString *str = ((NSMutableString * _Nonnull (*)(id, SEL, NSString * _Nonnull))(void *)objc_msgSend)((id)objc_getClass("NSMutableString"), sel_registerName("stringWithString:"), (NSString *)&__NSConstantStringImpl__var_folders_zh_hdyn1j_92zn2n74srg5dq88c0000gp_T_main_0eed7d_mi_1);
    ((void (*)(id, SEL, NSString * _Nonnull))(void *)objc_msgSend)((id)str, sel_registerName("appendString:"), (NSString *)&__NSConstantStringImpl__var_folders_zh_hdyn1j_92zn2n74srg5dq88c0000gp_T_main_0eed7d_mi_2);
    
    1. 运行时阶段:消息接受者recevier寻找对应的selector
      2.1、通过recevierisa指针找到recevierClass(类)
      2.2、在Class(类)cache(方法缓存)的散列表中寻找对应的IMP(方法实现)
      2.3、如果在cache(方法缓存)中没有找到对应的IMP(方法实现)的话,就继续在Class(类)method list(方法列表)中找对应的selector,如果找到,填充到cache(方法缓存)中,并返回selector
      2.4、如果在Class(类)中没有找到这个selector,就继续在它的 superClass(父类)中寻找,以此类推,一直查找到根类;
      2.5、一旦找到对应的selector,直接执行recevier对应selector方法实现的 IMP(方法实现)
      2.6、若找不到对应的selector,转向拦截调用,走消息转发;如果没有重写拦截调用的方法,程序就会崩溃。

    4、防护原理

       利用Objective-C语言的动态特性,采用AOP(Aspect Oriented Programming)面向切面编程的设计思想,在不侵入原有项目代码的基础之上,通过在 APP 运行时阶段对崩溃因素的的拦截和处理,使得 APP 能够持续稳定正常的运行。
       具象的说,就是对需要Hook的类添加Category(分类),在各个分类中通过Method Swizzling拦截容易造成崩溃的系统方法,将系统原有方法与添加的防护方法的selector(方法选择器)IMP(函数实现指针)进行对调。然后在替换方法中添加防护操作,从而达到避免以及修复崩溃的目的。

    5、防护系统可以处理掉哪几种crash类型?

    • unrecognized selector(找不到对象方法或者类方法的实现)
    • KVO Crash
    • KVC Crash
    • NSNotification Crash
    • NSTimer Crash (注册了没有主动释放会内存泄露,甚至在定时任务触发时可能会导致crash)
    • Container / NSString Crash(集合类操作造成的崩溃,例如数组越界,插入 nil 等)
    • Threading Crash (非主线程刷新UI)

    5.1 unrecognized selector类型crash防护

       unrecognized selector类型的crash在app众多的crash类型中占着比较大的成分,通常是因为一个对象调用了一个不属于它方法的方法导致的。
    部分复现代码:

      //.h中声明了,但是没有实现此方法
     [self gg];
     //performSelector可以向一个对象传递任何消息,而不需要在编译的时候声明这些方法
     [self performSelector:@selector(gg1)];
     [self performSelector:NSSelectorFromString(@"gg2:")];
      //id能代表任意类型对象,在编译期会跳过类型检查
      id str = @123;
      [str appendString:@"Hello World"];
      //类型不匹配导致崩溃
      [self gg3:@123];
    - (void)gg3:(NSString *)str
    {
        //没有对参数进行类型判断
        NSInteger length = str.length;
    }
    

       在3、消息机制的基本原理 最后一步中有提到:若找不到对应的selector,转向拦截调用,走消息转发;如果没有重写拦截调用的方法,程序就会崩溃
       当一个方法找不到的时候,Runtime 提供了消息动态解析消息接受者重定向消息重定向等三步处理消息,具体流程如下:

    图解:


    RunTime消息转发步骤图.png

    消息动态解析Objective-C运行时会调用+resolveInstanceMethod:或者 +resolveClassMethod:,让你有机会提供一个函数实现。我们可以通过重写这两个方法,添加其他函数实现,并返回 YES, 那运行时系统就会重新启动一次消息发送的过程。若返回NO或者没有添加其他函数实现,则进入下一步。

    举个例子:
    #import "ViewController.h"
    #include <objc/runtime.h>
    
    @interface ViewController ()
    @end
    
    @implementation ViewController
    - (void)viewDidLoad {
        [super viewDidLoad];
        // 执行 fun 函数
        [self performSelector:@selector(fun)];
    }
    
    // 重写 resolveInstanceMethod: 添加对象方法实现
    + (BOOL)resolveInstanceMethod:(SEL)sel {
        if (sel == @selector(fun)) { // 如果是执行 fun 函数,就动态解析,指定新的 IMP
            //特殊参数:v@:,具体可参考官方文档:https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html#//apple_ref/doc/uid/TP40008048-CH100
            class_addMethod([self class], sel, (IMP)funMethod, "v@:");
            return YES;
        }
        return [super resolveInstanceMethod:sel];
    }
    
    void funMethod(id obj, SEL _cmd) {
        NSLog(@"funMethod"); //新的 fun 函数
    }
    
    控制台打印结果:
    2021-03-30 16:50:06.145815+0800 CrashKiller_Example[22017:4869725] funMethod
    
    从上边例子中可以看出,虽然我们没有实现 fun 方法,但是通过重写resolveInstanceMethod: ,
    利用 class_addMethod 方法添加对象方法实现 funMethod 方法,并执行。
    从打印结果来看,成功调起了funMethod 方法。
    

    消息接受者重定向:如果当前对象实现了forwardingTargetForSelector:Runtime就会调用这个方法,允许我们将消息的接受者转发给其他对象。如果这一步方法返回 nil,则进入下一步。

    举个例子:
    #import "ViewController.h"
    #include "objc/runtime.h"
    
    @interface Person : NSObject
    
    - (void)fun;
    
    @end
    
    @implementation Person
    
    - (void)fun {
        NSLog(@"fun");
    }
    
    @end
    
    @interface ViewController ()
    
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        // 执行 fun 方法
        [self performSelector:@selector(fun)];
    }
    
    + (BOOL)resolveInstanceMethod:(SEL)sel {
        return YES; // 为了进行下一步 消息接受者重定向
    }
    
    // 消息接受者重定向
    - (id)forwardingTargetForSelector:(SEL)aSelector {
        if (aSelector == @selector(fun)) {
            return [[Person alloc] init];
            // 返回 Person 对象,让 Person 对象接收这个消息
        }
        return [super forwardingTargetForSelector:aSelector];
    }
    控制台打印结果:
    2021-03-30 17:10:52.645582+0800 CrashKiller_Example[22427:4885650] fun
    
    可以看到,虽然当前 ViewController 没有实现 fun 方法+resolveInstanceMethod:也没有添加其他函数实现。
    但是我们通过 forwardingTargetForSelector 把当前 ViewController 的方法转发给了 Person 对象去执行了。
    打印结果也证明我们成功实现了转发。
    

    消息重定向Runtime系统利用methodSignatureForSelector:方法获取函数的参数和返回值类型。

    • 如果methodSignatureForSelector:返回了一个NSMethodSignature对象(函数签名),Runtime系统就会创建一个NSInvocation对象,并通过forwardInvocation: 消息通知当前对象,给予此次消息发送最后一次寻找IMP的机会。
    • 如果methodSignatureForSelector:返回 nil。则Runtime系统会发出 doesNotRecognizeSelector:消息,程序也就崩溃了。
    举个例子:
    #import "ViewController.h"
    #include "objc/runtime.h"
    
    @interface Person : NSObject
    
    - (void)fun;
    
    @end
    
    @implementation Person
    
    - (void)fun {
        NSLog(@"fun");
    }
    
    @end
    
    @interface ViewController ()
    
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        // 执行 fun 函数
        [self performSelector:@selector(fun)];
    }
    
    + (BOOL)resolveInstanceMethod:(SEL)sel {
        return YES; // 为了进行下一步 消息接受者重定向
    }
    
    - (id)forwardingTargetForSelector:(SEL)aSelector {
        return nil; // 为了进行下一步 消息重定向
    }
    
    // 获取函数的参数和返回值类型,返回签名
    - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
        if ([NSStringFromSelector(aSelector) isEqualToString:@"fun"]) {
            return [NSMethodSignature signatureWithObjCTypes:"v@:"];
        }
        return [super methodSignatureForSelector:aSelector];
    }
    
    // 消息重定向
    - (void)forwardInvocation:(NSInvocation *)anInvocation {
        SEL sel = anInvocation.selector;   // 从 anInvocation 中获取消息
        Person *p = [[Person alloc] init];
        if([p respondsToSelector:sel]) {   // 判断 Person 对象方法是否可以响应 sel
            [anInvocation invokeWithTarget:p];  // 若可以响应,则将消息转发给其他对象处理
        } else {
            [self doesNotRecognizeSelector:sel];  // 若仍然无法响应,则报错:找不到响应方法
        }
    }
    @end
    
    控制台打印结果:
    2021-03-30 17:26:26.719413+0800 CrashKiller_Example[22715:4896625] fun
    

    从补救三部曲中,选择哪一步作为入手点最合适?
       1. resolveInstanceMethod需要在类的本身动态的添加它本身不存在的方法,这些方法对于该类本身来说是冗余的
       2. forwardInvocation可以通过NSInvocation的形式将消息转发给多个对象,但是其开销比较大,需要创建新的NSInvocation对象,并且forwardInvocation的函数经常被使用者调用来做消息的转发选择机制,不适合多次重写。
       3. forwardingTargetForSelector可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写。
    具体步骤如下:
       1. 给NSObject添加一个分类,在分类中实现一个自定义的-crashKiller_forwardingTargetForSelector:方法;
       2. 利用Method Swizzling-forwardingTargetForSelector:-crashKiller_forwardingTargetForSelector:进行方法交换。
       3. 在自定义的方法中,先判断当前对象是否已经实现了消息接受者重定向消息重定向。如果都没有实现,就动态创建一个目标类,给目标类动态添加一个方法。
       4. 把消息转发给动态生成类的实例对象,由目标类动态创建的方法实现,这样 APP 就不会崩溃了。
    实现代码如下:

    #import "NSObject+KillSelector.h"
    #import "NSObject+CrashKillerMethodSwizzling.h"
    
    @implementation NSObject (KillSelector)
    
    + (void)registerKillSelector
    {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
    
            // 拦截 `+forwardingTargetForSelector:` 方法,替换自定义实现
            [NSObject crashKillerMethodSwizzlingClassMethod:@selector(forwardingTargetForSelector:)
                                                 withMethod:@selector(crashKiller_forwardingTargetForSelector:)
                                                  withClass:[NSObject class]];
    
            // 拦截 `-forwardingTargetForSelector:` 方法,替换自定义实现
            [NSObject crashKillerMethodSwizzlingInstanceMethod:@selector(forwardingTargetForSelector:)
                                                    withMethod:@selector(crashKiller_forwardingTargetForSelector:)
                                                     withClass:[NSObject class]];
    
        });
    }
    
    
    + (id)crashKiller_forwardingTargetForSelector:(SEL)aSelector {
    
        if ([[CrashKillerManager shareManager].selectorClassWhiteList containsObject:[self class]]) {
            return [self crashKiller_forwardingTargetForSelector:aSelector];
        }
        SEL forwarding_sel = @selector(forwardingTargetForSelector:);
        // 获取 NSObject 的消息转发方法
        Method root_forwarding_method = class_getClassMethod([NSObject class], forwarding_sel);
        // 获取 当前类 的消息转发方法
        Method current_forwarding_method = class_getClassMethod([self class], forwarding_sel);
        // 判断当前类本身是否实现第二步:消息接受者重定向
        BOOL realize = method_getImplementation(current_forwarding_method) != method_getImplementation(root_forwarding_method);
        // 如果没有实现第二步:消息接受者重定向
        if (!realize) {
            // 判断有没有实现第三步:消息重定向
            SEL methodSignature_sel = @selector(methodSignatureForSelector:);
            Method root_methodSignature_method = class_getClassMethod([NSObject class], methodSignature_sel);
    
            Method current_methodSignature_method = class_getClassMethod([self class], methodSignature_sel);
            realize = method_getImplementation(current_methodSignature_method) != method_getImplementation(root_methodSignature_method);
    
            // 如果没有实现第三步:消息重定向
            if (!realize) {
                // 创建一个新类
                NSString *errClassName = NSStringFromClass([self class]);
                NSString *errSel = NSStringFromSelector(aSelector);
    
                NSString *reason = [NSString stringWithFormat:@"-[%@ %@]: unrecognized selector sent to class %p",errClassName,errSel,self];
                [[CrashKillerManager shareManager] throwExceptionWithName:@"NSInvalidArgumentException" reason:reason];
    
                NSString *className = @"CrachClass";
                Class cls = NSClassFromString(className);
    
                // 如果类不存在 动态创建一个类
                if (!cls) {
                    Class superClsss = [NSObject class];
                    cls = objc_allocateClassPair(superClsss, className.UTF8String, 0);
                    // 注册类
                    objc_registerClassPair(cls);
                }
                // 如果类没有对应的方法,则动态添加一个
                if (!class_getInstanceMethod(NSClassFromString(className), aSelector)) {
                    class_addMethod(cls, aSelector, (IMP)CrashIMP, "@@:@");
                }
                // 把消息转发到当前动态生成类的实例对象上
                return [[cls alloc] init];
            }
        }
        return [self crashKiller_forwardingTargetForSelector:aSelector];
    }
    
    - (id)crashKiller_forwardingTargetForSelector:(SEL)aSelector {
    
        if ([[CrashKillerManager shareManager].selectorClassWhiteList containsObject:[self class]]) {
            return [self crashKiller_forwardingTargetForSelector:aSelector];
        }
        SEL forwarding_sel = @selector(forwardingTargetForSelector:);
        // 获取 NSObject 的消息转发方法
        Method root_forwarding_method = class_getInstanceMethod([NSObject class], forwarding_sel);
        // 获取 当前类 的消息转发方法
        Method current_forwarding_method = class_getInstanceMethod([self class], forwarding_sel);
        // 判断当前类本身是否实现第二步:消息接受者重定向
        BOOL realize = method_getImplementation(current_forwarding_method) != method_getImplementation(root_forwarding_method);
    
        // 如果没有实现第二步:消息接受者重定向
        if (!realize) {
            // 判断有没有实现第三步:消息重定向
            SEL methodSignature_sel = @selector(methodSignatureForSelector:);
            Method root_methodSignature_method = class_getInstanceMethod([NSObject class], methodSignature_sel);
    
            Method current_methodSignature_method = class_getInstanceMethod([self class], methodSignature_sel);
            realize = method_getImplementation(current_methodSignature_method) != method_getImplementation(root_methodSignature_method);
    
            // 如果没有实现第三步:消息重定向
            if (!realize) {
                // 创建一个新类
                NSString *errClassName = NSStringFromClass([self class]);
                NSString *errSel = NSStringFromSelector(aSelector);
    
                NSString *reason = [NSString stringWithFormat:@"-[%@ %@]: unrecognized selector sent to instance %p",errClassName,errSel,self];
                [[CrashKillerManager shareManager] throwExceptionWithName:@"NSInvalidArgumentException" reason:reason];
    
    
                NSString *className = @"CrachClass";
                Class cls = NSClassFromString(className);
                // 如果类不存在 动态创建一个类
                if (!cls) {
                    Class superClsss = [NSObject class];
                    cls = objc_allocateClassPair(superClsss, className.UTF8String, 0);
                    // 注册类
                    objc_registerClassPair(cls);
                }
                // 如果类没有对应的方法,则动态添加一个
                if (!class_getInstanceMethod(NSClassFromString(className), aSelector)) {
                    class_addMethod(cls, aSelector, (IMP)CrashIMP, "@@:@");
                }
                // 把消息转发到当前动态生成类的实例对象上
                return [[cls alloc] init];
            }
        }
        return [self crashKiller_forwardingTargetForSelector:aSelector];
    }
    
    // 动态添加的方法实现
    static int CrashIMP(id slf, SEL selector) {
        return 0;
    }
    @end
    
    

    5.2 KVO Crash类型crash防护

    KVO(Key-Value Observing),它提供一种机制,当指定的对象的属性被修改后,KVO就会自动通知相应的观察者。
    通常一个对象的KVO关系图如下:

    对象的kvo关系图.jpg
        [self.wkWebView addObserver:self
                          forKeyPath:@"estimatedProgress"
                             options:NSKeyValueObservingOptionNew
                             context:nil];
        [self.wkWebView addObserver:self
                          forKeyPath:@"title"
                             options:NSKeyValueObservingOptionNew
                             context:nil];
    

    KVO Crash常见原因:
      1. KVO 添加次数和移除次数不匹配:
        • 移除未注册的观察者,导致崩溃
        • 重复移除多次,移除次数多于添加次数,导致崩溃
        • 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
      2. 被观察者提前被释放,被观察者在 dealloc 时仍然注册着 KVO,导致崩溃。 例如:被观察者是局部变量的情况(iOS 10 及之前会崩溃)。
      3. 添加了观察者,但未实现 observeValueForKeyPath:ofObject:change:context: 方法,导致崩溃。
      4. 添加或者移除时 keypath == nil,导致崩溃。

    解决方案:
      1. 首先为 NSObject 建立一个分类,利用 Method Swizzling,实现自定义的 crashKiller_addObserver:forKeyPath:options:context:crashKiller_removeObserver:forKeyPath:
    crashKiller_removeObserver:forKeyPath:context:
    crashKiller_dealloc方法,用来替换系统原生的添加移除观察者方法的实现。
      2. 然后在观察者和被观察者之间建立一个 KVOProxy对象,两者之间通过KVOProxy对象建立联系。然后在添加和移除操作时,将 KVO 的相关信息例如 observer、keyPath保存进关系哈希表kvoInfoMap中。 关系哈希表的数据结构:{keypath : observer1, observer2 , ...}
      3. 在添加和移除操作的时候,利用KVOProxy对象做转发,把真正的观察者变为 KVOProxy对象,而当被观察者的特定属性发生了改变,再由 KVOProxy对象 分发到原有的观察者上。

    添加观察者时: 通过关系哈希表判断是否重复添加,只添加一次。
    移除观察者时: 通过关系哈希表是否已经进行过移除操作,避免多次移除。
    观察键值改变时: 同样通过关系哈希表判断,将改变操作分发到原有的观察者上。
      另外,为了避免被观察者提前被释放,被观察者在 dealloc 时仍然注册着 KVO 导致崩溃。防护系统还利用 Method Swizzling 实现了自定义的 dealloc,在系统 dealloc 调用之前,将多余的观察者移除掉。

    KVO崩溃测试代码:

     /**
     1.1 移除了未注册的观察者,导致崩溃
     */
    - (void)testKVOCrash11 {
        // 崩溃日志:Cannot remove an observer XXX for the key path "xxx" from XXX because it is not registered as an observer.
        [self.objc removeObserver:self forKeyPath:@"name"];
    }
    
    /**
     1.2 重复移除多次,移除次数多于添加次数,导致崩溃
     */
    - (void)testKVOCrash12 {
        // 崩溃日志:Cannot remove an observer XXX for the key path "xxx" from XXX because it is not registered as an observer.
        [self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
        self.objc.name = @"0";
        [self.objc removeObserver:self forKeyPath:@"name"];
        [self.objc removeObserver:self forKeyPath:@"name"];
    }
    
    /**
     1.3 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
     */
    - (void)testKVOCrash13 {
        [self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
        [self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
        self.objc.name = @"0";
    }
    
    /**
     2. 被观察者 dealloc 时仍然注册着 KVO,导致崩溃
     */
    - (void)testKVOCrash2 {
        // 崩溃日志:An instance xxx of class xxx was deallocated while key value observers were still registered with it.
        // iOS 10 及以下会导致崩溃,iOS 11 之后就不会崩溃了
        CrashObject *obj = [[CrashObject alloc] init];
        [obj addObserver: self
              forKeyPath: @"name"
                 options: NSKeyValueObservingOptionNew
                 context: nil];
        obj = nil;
    }
    
    /**
     3. 观察者没有实现 -observeValueForKeyPath:ofObject:change:context:导致崩溃
     */
    - (void)testKVOCrash3 {
        // 崩溃日志:An -observeValueForKeyPath:ofObject:change:context: message was received but not handled.
        CrashObject *obj = [[CrashObject alloc] init];
    
        [self addObserver: obj
               forKeyPath: @"title"
                  options: NSKeyValueObservingOptionNew
                  context: nil];
        self.title = @"111";
    }
    
    /**
     4. 添加或者移除时 keypath == nil,导致崩溃。
     */
    - (void)testKVOCrash4 {
        // 崩溃日志: -[__NSCFConstantString characterAtIndex:]: Range or index out of bounds
        CrashObject *obj = [[CrashObject alloc] init];
        [self addObserver: obj
               forKeyPath: @""
                  options: NSKeyValueObservingOptionNew
                  context: nil];
    
            [self removeObserver:obj forKeyPath:@""];
    }
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary*)change context:(void *)context {
    
        NSLog(@"object = %@, keyPath = %@", object, keyPath);
    }
    

    5.3 KVC Crash类型crash防护

       KVC(Key Value Coding),即键值编码,提供一种机制来间接访问对象的属性。而不是通过调用SetterGetter方法进行访问。
    KVC 日常使用造成崩溃的原因通常有以下几个:
      1. key 不是对象的属性,造成崩溃。
      2. keyPath 不正确,造成崩溃。
      3. key 为 nil,造成崩溃。
      4. value 为 nil,为非对象设值,造成崩溃。
    常见的使用 KVC 造成崩溃代码:

    /********************* CrashObject.h 文件 *********************/
    #import <Foundation/Foundation.h>
    @interface CrashObject : NSObject
    @property (nonatomic, copy) NSString *name;
    @end
    
    /********************* ViewController.m 文件 *********************/
    #import "ViewController.h"
    #import "CrashObject.h"
    @interface ViewController ()
    @end
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
    //   1. key 不是对象的属性,造成崩溃
    //    [self testKVCCrash1];
    //    2. keyPath 不正确,造成崩溃
    //    [self testKVCCrash2];
    //    3. key 为 nil,造成崩溃
    //    [self testKVCCrash4];
    //    4. value 为 nil,为非对象设值,造成崩溃
    //    [self testKVCCrash4];
    }
    /**
     1. key 不是对象的属性,造成崩溃
     */
    - (void)testKVCCrash1 {
        // 崩溃日志:[<KVCCrashObject 0x600000d48ee0> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key XXX.;
    
        CrashObject *objc = [[CrashObject alloc] init];
        [objc setValue:@"value" forKey:@"address"];
    }
    
    /**
     2. keyPath 不正确,造成崩溃
     */
    - (void)testKVCCrash2 {
        // 崩溃日志:[<KVCCrashObject 0x60000289afb0> valueForUndefinedKey:]: this class is not key value coding-compliant for the key XXX.
        CrashObject *objc = [[CrashObject alloc] init];
        [objc setValue:@"后厂村路" forKeyPath:@"address.street"];
    }
    /**
     3. key 为 nil,造成崩溃
     */
    - (void)testKVCCrash3 {
        // 崩溃日志:'-[KVCCrashObject setValue:forKey:]: attempt to set a value for a nil key
        NSString *keyName;
        // key 为 nil 会崩溃,如果传 nil 会提示警告,传空变量则不会提示警告
        CrashObject *objc = [[CrashObject alloc] init];
        [objc setValue:@"value" forKey:keyName];
    }
    /**
     4. value 为 nil,造成崩溃
     */
    - (void)testKVCCrash4 {
        // 崩溃日志:[<KVCCrashObject 0x6000028a6780> setNilValueForKey]: could not set nil as the value for the key XXX.
        // value 为 nil 会崩溃
        CrashObject *objc = [[CrashObject alloc] init];
        [objc setValue:nil forKey:@"name"];
    }
    @end
    

    KVC的setter和getter方法
    Setter方法
      系统在执行setValue:forKey:方法时,会把keyvalue作为输入参数,并尝试在接收调用对象的内部,给属性key设置value值。
    通过以下几个步骤:

    `官方说明请查看 NSKeyValueCoding.h 文件`
    1. 按顺序查找名为  `set<Key>: `、`_set<Key>:`、 `setIs<Key>:`方法。如果找到方法,则执行该方法,使用输入参数设置变量,则`setValue:forKey:`完成执行。如果没找到方法,则执行下一步。
    2. 访问类的`accessInstanceVariablesDirectly`属性。如果    `accessInstanceVariablesDirectly`属性返回**YES**,就按顺序查找名为   `_<key>`、`_is<Key>`、`<key>`、`is<Key>`的实例变量,如果找到了对应的实例变量,则使用输入参数设置变量。则`setValue:forKey:`完成执行。如果未找到对应的实例变量,或者`accessInstanceVariablesDirectly`属性返回**NO**则执行下一步。
    3. 调用`setValue: forUndefinedKey:`方法,并引发崩溃。
    
    • 相关代码:
    CrashObject *objc = [[CrashObject alloc] init];
    [objc setValue:@"value" forKey:@"name"];
    

    Getter方法
      系统在执行valueForKey:方法时,会将给定的key作为输入参数,在调用对象的内部进行以下几个步骤:
      1. 按顺序查找名为get<Key><key>is<Key>_<key>的访问方法。如果找到,调用该方法,并继续执行步骤 5。否则继续向下执行步骤 2。
      2. 搜索形如countOf<Key>objectIn<Key>AtIndex:<key>AtIndexes:的方法。
      3. 如果实现了countOf<Key>方法,并且实现了objectIn<Key>AtIndex:<key>AtIndexes:这两个方法的任意一个方法,系统就会以NSArray为父类,动态生成一个类型为NSKeyValueArray的集合类对象,并调用上边的实现方法,将结果直接返回。
        - 如果对象还实现了形如get<Key>:range:的方法,系统也会在必要的时候自动调用。
        - 如果上述操作不成功则继续向下执行步骤 3。
        - 如果上边两步失败,系统就会查找形如countOf<Key>enumeratorOf<Key>memberOf<Key>:的方法。系统会自动生成一个 NSSet类型的集合类对象,该对象响应所有NSSet方法并将结果返回。如果查找失败,则执行步骤 4。
      4. 如果上边三步失败,系统就会访问类的accessInstanceVariablesDirectly方法。
         - 如果返回YES,就按顺序查找名为_<key>_is<Key><key>is<Key>的实例变量。如果找到了对应的实例变量,则直接获取实例变量的值。并继续执行步骤 5。
         - 如果返回NO,或者未找到对应的实例变量,则继续执行步骤 6。
      5. 分为三种情况:
        - 如果检索到的属性值是对象指针,则直接返回结果。
        - 如果检索到的属性值是NSNumber支持的基础数据类型,则将其存储在 NSNumber 实例中并返回该值。
        - 如果检索到的属性值是NSNumber不支持的数据类型,则转换为 NSValue 对象并返回该对象。
      6. 如果一切都失败了,调用valueForUndefinedKey:,并引发崩溃。

    KVC Crash 防护方案
      从 Setter 方法 和 Getter 方法 可以看出:
         - setValue:forKey:执行失败会调用setValue: forUndefinedKey:方法,并引发崩溃。
         - valueForKey:执行失败会调用valueForUndefinedKey:方法,并引发崩溃。
      所以,为了进行 KVC Crash 防护,我们就需要重写setValue: forUndefinedKey:方法和valueForUndefinedKey:方法。重写这两个方法之后,就可以防护1. key不是对象的属性2. keyPath不正确这两种崩溃情况了。
      那么3. key为nil,造成崩溃的情况,该怎么防护呢?
      我们可以利用 Method Swizzling方法,在NSObject的分类中将 setValue:forKey:crashKiller_setValue:forKey:进行方法交换。然后在自定义的方法中,添加对keynil这种类型的判断。
      还有最后一种4. value为nil,为非对象设值,造成崩溃 的情况。
      在NSKeyValueCoding.h文件中,有一个setNilValueForKey:方法。上边的官方注释给了我们答案。
      在调用setValue:forKey:方法时,系统如果查找到名为set<Key>:方法的时候,会去检测value的参数类型,如果参数类型为NSNmber的标量类型或者是 NSValue的结构类型,但是valuenil时,会自动调用 setNilValueForKey:方法。这个方法的默认实现会引发崩溃。
      所以为了防止这种情况导致的崩溃,我们可以通过重写setNilValueForKey:来解决。
      至此,上文提到的 KVC 使用不当造成的四种类型崩溃就都解决了。
    下面我们来看下具体实现代码:

    + (void)registerKillKVC
    {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
    
            [NSObject crashKillerMethodSwizzlingInstanceMethod:@selector(setValue:forKey:)
                                                    withMethod:@selector(crashKiller_setValue:forKey:)
                                                     withClass:[NSObject class]];
        });
    }
    
    - (void)crashKiller_setValue:(id)value forKey:(NSString *)key {
    
        @try {
            [self crashKiller_setValue:value forKey:key];
        } @catch (NSException *exception) {
            [[CrashKillerManager shareManager] printLogWithException:exception];
        }
    }
    
    - (void)setNilValueForKey:(NSString *)key {
    
        NSString *reason = [NSString stringWithFormat:@"[<%@ %p> setValue:forUndefinedKey:]: could not set nil as the value for the key %@. ***",NSStringFromClass([self class]),self,key];
        [[CrashKillerManager shareManager] throwExceptionWithName:@"NSUnknownKeyException" reason:reason];
    }
    
    - (void)setValue:(id)value forUndefinedKey:(NSString *)key {
    
        NSString *reason = [NSString stringWithFormat:@"[<%@ %p> valueForUndefinedKey:]: this class is not key value coding-compliant for the key %@.",NSStringFromClass([self class]),self,key];
        [[CrashKillerManager shareManager] throwExceptionWithName:@"NSUnknownKeyException" reason:reason];
    }
    
    - (nullable id)valueForUndefinedKey:(NSString *)key {
    
        NSString *reason = [NSString stringWithFormat:@"[<%@ %p> valueForUndefinedKey:]: this class is not key value coding-compliant for the key %@.",NSStringFromClass([self class]),self,key];
        [[CrashKillerManager shareManager] throwExceptionWithName:@"NSUnknownKeyException" reason:reason];
        return self;
    }
    

    5.4 NSNotification Crash类型crash防护

       当一个对象添加了notification之后,如果dealloc的时候,仍然持有notification,就会出现NSNotification类型的crash。
       NSNotification类型的crash多产生于程序员写代码时候犯疏忽,在NSNotificationCenter添加一个对象为observer之后,忘记了在对象dealloc的时候移除它。
       所幸的是,苹果在iOS9之后专门针对于这种情况做了处理,所以在iOS9之后,即使开发者没有移除observer,Notification crash也不会再产生了。

    5.5 NSTimer Crash类型crash防护

    NSTimer crash 产生原因:
       在程序开发过程中,大家会经常使用定时任务,但使用NSTimerscheduledTimerWithTimeInterval:target:selector:userInfo:repeats:接口做重复性的定时任务时存在一个问题:NSTimer会强引用target实例,所以需要在合适的时机invalidate定时器,否则就会由于定时器timer强引用target的关系导致target不能被释放,造成内存泄露,甚至在定时任务触发时导致crashcrash的展现形式和具体的target执行的selector有关。
       与此同时,如果NSTimer是无限重复的执行一个任务的话,也有可能导致targetselector一直被重复调用且处于无效状态,对app的CPU,内存等性能方面均是没有必要的浪费。
       所以,很有必要设计出一种方案,可以有效的防护NSTimer的滥用问题。

    NSTimer crash 防护方案:
       上面的分析可见,NSTimer所产生的问题的主要原因是因为其没有再一个合适的时机invalidate,同时还有NSTimer对target的强引用导致的内存泄漏问题。
       那么解决NSTimer的问题的关键点在于以下两点:
          1. NSTimer对其target是否可以不强引用。
          2. 是否找到一个合适的时机,在确定NSTimer已经失效的情况下,让NSTimer自动invalidate。
       关于第一个问题,target的强引用问题,可以用如下图的方案来解决:

    image.png

       在NSTimer和target之间加入一层stubTarget,stubTarget主要做为一个桥接层,负责NSTimer和target之间的通信。同时NSTimer强引用stubTarget,而stubTarget弱引用target,这样target和NSTimer之间的关系也就是弱引用了,意味着target可以自由的释放,从而解决了循环引用的问题。
       上文提到了stubTarget负责NSTimer和target的通信,其具体的实现过程又细分为两大步:
       1. swizzle NSTimer中scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:相关的方法,在新方法中动态创建stubTarget对象,stubTarget对象弱引用持有原有的target,selector,timer,targetClass等properties。然后将原target分发stubTarget上,selector回调函数为stubTarget的fireProxyTimer;
       2. 通过stubTarget的fireProxyTimer:来具体处理回调函数selector的处理和分发,当NSTimer的回调函数fireProxyTimer:被执行的时候,会自动判断原target是否已经被释放,如果释放了,意味着NSTimer已经无效,此时如果还继续调用原有target的selector很有可能会导致crash,而且是没有必要的。所以此时需要将NSTimer invalidate。
       如此一来就做到了NSTimer在合适的时机自动invalidate。

    5.6 Container / NSString 类型crash防护

       这个问题比较简单,就是对一些常用类中会导致崩溃的API进行method swizzling,然后在swizzle的新方法中加入一些条件限制和判断,从而让这些API变的安全。
       目前对以下类进行防范,类中可能导致crash的方法,逐步进行增量扩充。
       NSArray/NSMutableArray
       NSDictionary/NSMutableDictionary
       NSString / NSMutableString

    5.7 非主线程刷UI类型crash防护(UI not on Main Thread)

    初步处理方案就是swizzle UIView类的以下三个方法:

    - (void)setNeedsLayout;
    - (void)setNeedsDisplay;
    - (void)setNeedsDisplayInRect:(CGRect)rect;
    

    在这三个方法调用的时候判断 一下当前的线程,如果不是主线程的话,直接利用dispatch_async(dispatch_get_main_queue(), ^{ //调用原本方法 });
    来将对应的刷新UI的操作转移到主线程。
    考虑到此三个方法调用频率过高,且在开发阶段xcode就会给出提示;故不考虑维护此类异常crah。

    image.png

    6 CrashKiller防护系统设计文档:

    gitlab.mypaas.com.cn_appcloud_cocoapods_CrashKiller.png

    7 CrashKiller崩溃测试用例

    Simulator Screen Recording - iPhone 11 - 2021-09-28 at 11.39.41.gif

    相关文章

      网友评论

        本文标题:如何建立iOS crash防护机制

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