如何建立iOS crash防护机制

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容