美文网首页iOS 杂谈寒哥管理的技术专题iOS技术点
iOS-APP-运行时防Crash工具XXShield练就

iOS-APP-运行时防Crash工具XXShield练就

作者: 南栀倾寒 | 来源:发表于2017-11-04 17:46 被阅读1778次

    原文地址

    前言

    正在运行的 APP 突然 Crash,是一件令人不爽的事,会流失用户,影响公司发展,所以 APP 运行时拥有防 Crash 功能能有效降低 Crash 率,提升 APP 稳定性。但是有时候 APP Crash 是应有的表现,我们不让 APPCrash 可能会导致别的逻辑错误,不过我们可以抓取到应用当前的堆栈信息并上传至相关的服务器,分析并修复这些 BUG。

    所以本文介绍的 XXShield 库有两个重要的功能:

    1. 防止Crash
    2. 捕获异常状态下的崩溃信息

    类似的相关技术分析也有 网易iOS App运行时Crash自动防护实践

    目前已经实现的功能

    1. Unrecoginzed Selector Crash
    2. KVO Crash
    3. Container Crash
    4. NSNotification Crash
    5. NSNull Crash
    6. NSTimer Crash
    7. 野指针 Crash

    1 Unrecoginzed Selector Crash

    出现原因

    由于 Objective-C 是动态语言,所有的消息发送都会放在运行时去解析,有时候我们把一个信息传递给了错误的类型,就会导致这个错误。

    解决办法

    Objective-C 在出现无法解析的方法时有三部曲来进行消息转发。
    详见Objective-C Runtime 运行时之三:方法与消息

    1. 动态方法解析
    2. 备用接收者
    3. 完整转发

    1 一般适用与 Dynamic 修饰的 Property
    2 一般适用与将方法转发至其他对象
    3 一般适用与消息可以转发多个对象,可以实现类似多继承或者转发中心的概念。

    这里选择的是方案二,因为三里面用到了 NSInvocation 对象,此对象性能开销较大,而且这种异常如果出现必然频次较高。最适合将消息转发到一个备用者对象上。

    这里新建一个智能转发类。此对象将在其他对象无法解析数据时,返回一个 0 来防止 Crash。返回 0 是因为这个通用的智能转发类做的操作接近向 nil 发送一个消息。

    代码如下

    
    #import <objc/runtime.h>
    
    /**
     default Implement
     @param target trarget
     @param cmd cmd
     @param ... other param
     @return default Implement is zero
     */
    int smartFunction(id target, SEL cmd, ...) {
        return 0;
    }
    
    static BOOL __addMethod(Class clazz, SEL sel) {
        NSString *selName = NSStringFromSelector(sel);
        
        NSMutableString *tmpString = [[NSMutableString alloc] initWithFormat:@"%@", selName];
        
        int count = (int)[tmpString replaceOccurrencesOfString:@":"
                                                    withString:@"_"
                                                       options:NSCaseInsensitiveSearch
                                                         range:NSMakeRange(0, selName.length)];
        
        NSMutableString *val = [[NSMutableString alloc] initWithString:@"i@:"];
        
        for (int i = 0; i < count; i++) {
            [val appendString:@"@"];
        }
        const char *funcTypeEncoding = [val UTF8String];
        return class_addMethod(clazz, sel, (IMP)smartFunction, funcTypeEncoding);
    }
    
    @implementation XXShieldStubObject
    
    + (XXShieldStubObject *)shareInstance {
        static XXShieldStubObject *singleton;
        if (!singleton) {
            static dispatch_once_t onceToken;
            dispatch_once(&onceToken, ^{
                singleton = [XXShieldStubObject new];
            });
        }
        return singleton;
    }
    
    - (BOOL)addFunc:(SEL)sel {
        return __addMethod([XXShieldStubObject class], sel);
    }
    
    + (BOOL)addClassFunc:(SEL)sel {
        Class metaClass = objc_getMetaClass(class_getName([XXShieldStubObject class]));
        return __addMethod(metaClass, sel);
    }
    
    @end
    
    

    我们这里需要 Hook NSObject的 - (id)forwardingTargetForSelector:(SEL)aSelector 方法启动消息转发。
    很多人不知道的是如果想要转发类方法,只需要实现一个同名的类方法即可,虽然在头文件中此方法并未声明。

    
    XXStaticHookClass(NSObject, ProtectFW, id, @selector(forwardingTargetForSelector:), (SEL)aSelector) {
        // 1 如果是NSSNumber 和NSString没找到就是类型不对  切换下类型就好了
        if ([self isKindOfClass:[NSNumber class]] && [NSString instancesRespondToSelector:aSelector]) {
            NSNumber *number = (NSNumber *)self;
            NSString *str = [number stringValue];
            return str;
        } else if ([self isKindOfClass:[NSString class]] && [NSNumber instancesRespondToSelector:aSelector]) {
            NSString *str = (NSString *)self;
            NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
            NSNumber *number = [formatter numberFromString:str];
            return number;
        }
        
        BOOL aBool = [self respondsToSelector:aSelector];
        NSMethodSignature *signatrue = [self methodSignatureForSelector:aSelector];
        
        if (aBool || signatrue) {
            return XXHookOrgin(aSelector);
        } else {
            XXShieldStubObject *stub = [XXShieldStubObject shareInstance];
            [stub addFunc:aSelector];
            
            NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error.target is %@ method is %@, reason : method forword to SmartFunction Object default implement like send message to nil.",
                                [self class], NSStringFromSelector(aSelector)];
            [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeUnrecognizedSelector];
            
            return stub;
        }
    }
    XXStaticHookEnd
    
    

    这里汇报了 Crash 信息,出现消息转发一般是一个 logic 错误,为必须修复的Bug,上报尤为重要。


    2 KVO Crash

    出现原因

    KVOCrash总结下来有以下2大类。

    1. 不匹配的移除和添加关系。
    2. 观察者和被观察者释放的时候没有及时断开观察者关系。

    解决办法

    尼古拉斯赵四说过 : 赵四

    对比到程序世界就是,程序世界没有什么难以解决的问题都是不可以通过抽象层次来解决的,如果有,那就两层。
    纵观程序的架构设计,计算机网络协议分层设计,操作系统内核设计等等都是如此。

    问题1 : 不成对的添加观察者和移除观察者会导致 Crash,以往我们使用 KVO,观察者和被观察者都是直接交互的。这里的设计方案是我们找一个 Proxy 用来做转发, 真正的观察者是 Proxy,被观察者出现了通知信息,由 Proxy 做分发。所以 Proxy 里面要保存一个数据结构 {keypath : [observer1, observer2,...]} 。

    
    @interface XXKVOProxy : NSObject {
        __unsafe_unretained NSObject *_observed;
    }
    
    /**
     {keypath : [ob1,ob2](NSHashTable)}
     */
    @property (nonatomic, strong) NSMutableDictionary<NSString *, NSHashTable<NSObject *> *> *kvoInfoMap;
    
    @end
    
    

    我们需要 Hook NSObject的 �KVO 相关方法。

    
    - (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;
    
    - (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
    
    
    1. 在添加观察者时


      removeObserver

      问题2: 观察者和被观察者释放的时候没有断开观察者关系。
      对于观察者, 既然我们是自己用 Proxy 做的分发,我们自己就需要保存观察者,这里我们简单的使用 NSHashTable 指定指针持有策略为 weak 即可。

      对于被观察者,我们使用 iOS 界的毒瘤-MethodSwizzling
      一文中到的方法。我们在被观察者上绑定一个关联对象,在关联对象的 dealloc 方法中做相关操作即可。

      
      - (void)dealloc {
          @autoreleasepool {
              NSDictionary<NSString *, NSHashTable<NSObject *> *> *kvoinfos =  self.kvoInfoMap.copy;
              for (NSString *keyPath in kvoinfos) {
                  // call original  IMP
                  __xx_hook_orgin_function_removeObserver(_observed,@selector(removeObserver:forKeyPath:),self, keyPath);
              }
          }
      }
      
      

      3 Container Crash

      出现原因

      容器在任何编程语言中都尤为重要,容器是数据的载体,很多容器对容器放空值都做了容错处理。不幸的是 Objective-C 并没有,容器插入了 nil 就会导致 Crash,容器还有另外一个最容易 Crash 的原因就是下标越界。

      解决办法

      常见的容器有 NS(Mutable)Array , NS(Mutable)Dictionary, NSCache 等。我们需要 hook 常见的方法加入检测功能并且捕获堆栈信息上报。

      例如

      
      XXStaticHookClass(NSArray, ProtectCont, id, @selector(objectAtIndex:),(NSUInteger)index) {
      if (self.count == 0) {
          
          NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                              [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
          [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
          return nil;
      }
      
      if (index >= self.count) {
          NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                              [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
          [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
          return nil;
      }
      
      return XXHookOrgin(index);
      }
      XXStaticHookEnd
      
      

      但是需要注意的是 NSArray 是一个 Class Cluster 的抽象父类,所以我们需要 Hook 到我们真正的子类。

      这里给出一个辅助方法,获取一个类的所有直接子类:

      + (NSArray *)findAllOf:(Class)defaultClass {
          
          int count = objc_getClassList(NULL, 0);
          
          if (count <= 0) {
              
              @throw@"Couldn't retrieve Obj-C class-list";
              
              return @[defaultClass];
          }
          
          NSMutableArray *output = @[].mutableCopy;
          
          Class *classes = (Class *) malloc(sizeof(Class) * count);
          
          objc_getClassList(classes, count);
          
          for (int i = 0; i < count; ++i) {
              
              if (defaultClass == class_getSuperclass(classes[i]))//子类
              {
                  [output addObject:classes[i]];
              }
              
          }
          
          free(classes);
          
          return output.copy;
          
      }
      
      // 对于NSarray :
      
      //[NSarray array] 和 @[] 的类型是__NSArray0
      //只有一个元素的数组类型 __NSSingleObjectArrayI,
      // 其他的大部分是//__NSArrayI,
      
      
      
      // 对于NSMutableArray :
      //[NSMutableDictionary dictionary] 和 @[].mutableCopy__NSArrayM
      
      
      
      // 对于NSDictionary: :
      
      //[NSDictionary dictionary];。 @{}; __NSDictionary0
      // 其他一般是  __NSDictionaryI
      
      // 对于NSMutableDictionary: :
      // 一般用到的是 __NSDictionaryM
      

      4 NSNotification Crash

      出现原因

      在 iOS8 及以下的操作系统中添加的观察者一般需要在 dealloc 的时候做移除,如果开发者忘记移除,则在发送通知的时候会导致 Crash,而在 iOS9 上即使移忘记除也无所谓,猜想可能是 iOS9 之后系统将通知中心持有对象由 assign 变为了weak

      解决办法

      所以这里两种解决办法

      1. 类似 KVO 中间加上 Proxy 层,使用 weak 指针来持有对象
      2. 在 dealloc 的时候将未被移除的观察者移除

      这里我们使用 iOS 界的毒瘤-MethodSwizzling
      一文中到的方法。


      5 NSNull Crash

      出现原因

      虽然 Objecttive-C 不允许开发者将 nil 放进容器内,但是另外一个代表用户态 的类 NSNull 却可以放进容器,但令人不爽的是这个类的实例,并不能响应任何方法。

      容器中出现 NSNull 一般是 API 接口返回了含有 null 的 JSON �数据,
      调用方通常将其理解为 NSNumber,NSString,NSDictionary 和 NSArray。 这时开发者如果没有做好防御 一旦对 NSNull 这个类型调用任何方法都会出现 unrecongized selector 错误。

      解决办法

      我们在 NSNull 的转发方法中可以判断�上面的四种类型是否可以解析。如果可以解析直接将其转发给�这几种对象,如果不能则调用父类的默认实现。

      
      XXStaticHookClass(NSNull, ProtectNull, id, @selector(forwardingTargetForSelector:), (SEL) aSelector) {
          static NSArray *sTmpOutput = nil;
          if (sTmpOutput == nil) {
              sTmpOutput = @[@"", @0, @[], @{}];
          }
          
          for (id tmpObj in sTmpOutput) {
              if ([tmpObj respondsToSelector:aSelector]) {
                  return tmpObj;
              }
          }
          return XXHookOrgin(aSelector);
      }
      XXStaticHookEnd
      
      

      6. NSTimer Crash

      出现原因

      在使用 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo 创建定时任务的时候,target� 一般都会持有 timer,timer又会持有 target 对象,在我们没有正确关闭定时器的时候,timer 会一直持有target 导致内存泄漏。

      解决办法

      同 KVO 一样,既然 timer 和 target 直接交互容易出现问题,我们就再找个代理将 target 和 selctor 等信息保存到 Proxy 里,并且是弱引用 target。
      这样避免因为循环引用造成的内存泄漏。然后在触发真正 target 事件的时候如果 target 置为 nil 了这时候手动去关闭定时器。

      
      XXStaticHookMetaClass(NSTimer, ProtectTimer,  NSTimer * ,@selector(scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:),
                            (NSTimeInterval)ti , (id)aTarget, (SEL)aSelector, (id)userInfo, (BOOL)yesOrNo ) {
          if (yesOrNo) {
              NSTimer *timer =  nil ;
              @autoreleasepool {
                  XXTimerProxy *proxy = [XXTimerProxy new];
                  proxy.target = aTarget;
                  proxy.aSelector = aSelector;
                  timer.timerProxy = proxy;
                  timer = XXHookOrgin(ti, proxy, @selector(trigger:), userInfo, yesOrNo);
                  proxy.sourceTimer = timer;
              }
              return  timer;
          }
          return XXHookOrgin(ti, aTarget, aSelector, userInfo, yesOrNo);
      }
      XXStaticHookEnd
      @implementation XXTimerProxy
      
      - (void)trigger:(id)userinfo  {
          id strongTarget = self.target;
          if (strongTarget && ([strongTarget respondsToSelector:self.aSelector])) {
      #pragma clang diagnostic push
      #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
              [strongTarget performSelector:self.aSelector withObject:userinfo];
      #pragma clang diagnostic pop
          } else {
              NSTimer *sourceTimer = self.sourceTimer;
              if (sourceTimer) {
                  [sourceTimer invalidate];
              }
              NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error target is %@ method is %@, reason : an object dealloc not invalidate Timer.",
                                  [self class], NSStringFromSelector(self.aSelector)];
              
              [XXRecord recordFatalWithReason:reason userinfo:nil errorType:(EXXShieldTypeTimer)];
          }
      }
      
      @end
      
      

      7. 野指针 Crash

      出现原因

      一般在单线程条件下使用 ARC 正确的处理引用关系野指针出现的并不频繁, 但是多线程下则不尽然,通常在一个线程中释放了对象,�另外一个线程还没有更新指针状态 后续访问就可能会造成随机性 bug。

      之所以是随机 bug 是因为被回收的内存不一定立马被使用。而且崩溃的位置可能也与原来的逻辑相聚很远,因此收集的堆栈信息也可能是杂乱无章没有什么价值。
      具体的分类请看Bugly整理的脑图。


      x

      更多关于野指针的文章请参考:

      1. 如何定位Obj-C野指针随机Crash(一)
      2. 如何定位Obj-C野指针随机Crash(二)
      3. 如何定位Obj-C野指针随机Crash(三)

      解决办法

      这里我们可以借用系统的NSZombies对象的设计。
      参考buildNSZombie

      解决过程

      1. 建立白名单机制,由于系统的类基本不会出现野指针,而且 hook 所有的类开销较大。所以我们只过滤开发者自定义的类。

      2. hook dealloc 方法 这些需要保护的类我们并不让其释放,而是调用objc_desctructInstance 方法释放实例内部所持有属性的引用和关联对象。

      3. 利用 object_setClass(id,Class) 修改 isa 指针将其指向一个Proxy 对象(类比�系统的 KVO 实现),此 Proxy 实现了一个和前面所说的智能转发类一样的 return 0的函数。

      4. 在 Proxy 对象内的 - (void)forwardInvocation:(NSInvocation *)anInvocation 中收集 Crash 信息。

      5. 缓存的对象是有成本的,我们在缓存对象到达一定数量时候将其释放(object_dispose)。

      存在问题

      1. 延迟释放内存会造成性能浪费,所以默认缓存会造成野指针的Class实例的对象限制是50,超出之后会释放,如果这时候再此触发了刚好释放掉的野指针,还是会造成Crash的,

      2. 建议使用的时候如果近期没有野指针的Crash可以不必开启,如果野指针类型的Crash突然增多,可以考虑在 hot Patch 中开启野指针防护,待收取异常信息之后,再关闭此开关。


      收集信息

      由于希望此库没有任何外部依赖,所以并未实现响应的上报逻辑。使用者如果需要上报信息 只需要自行实现 XXRecordProtocol 即可,然后在开启 SDK 之前将其注册进入 SDK。
      在实现方法里面会接收到 XXShield 内部定义的错误信息。
      开发者无论可以使用诸如 CrashLytics,友盟, bugly等第三库,或者自行 dump堆栈信息都可。

      @protocol XXRecordProtocol <NSObject>
      
      - (void)recordWithReason:(NSError * )reason userInfo:(NSDictionary *)userInfo;
      
      @end
      

      使用方法

      示例工程

      
      git clone git@github.com:ValiantCat/XXShield.git
      cd Example
      pod install 
      open XXShield.xcworkspace
      
      

      Install

          
        pod "XXShield"
          
      

      Usage

      
      /**
       注册汇报中心
       
       @param record 汇报中心
       */
      + (void)registerRecordHandler:(id<XXRecordProtocol>)record;
      
      /**
       注册SDK,默认只要开启就打开防Crash,如果需要DEBUG关闭,请在调用处使用条件编译
       本注册方式不包含EXXShieldTypeDangLingPointer类型
       */
      + (void)registerStabilitySDK;
      
      /**
       本注册方式不包含EXXShieldTypeDangLingPointer类型
       
       @param ability ability
       */
      + (void)registerStabilityWithAbility:(EXXShieldType)ability;
      
      /**
       ///注册EXXShieldTypeDangLingPointer需要传入存储类名的array,暂时请不要传入系统框架类
       
       @param ability ability description
       @param classNames 野指针类列表
       */
      + (void)registerStabilityWithAbility:(EXXShieldType)ability withClassNames:(nonnull NSArray<NSString *> *)classNames;
      
      
      

      ChangeLog

      ChangeLog

      单元测试

      相关的单元测试在示例工程的Test Target下,有兴趣的开发者可以自行查看。并且已经接入 TrivisCI保证了�代码质量。

      �Bug&Feature

      如果有相关的 Bug 请提 Issue

      如果觉得可以扩充新的防护类型,请提 PR 给我。

      作者

      �ValiantCat, 519224747@qq.com
      个人博客
      南栀倾寒的简书

      License

      XXShield 使用 Apache-2.0 开源协议.

    相关文章

      网友评论

      • 愤怒的小懒懒:寒哥,若是这个框架只能防Crash,不能把Crash的日志收集,上传到服务器,有点小遗憾
        愤怒的小懒懒:@南栀倾寒 因为我引入了,日志SDKSetup.m 这个类,造成每次运行就报上面的错
        愤怒的小懒懒:寒哥,我用这个框架和腾讯的bugly 一起使用,一运行就崩溃。0 CoreFoundation 0x000000010d43c1e6 __exceptionPreprocess + 294
        1 libobjc.A.dylib 0x000000010cad1031 objc_exception_throw + 48
        2 CoreFoundation 0x000000010d4b004c __NSFastEnumerationMutationHandler + 124
        3 Foundation 0x000000010c510a4c -[NSConcreteHashTable countByEnumeratingWithState:objects:count:] + 59
        4 XXShield 0x000000010c48c9bc -[XXKVOProxy observeValueForKeyPath:ofObject:change:context:] + 668
        5 Foundation 0x000000010c4e6820 NSKeyValueNotifyObserver + 349
        6 Foundation 0x000000010c4e60d7 NSKeyValueDidChange + 483
        7 Foundation 0x000000010c5c722b -[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:usingBlock:] + 778
        8 Foundation 0x000000010c4e6f2d +[__NSOperationInternal _observeValueForKeyPath:ofObject:changeKind:oldValue:newValue:indexes:context:] + 1742
        9 Foundation 0x000000010c4e57e6 -[__NSOperationInternal _start:] + 1074
        南栀倾寒:@愤怒的小懒懒 可以的
      • 深山问:无法拦截的问题,提了一个Issue,能否提示一下?https://github.com/ValiantCat/XXShield/issues/13
      • Zz7777777:我问下如果crash 是防护了但是我项目如何收集crash呢 我们用的是第三方的友盟收集crash的工具
        南栀倾寒:@HelloKids 用友盟自带的功能去收集
      • child_cool:我试用了一下,感觉不能防止崩溃呀,调用了[XXShieldSDK registerRecordHandler:self];
        [XXShieldSDK registerStabilitySDK]; 这两个方法,崩溃方法是NSArray *array = @[];

        NSLog(@"%@",array[1]);,是我使用的问题还是你这个本身不能防这个
        南栀倾寒:@troubledKid 我跑自己的测试用例是可以的吧 你要不然在一个空工程先测试是否可用
        child_cool:@南栀倾寒 - (void)recordWithReason:(NSError *)reason {
        NSLog(@"%@",reason);
        } 这个方法实现了,协议也遵守了,一样直接闪退的,那个方法根本不走,更奇怪的是腾讯的bugly 也一样不上报,但是可以知道用户登录
        南栀倾寒:你是不是没有实现收集信息的方法
      • ddaa8dae50b0:哪些崩溃是不能被NSSetUncaughtExceptionHandler收集的?
      • Beyond_JG:swift强制解包引起的崩溃怎么解决呢
        南栀倾寒:@Beyond_JG 强制解包就是自己作死
      • 景铭巴巴:网易官方博客 也有篇 类似的文章,我自己也封装了一个SDK,不过最终被架构师否定了
        景铭巴巴:@BeyondChao 不推荐大规模的使用hook
        BeyondChao:那个架构以什么理由否定了?效率,代码质量?
        南栀倾寒:@景铭巴巴 我在文章一开始就说了 网易有类似的 但是我开源了。 大公司的话是不适合用这个的。毕竟代码质量才重要
      • henu_Larva:大咖,引入这个拦截体系的话,会影响友盟统计的崩溃统计功能吗?
        南栀倾寒:@henuColorWolf 刚好你可以用友盟实现堆栈的的抓取 然后上报,虽然是防护Crash,但是也能证明代码是有风险的。
        henu_Larva:@南栀倾寒 好的,谢谢~
        南栀倾寒:@henuColorWolf 友盟是捕获崩溃堆栈的 这个是防护一些可以挽救的crash 不冲突的
      • Purlible:🛋!

      本文标题:iOS-APP-运行时防Crash工具XXShield练就

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