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

原文地址php

前言

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

因此本文介绍的 XXShield 库有两个重要的功能:git

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

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

目前已经实现的功能

  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 在出现没法解析的方法时有三部曲来进行消息转发。 详见Objective-C Runtime 运行时之三:方法与消息编程

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

1 通常适用与 Dynamic 修饰的 Property 2 通常适用与将方法转发至其余对象 3 通常适用与消息能够转发多个对象,能够实现相似多继承或者转发中心的概念。数组

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

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

代码以下服务器

#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. 在添加观察者时

    addObserver

  2. 在移除观察者时

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 开源协议.

相关文章
相关标签/搜索