APM - iOS Crash 常见崩溃及防护

2,595 阅读7分钟

Crash 分析与治理是开发过程的必备技能,Crash 相关数据也是衡量应用程序质量的重要指标。

APM - iOS Crash 常见崩溃及防护

APM - iOS Crash 异常捕获原理

本文总结了 iOS 开发中基础的 Crash 种类以及防护方案。

常见崩溃及防护

1.集合类相关的 Crash

这类Crash 主要是对 OC 中的集合类(如 NSArray/NSMutableArray/NSDictionary/NSMutableDictionary)的不当操作产生的崩溃。

常见场景

  • 数组越界
  • 向数组、字典中添加 nil 元素
  • 在遍历数组的过程中,使用错误的方法修改了数组

防护措施

可通过 swizzle 系统相关的方法,在自定义的方法中加一些条件判断,从而防止崩溃。

/**
 	  NSArray 防越界取出NSArray 
 */
- (id)safe_objectAtIndex:(NSUInteger)index {
    if (index >= self.count){
        return nil;
    }
    return [self safe_objectAtIndex:index];
}

/**
 NSMutableArray 插入 新值 到 索引index 指定位置
 */
- (void)safeMutable_insertObject:(id)anObject atIndex:(NSUInteger)index {
    if (index > self.count) {
        return;
    }

    if (!anObject){
        return;
    }

    [self safeMutable_insertObject:anObject atIndex:index];
}

2.KVC

KVC(Key Value Coding)又称键值编码,开发者可以通过 key 名直接访问对象的属性或给属性赋值,而不需要调用存取方法。常见的一些 Crash 场景有:

常见场景

  • key 的值为 nil
  • key 不是对象的属性或变量
  • keyPath 不正确
  • value 为nil,为非对象赋值

防护措施

// 针对 key 不正确的情况,重写如下两个方法
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
    NSString *crashMessages = [NSString stringWithFormat:@"crashMessages : [<%@ %p> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key: %@,value:%@'",NSStringFromClass([self class]),self,key,value];
    NSLog(@"%@", crashMessages);
}

- (nullable id)valueForUndefinedKey:(NSString *)key {
    NSString *crashMessages = [NSString stringWithFormat:@"crashMessages :[<%@ %p> valueForUndefinedKey:]: this class is not key value coding-compliant for the key: %@",NSStringFromClass([self class]),self,key];
    NSLog(@"%@", crashMessages);
    
    return self;
}

//处理 key 为 nil 的情况,swizzle 系统 setValu 方法
- (void)xx_setValue:(id)value forKey:(NSString *)key {
    if (key == nil) {
        NSString *crashMessages = [NSString stringWithFormat:@"crashMessages : [<%@ %p> setNilValueForKey]: could not set nil as the value for the key %@.",NSStringFromClass([self class]),self,key];
        NSLog(@"%@", crashMessages);
        return;
    }

    [self ysc_setValue:value forKey:key];
}

//处理 value 为 nil的情况
- (void)setNilValueForKey:(NSString *)key {
    NSString *crashMessages = [NSString stringWithFormat:@"crashMessages : [<%@ %p> setNilValueForKey]: could not set nil as the value for the key %@.",NSStringFromClass([self class]),self,key];
    NSLog(@"%@", crashMessages);
}

参考『Crash 防护系统』(三)KVC 防护

3.KVO

KVO(Key Value Observing),是一套事件通知机制。允许一个对象监听另一个对象属性的变化。对于被观察者和观察者之间的关系是一对一的,不是一对多的。在某些场景下如果使用不当也会产生 Crash。

常见场景

  • 观察者 或 被观察者是局部对象
  • 添加或移除时,keyPath 为nil
  • 即添加次数和移除次数不匹配(重复移除监听对象)
  • 添加了观察者,但未实现 observeValueForKeyPath:ofObject:change:context: 方法

防护措施

防护方案的主要思路是:

  1. 创建一个 KVOProxy 对象,并通过哈希表维护 oberver 与 keyPath 之间的关系,存储的结构是:{ keyPath: [observer1, observer2,...] } (记录被观察者个数,防护添加、移除次数不匹配的情况)
  2. swizzle NSObject 的添加、移动观察者等方法,将观察者设置成 KVOProxy 对象,然后再利用 KVOProxy 对象进行分发处理(防护 keyPath为 nil 的情况,以及防护未实现 observeValueForKeyPath 方法的情况)
  3. swizzle NSObject 的 dealloc 方法,在此移除多余的观察者(防止局部变量提前释放导致的 Crash)

可参考『Crash 防护系统』(二)KVO 防护

4.Unrecognized Selector (未实现的方法)

如果对象没有方法的实现,或者对象没有该方法,导致的崩溃。常见的场景有:

常见场景

  • .h声明了方法,.m却没有实现
  • 协议中的方法没有实现
if ([self.delegate respondsToSelector:@selector(protocolMehtod)]) {
    [self.delegate protocolMehtod];
}
  • copy 修饰可变属性
