崩溃防护

935 阅读6分钟

防护类型

参考文章Crash防护

unrecognized selector crash

当消息定义了, 但是没有实现, 也就是SEL没有对应的imp的时候, 消息发送就会报错.

消息流程

需要了解消息转发流程, 大致说一下:

  • + (BOOL)resolveInstanceMethod:(SEL)sel

    • 可以做消息的添加操作
  • - (id)forwardingTargetForSelector:(SEL)aSelector

    • 可以指定消息的接受者
  • + (IMP)instanceMethodForSelector:(SEL)aSelector;

  • - (void)forwardInvocation:(NSInvocation *)anInvocation;

    • aSelector正确的情况下, 实现forwardInvocation就不会报错
  • - (void)doesNotRecognizeSelector:(SEL)aSelector;

    • 内部是调用objc_fatal()函数, 抛出异常

选择哪个阶段处理

为什么选择在forwardingTargetForSelector阶段做处理

  • resolveInstanceMethod阶段添加方法, 对类来说添加无用方法, 是一种冗余代码.

  • forwardInvocationinstanceMethodForSelector是绑定的, 且返回正确的函数签名. 且通过NSInvocation可以做多种转发.

  • forwardingTargetForSelector只需要返回一个消息接收者即可, 比较适合.

  • doesNotRecognizeSelector直接hook, 所有走doesNotRecognizeSelector方法直接报错的都会被失效, 需要自己处理收集堆栈.

大白实现

添加NSObject的分类添加替换的方法, hookforwardingTargetForSelector方法, 替换了自己的实现.

KVO崩溃

KVO崩溃场景:

  • 已经添加观察者的KVO, 观察者被释放, 且没有移除掉的时候, 触发会崩溃

  • 重复移除KVO导致的崩溃

  • 同一个对象添加不同的观察者, 必须要实现回调方法.

  • 同一个对象添加相同的观察者, 会出发多次回调.

iOS11以前, 重复添加, 被观察者销毁时还存在观察者会产生崩溃

解决思路

所以, 我们要做到

  • 不能重复添加
  • 不能重复移除
  • 要在观察者的生命周期, 在消失之前移除掉KVO.

解决方案

大白系统之中, 使用的是一个BayMaxKVODelegate的类. 并不是一个真正的代理.

主要做了以下几件事

  • 做一个NSObject(KVOProtector)分类, 动态添加BayMaxKVODelegate的属性

  • KVOProtectorKey动态添加了保护属性, 在dealloc的时候, 根据字段判断是否要进行移除.

  • hook以下几个方法:

    • addObserver:forKeyPath:options:context:
    • removeObserver:forKeyPath:
    • removeObserver:forKeyPath:context:
    • dealloc
  • BayMaxKVODelegate

    • _keyPathMaps类型是NSMutableDictionary<NSString*, NSMutableArray<KVOInfo *> *> *_keyPathMaps;

    • 封装KVOInfo接受整个消息的所有入参, 并且存储

    • addObserver的操作如下:

      • 先根据keyPath拿出KVOInfo数组
      • 遍历找到对应的observer观察者, 如果已经存在则返回错误信息
      • 如果不存在, 则添加新的KVOInfo对象
    • 添加移除都使用NSLock进行了线程安全保护

    • 移除的时候

      • keyPath取出观察数组
      • 比对observerMD5的值进行验证
      • 找到了就移除, 找不到则不处理.
    • observeValueForKeyPath回调的时候, 判断observer是否存在在回调

所以通过以上的操作, 重复添加, 重复移除, observer不存在出发的崩溃 都解决了.

NSNotification

iOS9之之前, 对象销毁的时候, 没有移除会产生bug.

需要注意注意点:

  • NSNotificationQueue的使用

    • 可以指定通知策略, 是否合并等.
    • 可以设定同步, 异步
  • 通知流程:

    • 新建一个数组, 添加通知
    • wildcard链表, 添加没有name也没有object的通知对象
    • nameless没有名字的有object的通知表, object->链表
    • named有名字的通知, 这个表是个二级结构.
      • 根据name找到表, 取出表根据objectobject找到链表
      • 如果没有object, 会有一个默认的key
    • 找完了之后, 遍历数组
      • 通过[observerNode->observer performSelector: o->selector withObject: notification];发送通知.

