iOS老司机带你一起把App的崩溃率降到0.1%以下

13,410 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

1. 前言: 如何把App的崩溃率降到0.1%以下?

  • 崩溃无疑是我们在iOS开发工作中要面对的一个问题, 开发调试阶段的崩溃往往可以通过断点排查处理; 线上的崩溃往往让人手足无措, 需要结合Bugly等工具上传符号表, 抽丝剥茧的寻找原因一并解决.
  • 对于崩溃率, 0.1%往往是很多公司的硬性要求合格线, 在达到0.1%崩溃率的过程中, 我们作为一线iOS开发者, 可以做些什么呢? 下面的思路和做法抛砖引玉, 欢迎大家在评论区交流探讨:)
  • 无痕植入的思路: AOP(面向切面编程)的思想. 基于OC的runtime运行时特性, 打点, 自动在App运行时实时捕获导致App崩溃的因子, 然后通过针对性的的方法去应对因子, 做防崩处理.

2. 常见的8大崩溃产生原因

  1. unrecognized selector造成的崩溃: 没有找到对应的方法选择器.
  2. KVO 造成的崩溃: KVO的被观察者在dealloc时仍然注册着KVO导致的崩溃, 重复添加观察者或重复移除观察者.
  3. NSNotification 造成的崩溃: 当一个对象添加了Notification之后, 在dealloc的时候, 仍然持有Notification.
  4. NSTimer 造成的崩溃: 需要在合适的时机invalidate定时器, 否则就会由于定时器的timer强引用target导致target不被释放, 造成内存泄漏.
  5. 容器类型越界造成的崩溃: Array越界、Dictionary插入nil
  6. 非主线程刷新UI造成的崩溃: 在子线程刷新UI会导致App崩溃.
  7. 野指针造成的崩溃: 访问了野指针, 对象已经被释放.
  8. 第三方合作时产生的崩溃: 三方只提供了基于.a静态库的SDK文件, 三方更新后发生了崩溃

3. 常见的8大崩溃解决思路

3.1 unrecognized selector造成的崩溃处理

  • 采用拦截调用的方式, 在找不到调用的方法之后, App崩溃之前, 我们有机会通过重写NSObject的四个消息转发方法来做防崩溃处理.
+ (BOOL)resolveClassMethod:(SEL)sel; // 动态在方法决议机制, 决议类方法
+ (BOOL)resolveInstanceMethod:(SEL)sel; // 动态的对象方法决议, 决议对象方法

// 后两个方法需要转发到其他的类处理
- (id)forwardingTargetForSelector:(SEL)aSelector; // 转发给其它的一个对象去处理
- (void)forwardInvocation:(NSInvocation *)anInvocation; // 灵活地将目标函数以其他形式执行
  • 拦截调用的整个流程即OC的消息转发机制. runtime提供了3种方式去补救:
  1. 调用resolveInstanceMethod给个机会让类添加这个函数实现.
    • 需要在类的本身动态地添加它不存在的方法, 这些方法对于该类是冗余的.
  1. 调用forwardingTargetForSelector让别的对象去执行这个函数.
    • 可以通过NSInvocation的形式将消息转发给多个对象, 但是开销比较大,
    • 需要创建新的NSInvocation对象, 并且forwardInvocation的函数经常被使用者调用来做消息的转发选择机制, 不适合多次重写.
  1. 调用forwardingInvocation(函数执行器)灵活地将目标函数以其他形式执行.
    • 可以将消息转发给一个同一对象, 开销较小, 并且被重写的概率较低, 推荐在这重写.
  • 如果都不行, 系统才会调用doesNotRecognizeSelector抛出异常.

  • 重写NSObjectforwardingTargetForSelector具体步骤:

  1. 为类动态地重建一个桩类.
  2. 动态为桩类添加对应的Selector, 用一个通用的返回0的函数来实现该SELIMP.
  3. 将消息直接转发到这个桩类对象上.
- (id)jh_forwardingTargetForSelector:(SEL)aSelector {
    if (class_respondsToSelector([self class], @selector(forwardInvocation:))) {
        IMP impOfNSObject = class_getMethodImplementation([NSObject class], @selector(forwardInvocation:));
        IMP imp = class_getMethodImplementation([self class], @selector(forwardInvocation:));
        if (imp != impOfNSObject) {
            NSLog(@"class has implemented invocation");
            return nil;
        }
    }
    
    JHUnrecognizedSelectorSolveObject *solveObject = [JHUnrecoginzedSelectorSolveObject new];
    solveObject.objc = self;
    return solveObject;
}
  • ps: 如果对象的类本身重写了forwardInvocation方法的话, 就不应该对forwardingTargetForSelector进行重写了, 否则会影响到该类型的对象原本的消息转发流程.

3.2 KVO 造成的崩溃处理

  • 产生原因主要有2种
  1. KVO的被观察者dealloc时仍然注册着KVO导致的崩溃.
  2. 添加KVO重复添加观察者或重复移除观察者导致的崩溃.

image.png

  • 如上图所示: 一个被观察的对象有多个观察者, 每个观察者又有多个keyPath,

  • 如果观察者和keyPath的数量一多, 很容易不清楚被观察的对象整个KVO关系,

  • 导致被观察者在dealloc的时候, 仍然残存着一些关系没有被注销,

  • 同时还会导致KVO注册者和移除观察者不匹配的情况发生,

  • 尤其是多线程环境下, 导致KVO重复添加观察者或者重复移除观察者的情况, 这种类似的情况比较难排查.

  • 可以这样管理混乱的KVO关系:

  • 让观察者对象持有一个KVO的delegate, 所有和KVO相关的操作均通过delegate来进行管理,

  • delegate通过建立一张Map表来维护KVO的整个关系, 如下图:

