1. 为什么要做 Crash 防护

iOS 开发中,咱们经常会遇到运用溃散的情况,这种情况不仅会影响用户的运用体验,还或许导致用户的数据丢掉,甚至让用户流失。因此,关于开发者来说,做好 Crash 防护作业是非常重要的。经过 Crash 防护,咱们能够使运用在出现反常时不会当即溃散,而是能够正常运行,从而提高运用的稳定性和用户体验。

针对游戏发行 SDK 而言,研发接入的 ObjcC 层因为各种原因(研发对 ObjC 代码不熟悉,参数传 nil,没有做好值类型转换等),简单引起运用溃散,假如问题在线上才露出出来,会极大影响玩家体验和游戏转化。因此 SDK 需要对一些常见的反常溃散场景做好兜底和防护,尽或许降低这类问题带来的影响。

2. Crash 防护原理

iOS 中导致溃散的场景很多,如野指针、数组越界、主线程卡死等等,而咱们本次主要讨论的是 ObjC 运行时的反常导致的溃散。

因为 Objective-C 是一门动态语言,使用 Objective-C 语言的 Runtime 运行时机制,对需要 Hook 的类增加 Category(分类),在各个分类的 +(void)load 中经过 Method Swizzling 拦截简单形成溃散的体系办法,将体系原有办法与增加的防护办法的 selector(办法选择器)与 IMP(函数完成指针)进行交流。然后在替换办法中增加防护操作,从而达到防止以及修正溃散和反常上报的目的。

3. Crash 防护实战

3.1 Method Swizzling

Method Swizzling 是 Objective-C 的一种特性,它允许咱们在运行时改动办法的完成。咱们能够经过 Method Swizzling 来改动原有办法的履行流程,使得在原有办法出现反常时,能够经过咱们自定义的办法来处理这些反常,防止运用溃散。

/// 交流类办法
/// - Parameters:
///  - cls: 类
///  - origSelector: 原办法
///  - newSelector: 交流办法
void swizzleClassMethod(Class cls, SEL origSelector, SEL newSelector)
{
  if (!cls)
    return;
  
  if (![cls respondsToSelector: origSelector])
    return;
  
  Method originalMethod = class_getClassMethod(cls, origSelector);
  Method swizzledMethod = class_getClassMethod(cls, newSelector);
  
  Class metacls = objc_getMetaClass(NSStringFromClass(cls).UTF8String);
  if (class_addMethod(metacls,
            origSelector,
            method_getImplementation(swizzledMethod),
            method_getTypeEncoding(swizzledMethod)) ) {
    class_replaceMethod(metacls,
              newSelector,
              method_getImplementation(originalMethod),
              method_getTypeEncoding(originalMethod));
    
   } else {
    class_replaceMethod(metacls,
              newSelector,
              class_replaceMethod(metacls,
                        origSelector,
                        method_getImplementation(swizzledMethod),
                        method_getTypeEncoding(swizzledMethod)),
              method_getTypeEncoding(originalMethod));
   }
}
​
/// 交流实例办法
/// - Parameters:
///  - cls: 类
///  - origSelector: 原办法
///  - newSelector: 交流办法
void swizzleInstanceMethod(Class cls, SEL origSelector, SEL newSelector)
{
  if (!cls) {
    return;
   }
  if (![cls instancesRespondToSelector: origSelector])
    return;
  
  Method originalMethod = class_getInstanceMethod(cls, origSelector);
  Method swizzledMethod = class_getInstanceMethod(cls, newSelector);
  
  if (class_addMethod(cls,
            origSelector,
            method_getImplementation(swizzledMethod),
            method_getTypeEncoding(swizzledMethod)) ) {
    class_replaceMethod(cls,
              newSelector,
              method_getImplementation(originalMethod),
              method_getTypeEncoding(originalMethod));
    
   } else {
    class_replaceMethod(cls,
              newSelector,
              class_replaceMethod(cls,
                        origSelector,
                        method_getImplementation(swizzledMethod),
                        method_getTypeEncoding(swizzledMethod)),
              method_getTypeEncoding(originalMethod));
   }
}