NSNotification防护

现在基本上支持的版本都是在iOS10以上了, 所以不太需要处理.

兼容iOS9以下的, 文中给出的是dealloc的hook方案. 但是hook整个dealloc并不是很好, 可以考虑使用其他方式.

NStimer类

主要是Timer会持有Target, 所以会造成内存泄漏.

NSTimer->Target, 即便是传入的weakSelf依旧是无效的, 因为Timer是直接指向weakSelf的内存地址的.

防护

文中给出的方案是:

  • hook以下方法
    • scheduledTimerWithTimeInterval:....
    • timerWithTimeInterval...
  • 使用BayMaxTimerSubTarget替代
    • 相当于NSTimer->BayMaxTimerSubTarget- - - ->target 也可以直接使用YYWeakProxy

文中的方案, 主要是兼容性的, 也是为了维护作者自己定义的BayMaxCatchError回调. 所以两种方案实际上原理是一样的

  • 大白方案对使用没有要求,
  • YY方案需要在写的时候自己处理.

Container类型crash

容器类的崩溃还是非常多且明显的, 在debug模式下, 正常开发不要开启防护. 上线版本开启.

BayMaxContainers直接查看这个类, 交换了很多.需要注意的点如下:

  • 容器类是类簇, 所以真实的调用类型并不是容器类型. 需要根据堆栈打印出来的符号进行调用, 比如__NSArrayI, __NSArray0, __NSSingleObjectArrayI等等实际的内部调用类型.
  • NSNumber, NSCache类型容易被忽略.
  • 注意TargetPointer类型

防护

BayMaxContainers, 这个类里面直接拿去用.

  • 主要就是对需要防护的类, 添加分类方法, 然后hook原方法.
  • 看下下面的例子
//objectAtIndex:
//__NSArrayI 这里换的就是__NSArrayI类型
    BMP_EXChangeInstanceMethod(__NSArrayI, @selector(objectAtIndex:), __NSArrayI, @selector(BMP__NSArrayIObjectAtIndex:));

    BMP_EXChangeInstanceMethod(__NSArrayI, @selector(objectAtIndexedSubscript:), __NSArrayI, @selector(BMP_objectAtIndexedSubscript:));

NSString

和容器类很像, 需要自己进行各方方法的调用测试, 然后根据报错的信息进行真实的hook.

防护

大白提供的防护也在BayMaxContainers类中, 原理和容器类一样.

野指针崩溃

不太好处理, 参考系统的僵尸对象. 调试模式关闭, 不然问题不好排查.

防护

最简单的思路, 不进行动态僵尸对象处理:

  • 创建一个Zoombie类, 添加一个出发类的ClassName的属性
    • 重写forwardingTargetForSelector, 设置返回值为ClassName的实例
  • Hook NSObjectdealloc的方法
    • 延时2秒判断self是否存在. 如果存在则走如下流程:
    • object通过object_setClass设置指向Zoombie类`
    • 设置objClassName属性为类名
  • 使用一句全局双向链表, 把这Zoombie对象链接起来
    • 使用头插法
    • 发出内存警告的时候进行尾部删除. 一般设置为2M或者多少个

非主线程刷新UI

大白给出的方案是:

  • hook以下三个方法
    • - (void)setNeedsLayout;
    • - (void)setNeedsDisplay;
    • - (void)setNeedsDisplayInRect; 在调用的时候, 使用strcmp函数进行主线程判断, 如果不在主线程, 则放在主线程操作.

绘制方法调用顺序:

  • setNeedsDisplay
  • setNeedsLayout
  • 重写了displayLayer就走自己的绘制流程
  • 没有重写就走系统的绘制流程:
    • drawLayer:inContext:
    • drawRect:

总结

如果有机会专门做某一方面, 就可以更加深入的做一些东西.

这里只是简单的记录和学习.