@property (nonatomic, copy) NSMutableArray *mutableArray;
  • 动态调用未知方法
if ([self respondsToSelector:@selector(unknow)]) {
    [self performSelector:@selector(unknow)];
}
  • 低版本调用了高版本的 API
if (@available(iOS 13.0, *)) {
    [self canPerformUnwindSegueAction:@selector(test) fromViewController:self sender:nil];
} else {
    // Fallback on earlier versions
}

防护措施

当找不到对象的方法实现时,会进入到消息的转发流程,可以在这里进行拦截并处理。

消息转发流程:

  1. 动态方法解析

首先调用 +resolveInstanceMethod 方法,查看是否有动态添加的方法实现。

  1. 备用的接受者

上一步失败,调用 forwardingTargetForSelector 查看是否有其他对象处理了该消息。

  1. 完成的消息转发

前两步都失败了,则启动完整的消息转发机制,调用 forwardingInvocation,通过封装完整的 NSInvocation, 明确指出方法的响应者。

  1. 以上步骤都失败,则抛出异常。

防护措施的思路是:

  1. swizzle NSObject 的 forwardingTargetForSelector 方法

  2. 判断当前对象是否已经实现了消息转发中的第二步、第三步的方法,如果都没有实现,就动态的创建一个目标类,给目标类动态添加一个方法

  3. 返回动态创建的目标类的实例对象。

5.Bad Access (野指针)

野指针通常指所指向的对象已经被释放的指针,其所指向的内存地址存储的数据也被称为僵尸对象。野指针相关的异常场景有:

常见场景

  • ARC 下,使用 assign 或 unsafe_unretained 修饰对象
  • runtime 关联对象使用了不合适的修饰符,如 OBJC_ASSOCIATION_ASSIGN
  • 使用了未初始化的对象

防护措施

可在开发阶段,通过开启 xcode 的僵尸对象功能进行检测和预防

6.多线程相关 Crash

多线程产生的异常和野指针一样都不是很容易复现的,因此在写代码的时候一定要注意开发规范,常见问题的场景有:

常见场景

  • 非主线程刷新 UI

如:在子线程调用了 UI 刷新相关的操作;如果需要刷新 UI,可通过 dispatch_async(dispatch_get_main_queue(), ^{ //调用UI 相关方法 }) 回到主线程执行相关操作。

  • 多线程同时操作同一个数组

如:当一个线程正在遍历数组,此时另一个线程改变了数组元素的个数,会由于索引错乱而产生Crash。当多线程遍历数组时,可先拷贝一份再进行遍历。

  • 多线程死锁

如:group enter 与 group leave 需要保证成对调用,否则很容易出现死锁问题。

7.watch dog 异常

为防止一个应用占用过多的系统资源,Apple 设计了一个名为“看门狗”(watch dog)的机制。如果某个场景超过了所规定的运行时间,“看门狗”就会强制终结这个应用程序。异常代码为:“0x8badf00d” (很像 bad food)。

Watch dog 造成的 Crash 不是代码本身的错误,其实是一种保护机制。当收集到这类的异常问题时,可着重考虑下应用的性能(长卡)或者是否有死锁等异常逻辑。

常见场景

  • 长卡导致

  • 死锁导致


如何收集 Crash 日志?

Crash 日志的来源一般有两个渠道:

苹果收集的

  • 用户的 iPhone:可从 “设置 - 隐私 - 分析与改进 - 分析数据” 中获得
  • 若 App 绑定了 Apple Connect 账号,可从 Xcode - Window - Origanizer - Crashes 查看

应用内自己收集的

  • 接入 APM 产品, 如 Bugly、EMAS、phabricator 等

  • 接入 SDK 收集,上报到自己的平台统计,如 PLCrashReporter 、 KSCrash 等

如何确认 Crash 文件、App包、dSYM 三者 UUID 一致?

Crash 文件中的 UUID

如上图,Crash 文件中二进制文件信息在 Binary Images 中列出,分别是二进制文件名(TouchCanvas)、CPU架构(arm64)、二进制文件UUID

APP 包的 UUID

  • 把APP的后缀名ipa改为zip,并解压,得到后缀名为app的二进制文件。
  • 使用dwarfdump -uuid XXX.app/XXX 查看APP所包含的CPU架构的UUID,以及对应的架构,如图:

dSYM 的 UUID

与APP 一样,使用 dwarfdump -uuid XXX.app.dSYM 查看dSYM所包含的CPU架构的UUID:

如何符号化?

  • symbolicatecrash
  • atos
  • CrashSymbolicator

可参考:iOS 崩溃符号化工具

总结

以上列举了开发中常见的一些 Crash,以及利用 Objective-C 语言的动态性,拦截容易造成崩溃的系统方法,添加一些防护操作,从而达到避免以及修复崩溃的目的。

但是防护的目的不是掩饰,一定要在关键节点将拦截到的异常信息记录或者上报,从而对导致异常的原因完成真正意义上的修复。

参考 Crash分析实践

Crash 防护系统:KVO

各种 Cash 防护