3.2 常见办法 Hook

  1. 容器类防护

    在 Objective-C 中,最常见的就是在NSDictionary、NSArray等这类容器中刺进 nil 引发溃散,针对这些类咱们能够 hook 对应的办法,参加@try @catch 来捕获 OC 运行时发生的反常。

    以 NSDictionary 和 NSMutableDictionary 防护为例,在创建字典时捕获到反常,过滤掉为 nil 的 key 和 value。针对NSMutableDictionary, 分别在新增和删去键值对的办法中做好反常捕获。最重要的是,需及时上报反常堆栈和做好对应的告警

    @implementation NSDictionary (Safe)
    + (void)load
    {
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
        /* 交流类办法 */
        swizzleClassMethod([self class], @selector(dictionaryWithObjects:forKeys:count:), @selector(hookDictionaryWithObjects:forKeys:count:));
       });
    }
    ​
    + (instancetype) hookDictionaryWithObjects:(const id [])objects forKeys:(const id [])keys count:(NSUInteger)cnt
    {
      id object = nil;
      @try {
        object = [self hookDictionaryWithObjects:objects forKeys:keys count:cnt];
       }
      @catch (NSException *exception) {
        // TODO: 反常上报// 去掉为 nil 的 key/value
        NSUInteger index = 0;
        id _Nonnull __unsafe_unretained newObjects[cnt];
        id _Nonnull __unsafe_unretained newkeys[cnt];
        for (int i = 0; i < cnt; i++) {
          if (objects[i] && keys[i]) {
            newObjects[index] = objects[i];
            newkeys[index] = keys[i];
            index++;
           }
         }
        object = [self hookDictionaryWithObjects:newObjects forKeys:newkeys count:index];
       }
      @finally {
        return object;
       }
    }
    ​
    ​
    @implementation NSMutableDictionary (Safe)
    + (void)load
    {
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
        swizzleInstanceMethod(NSClassFromString(@"__NSDictionaryM"), @selector(setObject:forKey:), @selector(hookSetObject:forKey:));
        swizzleInstanceMethod(NSClassFromString(@"__NSDictionaryM"), @selector(removeObjectForKey:), @selector(hookRemoveObjectForKey:));
        swizzleInstanceMethod(NSClassFromString(@"__NSDictionaryM"), @selector(setObject:forKeyedSubscript:), @selector(hookSetObject:forKeyedSubscript:));
       });
    }
    ​
    - (void) hookSetObject:(id)anObject forKey:(id)aKey {
      @try {
         [self hookSetObject:anObject forKey:aKey];
       }
      @catch (NSException *exception) {
        // TODO: 反常上报
       }
      @finally {
    ​
       }
    }
    ​
    - (void) hookRemoveObjectForKey:(id)aKey {
      @try {
         [self hookRemoveObjectForKey:aKey];
       }
      @catch (NSException *exception) {
        // TODO: 反常上报
       }
      @finally {
    ​
       }
    }
    ​
    - (void)hookSetObject:(id)obj forKeyedSubscript:(id<NSCopying>)key
    {
      @try {
         [self hookSetObject:obj forKeyedSubscript:key];
       }
      @catch (NSException *exception) {
        // TODO: 反常上报
       }
      @finally {
    ​
       }
    }
    @end
    

    同理,针对 NSArray/NSMutableArray,NSSet/NSMutableSet 也可做类似的 hook 和反常捕获

    // NSArray hook 办法
    + (void)load
    {
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
        /* 结构函数 */
        swizzleClassMethod(self, @selector(arrayWithObject:), @selector(hookArrayWithObject:));
        swizzleClassMethod(self, @selector(arrayWithObjects:count:), @selector(hookArrayWithObjects:count:));
        
        /* 没内容类型是__NSArray0 */
        swizzleInstanceMethod(NSClassFromString(@"__NSArray0"), @selector(objectAtIndex:), @selector(hookObjectAtIndex:));
        swizzleInstanceMethod(NSClassFromString(@"__NSArray0"), @selector(subarrayWithRange:), @selector(hookSubarrayWithRange:));
        swizzleInstanceMethod(NSClassFromString(@"__NSArray0"), @selector(objectAtIndexedSubscript:), @selector(hookObjectAtIndexedSubscript:));
        
        /* 有内容类型是__NSArrayI */
        swizzleInstanceMethod(NSClassFromString(@"__NSArrayI"), @selector(objectAtIndex:), @selector(hookObjectAtIndex:));
        swizzleInstanceMethod(NSClassFromString(@"__NSArrayI"), @selector(subarrayWithRange:), @selector(hookSubarrayWithRange:));
        swizzleInstanceMethod(NSClassFromString(@"__NSArrayI"), @selector(objectAtIndexedSubscript:), @selector(hookObjectAtIndexedSubscript:));
        
        
        /* 单个内容类型是__NSSingleObjectArrayI */
        swizzleInstanceMethod(NSClassFromString(@"__NSSingleObjectArrayI"), @selector(objectAtIndex:), @selector(hookObjectAtIndex:));
        swizzleInstanceMethod(NSClassFromString(@"__NSSingleObjectArrayI"), @selector(subarrayWithRange:), @selector(hookSubarrayWithRange:));
        swizzleInstanceMethod(NSClassFromString(@"__NSSingleObjectArrayI"), @selector(objectAtIndexedSubscript:), @selector(hookObjectAtIndexedSubscript:));
       });
    }
    ​
    // hook办法的防护,具体代码就不一一具体列出来了...
    
  2. Unrecognized Selector 溃散防护

    Objc 的音讯转发是一种用于在运行时动态地将音讯转发给其他目标来处理的机制,使用这个机制能够来防护 Unrecognized Selector 这一类的溃散。

    音讯转发根本流程:

    iOS 崩溃防护实战

    1. 当一个目标收到一个不知道的音讯时,它会先调用 +resolveInstanceMethod:+resolveClassMethod: 办法,尝试动态增加办法来处理这个音讯。
    2. 假如动态办法解析无法处理这个音讯,那么它会调用 forwardingTargetForSelector: 办法,尝试将音讯转发给另一个目标来处理。
    3. 假如 forwardingTargetForSelector: 办法也无法处理这个音讯,那么它会调用 methodSignatureForSelector: 办法,获取一个办法签名。假如回来办法签名为 nil,那么它会调用 doesNotRecognizeSelector: 办法,抛出一个反常。
    4. 办法签名不为空,则它会调用 forwardInvocation: 办法,将办法签名和音讯转发给另一个目标来处理。

    具体完成:

    咱们在第 4 步中 methodSignatureForSelector: 获取办法签名,假如办法签名为空,且methodSignatureForSelector:没有被重写过的情况下,给他回来一个默许的办法签名v@:@,最终,咱们在forwardInvocation办法中即可用来上报反常情况

    swizzleInstanceMethod([NSObject class], @selector(methodSignatureForSelector:), @selector(hookMethodSignatureForSelector:));
    swizzleInstanceMethod([NSObject class], @selector(forwardInvocation:), @selector(hookForwardInvocation:));
    ​
    ​
    - (NSMethodSignature*)hookMethodSignatureForSelector:(SEL)aSelector {
      NSMethodSignature* sig = [self hookMethodSignatureForSelector:aSelector];
      if (!sig){
        if (class_getMethodImplementation([NSObject class], @selector(methodSignatureForSelector:))
          != class_getMethodImplementation(self.class, @selector(methodSignatureForSelector:)) ){
          return nil;
         }
        return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
       }
      return sig;
    }
    ​
    ​
    - (void)hookForwardInvocation:(NSInvocation*)invocation
    {
      NSString* info = [NSString stringWithFormat:@"unrecognized selector [%@] sent to %@", NSStringFromSelector(invocation.selector), NSStringFromClass(self.class)];
      NSString *stackString = [[NSThread callStackSymbols] componentsJoinedByString:@"\n"];
      NSLog(@"%@", info);
      NSLog(@"%@", stackString);
      // TODO: 反常上报
    }
    
  3. KVO 溃散防护

    常见的溃散原因:

    • KVO 增加次数和移除次数不匹配;
    • 增加或许移除时 keypath == nil;

    hook addObserver:forKeyPath:options:context:removeObserver:forKeyPath:办法,在设置和移除监听中做空值判别和try catch

    swizzleInstanceMethod([NSObject class], @selector(addObserver:forKeyPath:options:context:), @selector(hookAddObserver:forKeyPath:options:context:));
    swizzleInstanceMethod([NSObject class], @selector(removeObserver:forKeyPath:), @selector(hookRemoveObserver:forKeyPath:));
    ​
    ​
    - (void) hookAddObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context
    {
      if (!observer || !keyPath.length) {
        return;
       }
      @try {
         [self hookAddObserver:observer forKeyPath:keyPath options:options context:context];
       }
      @catch (NSException *exception) {
        // TODO: 反常上报
       }
    }
    ​
    - (void) hookRemoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath
    {
      if (!observer || !keyPath.length) {
        return;
       }
      @try {
         [self hookRemoveObserver:observer forKeyPath:keyPath];
       }
      @catch (NSException *exception) {
        // TODO: 反常上报
       }
    }
    
  4. KVC 溃散防护

    常见的溃散原因:

    1. key 不是目标的属性,形成溃散
    2. 设置的 keyPath 不正确,形成溃散
    3. 设置的 value 为 nil,形成溃散

    经过查看 Xcode 文档,在头文件NSKeyValueCoding.h中的可看到以下办法的注释:

    1. valueForUndefinedKey:

      调用 valueForKey: 获取键值,但 key 不正确时会触发此办法。 此办法的默许完成会引发 NSUndefinedKeyException。

    2. setValue:forUndefinedKey:

      调用 setValue:forKey: 设置键值,但 key 不正确时会触发此办法,此办法的默许完成会引发 NSInvalidArgumentException。

    3. setNilValueForKey:

      调用 setValue:forKey: 设置键值,相应的访问器办法的参数类型是 NSNumber 标量类型或 NSValue 结构类型,但值为 nil 的情况时,会触发此办法。 此办法的默许完成会引发 NSInvalidArgumentException。

    针对以上的三种场景,咱们只需要重写默许完成,即可完成溃散防护:

    - (nullable id)valueForUndefinedKey:(NSString *)key {
     // TODO: 反常上报
    }
    ​
    - (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key {
     // TODO: 反常上报
    }
    ​
    - (void)setNilValueForKey:(NSString *)key {
     // TODO: 反常上报
    }
    

    还有一种场景,当 key == nil时,会触发反常溃散,咱们只需 hook setValue:forKey:办法并对 key 做非空判别即可处理

    swizzleInstanceMethod([NSObject class], @selector(setValue:forKey:), @selector(hookSetValue:forKey:));
    ​
    - (void)hookSetValue:(id)value forKey:(NSString *)key {
      if (key == nil) {
        // TODO: 反常上报
        return;
       }
       [self hookSetValue:value forKey:key];
    }
    

以上咱们针对 OC 中一些比较常见的反常溃散场景,做了相应的反常拦截和防护的措施。当然还有更多的场景,如 NSString、NSData、NSSet 等常用类也可做溃散防护,在这里就不一一列举。

3.3 反常上报与告警

当遇到或许引发溃散的反常时,仅进行基础防护是不满足的。溃散防护作为最终一道防线,单实践的事务逻辑或许已出现反常。因此,咱们的首要任务是建立一套全面的反常陈述和警报机制,并保证及时处理问题。

实时反常告警:

iOS 崩溃防护实战

具体反常日志:

iOS 崩溃防护实战

4. 总结

总结而言,Crash 防护能提升运用稳定性和用户体验。咱们能够使用 Method Swizzling 和 Hook 等技能进行 Crash 防护,调整原有办法的履行流程并增加防护逻辑,以防止运用因反常而溃散。然而,咱们也需警觉这些技能所带来的危险,例如或许使代码流程变得模糊,使问题定位变得困难,尤其是,溃散防护或许会带来事务逻辑反常。因此,及时上报反常并警告机制是至关重要的一步。