image.png

  • 这样做的好处如下:
  1. 如果出现KVO重复添加或移除观察者(KVO注册者不匹配)的情况, delegate可以直接阻止这些异常操作.
  2. 被观察对象dealloc之前, 可以通过delegate自动将与自己有关的KVO关系都注销掉, 避免了KVO的被观察者dealloc时仍然注册着KVO导致的崩溃.

3.3 NSNotification造成的崩溃处理

  • iOS9之前, 当一个对象添加了Notification之后, 如果dealloc的时候, 仍然持有Notification, 就会出现NSNotification类型的崩溃.
  • iOS9之后苹果专门针对这种情况做了处理, 所以在iOS9之后, 即使开发者没有移除Observer, Notification崩溃也不会再产生了.
  • 针对iOS9之前的用户, 防止NSNotification崩溃的思路是:
  • 利用method swizzling hook NSObjectdealloc方法,
  • 在对象真正dealloc之前先调用一下[[NSNotificationCenter defaultCenter] removeObserve:self].

3.4 NSTimer内存泄漏造成的崩溃处理

  • 产生原因: Runloop -> NSTimer --> <- - 对象 <-VC
  • 这就导致了内存泄漏
  • 处理方法如下:
  • NSTimer和对象间添加一个中间对象, NSTimer强引用中间对象, 中间对象弱引用NSTimer、对象 image.png

3.5 容器类型越界造成的崩溃处理

  • 针对NSArray、NSMutableArray、NSDictionary、NSMutableDictionary、NSCache的一些常用的, 可能会导致崩溃的API进行基于runtime的method swizzling, 然后在swizzle的新方法中针对Debug环境和Release加入一些判空处理操作, 从而让这些API变得更难崩溃.

3.6 子线程刷新UI造成的崩溃处理

  • 采用基于runtime的swizzleUIView类的刷新UI方法
- (void)setNeedsLayout;
- (void)setNeedsDisplay;
- (void)setNeedsDisplayInRect:(CGRect)rect;
  • 在自定义的交换方法里, 调用上面几个方法时, 判断一下当前的线程, 如果不是主线程, 直接调用dispatch_async(dispatch_get_main_queue(),^{// 原代码});, 来将对应的刷新UI操作转移到主线程来做, 也可统计错误信息Debug模式下给到提示.

3.7 野指针造成的崩溃处理

  • 当Bugly统计到Exception Type:SIGSEGV, Exception Codes:SEGV_ACCERR时, 就代表发生了野指针访问.
  • 然而解决野指针造成的崩溃是一件比较棘手的事, 主要是因为崩溃信息很难提供精准的定位, 这就导致野指针崩溃的场景不一定好复现.
  • XCode为了开发阶段调试时就发现野指针问题, 提供了Zombie机制, 能够在发生野指针时提示出现野指针的类, 从而解决了开发阶段出现野指针的问题.
  • 但是线上环境产生的野指针问题, 依旧很难定位到具体的发生野指针的代码. 所以专门针对野指针做一层防崩措施, 在生产环境中就显得很有必要. 常见的一个思路:
  • 在类init初始化的时候做一个标记, 在该类dealloc时再做一个标记. 通过2次的标记来判断是否存在野指针. 但是对于UIVIew、UIImageView这些常用的类来说, 多次分配释放内存的CPU开销还是很大的, 这只是一个思路.
  • 更推荐腾讯的MLeaksFinder.
  • MLeaksFinder的思路:
MLeaksFinder一开始从UIViewController入手,
当一个UIViewController被pop或dismiss后, 该UIViewController包括他的view及subviews将很亏被释放. 
于是, 我们只需要在一个UIViewController被pop或dismiss一小段时间后, 
看看这个UIViewController及它的view、subviews等是否还存在.
MLeaksFinder具体的方法是为积累NSObject添加一个方法 -(void)willDealloc, 该方法的作用是:
先用一个弱指针指向self, 并在一小段时间后, 
通过这个弱指针调用 -(void)assertNotDealloc, 而 assertNotDealloc主要作用是直接调用中断言.
若果它没被释放(即发生了内存泄漏), assertNotDealloc就会被调用中断言.
这样一来, 当一个UIViewController被pop或dismiss时, 我们遍历该UIViewController上所有的view, 依次调用 willDealloc, 若一小段时间(如2s)之后还没释放, 那么指向它的weak指针还是存在的,
所以可以调用其tuntime绑定的方法 willDealloc 来提示野指针内存泄漏.

3.8 跟第三方合作时产生的崩溃处理

  • 当公司跟第三方公司合作时, 第三方公司只提供了一个.a的SDK,
  • 之前的版本可以稳定运行, 更新了第三方的SDK相关文件后却产生了线上的崩溃.
  • 这种情况一般来说一旦出现就会非常紧急.
  • 一般的解决思路是直接跟第三方联系, 让他们再跑一下测试流程, 定位问题.
  • 自己公司可以通过Bugly上收集到的崩溃信息, 上传符号表, 定位到崩溃的堆栈调用信息.
  • 联合排查, 如果线上版本已经发布, 崩溃又比较紧急, 短时间内三方也排查不出问题.
  • 这时可以通过Git分支的Tag, 回退到稳定版本, 紧急更新一个版本, 避免线上崩溃.
  • 待三方公司排查出问题后, 更新三方SDK相关文件, 再发一个bugFix版本.

发文不易, 喜欢点赞的人更有好运气👍 :), 定期更新+关注不迷路~

ps:欢迎加入笔者18年建立的研究iOS审核及前沿技术的三千人扣群:662339934,坑位有限,备注“掘金网友”可被群管通过~