iOS 崩溃防护实战

6,332 阅读8分钟

1. 为什么要做 Crash 防护

在 iOS 开发中,我们经常会遇到应用崩溃的情况,这种情况不仅会影响用户的使用体验,还可能导致用户的数据丢失,甚至让用户流失。因此,对于开发者来说,做好 Crash 防护工作是非常重要的。通过 Crash 防护,我们可以使应用在出现异常时不会立即崩溃,而是能够正常运行,从而提高应用的稳定性和用户体验。

针对游戏发行 SDK 而言,研发接入的 ObjcC 层由于各种原因(研发对 ObjC 代码不熟悉,参数传 nil,没有做好值类型转换等),容易引起应用崩溃,如果问题在线上才暴露出来,会极大影响玩家体验和游戏转化。因此 SDK 需要对一些常见的异常崩溃场景做好兜底和防护,尽可能降低这类问题带来的影响。

2. Crash 防护原理

iOS 中导致崩溃的场景很多,如野指针、数组越界、主线程卡死等等,而我们本次主要讨论的是 ObjC 运行时的异常导致的崩溃。

由于 Objective-C 是一门动态语言,利用 Objective-C 语言的 Runtime 运行时机制,对需要 Hook 的类添加 Category(分类),在各个分类的 +(void)load 中通过 Method Swizzling 拦截容易造成崩溃的系统方法,将系统原有方法与添加的防护方法的 selector(方法选择器)与 IMP(函数实现指针)进行交换。然后在替换方法中添加防护操作,从而达到避免以及修复崩溃和异常上报的目的。

3. Crash 防护实战

3.1 Method Swizzling

Method Swizzling 是 Objective-C 的一种特性,它允许我们在运行时改变方法的实现。我们可以通过 Method Swizzling 来改变原有方法的执行流程,使得在原有方法出现异常时,可以通过我们自定义的方法来处理这些异常,防止应用崩溃。

 /// 交换类方法
 /// - Parameters:
 ///   - cls: 类
 ///   - origSelector: 原方法
 ///   - newSelector: 交换方法
 void swizzleClassMethod(Class cls, SEL origSelector, SEL newSelector)
 {
     if (!cls)
         return;
     
     if (![cls respondsToSelector: origSelector])
         return;
     
     Method originalMethod = class_getClassMethod(cls, origSelector);
     Method swizzledMethod = class_getClassMethod(cls, newSelector);
     
     Class metacls = objc_getMetaClass(NSStringFromClass(cls).UTF8String);
     if (class_addMethod(metacls,
                         origSelector,
                         method_getImplementation(swizzledMethod),
                         method_getTypeEncoding(swizzledMethod)) ) {
         class_replaceMethod(metacls,
                             newSelector,
                             method_getImplementation(originalMethod),
                             method_getTypeEncoding(originalMethod));
         
     } else {
         class_replaceMethod(metacls,
                             newSelector,
                             class_replaceMethod(metacls,
                                                 origSelector,
                                                 method_getImplementation(swizzledMethod),
                                                 method_getTypeEncoding(swizzledMethod)),
                             method_getTypeEncoding(originalMethod));
     }
 }
 ​
 /// 交换实例方法
 /// - Parameters:
 ///   - cls: 类
 ///   - origSelector: 原方法
 ///   - newSelector: 交换方法
 void swizzleInstanceMethod(Class cls, SEL origSelector, SEL newSelector)
 {
     if (!cls) {
         return;
     }
     if (![cls instancesRespondToSelector: origSelector])
         return;
     
     Method originalMethod = class_getInstanceMethod(cls, origSelector);
     Method swizzledMethod = class_getInstanceMethod(cls, newSelector);
     
     if (class_addMethod(cls,
                         origSelector,
                         method_getImplementation(swizzledMethod),
                         method_getTypeEncoding(swizzledMethod)) ) {
         class_replaceMethod(cls,
                             newSelector,
                             method_getImplementation(originalMethod),
                             method_getTypeEncoding(originalMethod));
         
     } else {
         class_replaceMethod(cls,
                             newSelector,
                             class_replaceMethod(cls,
                                                 origSelector,
                                                 method_getImplementation(swizzledMethod),
                                                 method_getTypeEncoding(swizzledMethod)),
                             method_getTypeEncoding(originalMethod));
     }
 }

3.2 常见方法 Hook

  1. 容器类防护

    在 Objective-C 中,最常见的就是在NSDictionary、NSArray等这类容器中插入 nil 引发崩溃,针对这些类我们可以 hook 对应的方法,加入@try @catch 来捕获 OC 运行时产生的异常。

    以 NSDictionary 和 NSMutableDictionary 防护为例,在创建字典时捕获到异常,过滤掉为 nil 的 key 和 value。针对NSMutableDictionary, 分别在新增和删除键值对的方法中做好异常捕获。最重要的是,需及时上报异常堆栈和做好对应的告警

     @implementation NSDictionary (Safe)
     + (void)load
     {
         static dispatch_once_t onceToken;
         dispatch_once(&onceToken, ^{
             /* 交换类方法 */
             swizzleClassMethod([self class], @selector(dictionaryWithObjects:forKeys:count:), @selector(hookDictionaryWithObjects:forKeys:count:));
         });
     }
     ​
     + (instancetype) hookDictionaryWithObjects:(const id [])objects forKeys:(const id [])keys count:(NSUInteger)cnt
     {
         id object = nil;
         @try {
             object = [self hookDictionaryWithObjects:objects forKeys:keys count:cnt];
         }
         @catch (NSException *exception) {
             // TODO: 异常上报
     ​
             // 去掉为 nil 的 key/value
             NSUInteger index = 0;
             id  _Nonnull __unsafe_unretained newObjects[cnt];
             id  _Nonnull __unsafe_unretained newkeys[cnt];
             for (int i = 0; i < cnt; i++) {
                 if (objects[i] && keys[i]) {
                     newObjects[index] = objects[i];
                     newkeys[index] = keys[i];
                     index++;
                 }
             }
             object = [self hookDictionaryWithObjects:newObjects forKeys:newkeys count:index];
         }
         @finally {
             return object;
         }
     }
     ​
     ​
     @implementation NSMutableDictionary (Safe)
     + (void)load
     {
         static dispatch_once_t onceToken;
         dispatch_once(&onceToken, ^{
             swizzleInstanceMethod(NSClassFromString(@"__NSDictionaryM"), @selector(setObject:forKey:), @selector(hookSetObject:forKey:));
             swizzleInstanceMethod(NSClassFromString(@"__NSDictionaryM"), @selector(removeObjectForKey:), @selector(hookRemoveObjectForKey:));
             swizzleInstanceMethod(NSClassFromString(@"__NSDictionaryM"), @selector(setObject:forKeyedSubscript:), @selector(hookSetObject:forKeyedSubscript:));
         });
     }
     ​
     - (void) hookSetObject:(id)anObject forKey:(id)aKey {
         @try {
             [self hookSetObject:anObject forKey:aKey];
         }
         @catch (NSException *exception) {
             // TODO: 异常上报
         }
         @finally {
     ​
         }
     }
     ​
     - (void) hookRemoveObjectForKey:(id)aKey {
         @try {
             [self hookRemoveObjectForKey:aKey];
         }
         @catch (NSException *exception) {
             // TODO: 异常上报
         }
         @finally {
     ​
         }
     }
     ​
     - (void)hookSetObject:(id)obj forKeyedSubscript:(id<NSCopying>)key
     {
         @try {
             [self hookSetObject:obj forKeyedSubscript:key];
         }
         @catch (NSException *exception) {
             // TODO: 异常上报
         }
         @finally {
     ​
         }
     }
     @end
    

    同理,针对 NSArray/NSMutableArray,NSSet/NSMutableSet 也可做类似的 hook 和异常捕获

     ​
     // NSArray hook 方法
     + (void)load
     {
         static dispatch_once_t onceToken;
         dispatch_once(&onceToken, ^{
             /* 构造函数 */
             swizzleClassMethod(self, @selector(arrayWithObject:), @selector(hookArrayWithObject:));
             swizzleClassMethod(self, @selector(arrayWithObjects:count:), @selector(hookArrayWithObjects:count:));
             
             /* 没内容类型是__NSArray0 */
             swizzleInstanceMethod(NSClassFromString(@"__NSArray0"), @selector(objectAtIndex:), @selector(hookObjectAtIndex:));
             swizzleInstanceMethod(NSClassFromString(@"__NSArray0"), @selector(subarrayWithRange:), @selector(hookSubarrayWithRange:));
             swizzleInstanceMethod(NSClassFromString(@"__NSArray0"), @selector(objectAtIndexedSubscript:), @selector(hookObjectAtIndexedSubscript:));
             
             /* 有内容类型是__NSArrayI */
             swizzleInstanceMethod(NSClassFromString(@"__NSArrayI"), @selector(objectAtIndex:), @selector(hookObjectAtIndex:));
             swizzleInstanceMethod(NSClassFromString(@"__NSArrayI"), @selector(subarrayWithRange:), @selector(hookSubarrayWithRange:));
             swizzleInstanceMethod(NSClassFromString(@"__NSArrayI"), @selector(objectAtIndexedSubscript:), @selector(hookObjectAtIndexedSubscript:));
             
             
             /* 单个内容类型是__NSSingleObjectArrayI */
             swizzleInstanceMethod(NSClassFromString(@"__NSSingleObjectArrayI"), @selector(objectAtIndex:), @selector(hookObjectAtIndex:));
             swizzleInstanceMethod(NSClassFromString(@"__NSSingleObjectArrayI"), @selector(subarrayWithRange:), @selector(hookSubarrayWithRange:));
             swizzleInstanceMethod(NSClassFromString(@"__NSSingleObjectArrayI"), @selector(objectAtIndexedSubscript:), @selector(hookObjectAtIndexedSubscript:));
         });
     }
     ​
     // hook方法的防护,具体代码就不一一详细列出来了...
    
  2. Unrecognized Selector 崩溃防护

    Objc 的消息转发是一种用于在运行时动态地将消息转发给其他对象来处理的机制,利用这个机制可以来防护 Unrecognized Selector 这一类的崩溃。

    消息转发基本流程: image-20230922114121759.png

    1. 当一个对象收到一个未知的消息时,它会先调用 +resolveInstanceMethod:+resolveClassMethod: 方法,尝试动态添加方法来处理这个消息。
    2. 如果动态方法解析无法处理这个消息,那么它会调用 forwardingTargetForSelector: 方法,尝试将消息转发给另一个对象来处理。
    3. 如果 forwardingTargetForSelector: 方法也无法处理这个消息,那么它会调用 methodSignatureForSelector: 方法,获取一个方法签名。如果返回方法签名为 nil,那么它会调用 doesNotRecognizeSelector: 方法,抛出一个异常。
    4. 方法签名不为空,则它会调用 forwardInvocation: 方法,将方法签名和消息转发给另一个对象来处理。

    具体实现:

    我们在第 4 步中 methodSignatureForSelector: 获取方法签名,如果方法签名为空,且methodSignatureForSelector:没有被重写过的情况下,给他返回一个默认的方法签名v@:@,最后,我们在forwardInvocation方法中即可用来上报异常情况

     ​
     swizzleInstanceMethod([NSObject class], @selector(methodSignatureForSelector:), @selector(hookMethodSignatureForSelector:));
     swizzleInstanceMethod([NSObject class], @selector(forwardInvocation:), @selector(hookForwardInvocation:));
     ​
     ​
     - (NSMethodSignature*)hookMethodSignatureForSelector:(SEL)aSelector {
         NSMethodSignature* sig = [self hookMethodSignatureForSelector:aSelector];
         if (!sig){
             if (class_getMethodImplementation([NSObject class], @selector(methodSignatureForSelector:))
                 != class_getMethodImplementation(self.class, @selector(methodSignatureForSelector:)) ){
                 return nil;
             }
             return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
         }
         return sig;
     }
     ​
     ​
     - (void)hookForwardInvocation:(NSInvocation*)invocation
     {
         NSString* info = [NSString stringWithFormat:@"unrecognized selector [%@] sent to %@", NSStringFromSelector(invocation.selector), NSStringFromClass(self.class)];
         NSString *stackString = [[NSThread callStackSymbols] componentsJoinedByString:@"\n"];
         NSLog(@"%@", info);
         NSLog(@"%@", stackString);
         // TODO: 异常上报
     }
    
  3. KVO 崩溃防护

    常见的崩溃原因:

    • KVO 添加次数和移除次数不匹配;
    • 添加或者移除时 keypath == nil;

    hook addObserver:forKeyPath:options:context:removeObserver:forKeyPath:方法,在设置和移除监听中做空值判断和try catch

     swizzleInstanceMethod([NSObject class], @selector(addObserver:forKeyPath:options:context:), @selector(hookAddObserver:forKeyPath:options:context:));
     swizzleInstanceMethod([NSObject class], @selector(removeObserver:forKeyPath:), @selector(hookRemoveObserver:forKeyPath:));
     ​
     ​
     - (void) hookAddObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context
     {
         if (!observer || !keyPath.length) {
             return;
         }
         @try {
             [self hookAddObserver:observer forKeyPath:keyPath options:options context:context];
         }
         @catch (NSException *exception) {
             // TODO: 异常上报
         }
     }
     ​
     - (void) hookRemoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath
     {
         if (!observer || !keyPath.length) {
             return;
         }
         @try {
             [self hookRemoveObserver:observer forKeyPath:keyPath];
         }
         @catch (NSException *exception) {
             // TODO: 异常上报
         }
     }
    
  4. KVC 崩溃防护

    常见的崩溃原因:

    1. key 不是对象的属性,造成崩溃
    2. 设置的 keyPath 不正确,造成崩溃
    3. 设置的 value 为 nil,造成崩溃

    通过查看 Xcode 文档,在头文件NSKeyValueCoding.h中的可看到以下方法的注释:

    1. valueForUndefinedKey:

      调用 valueForKey: 获取键值,但 key 不正确时会触发此方法。 此方法的默认实现会引发 NSUndefinedKeyException。

    2. setValue:forUndefinedKey:

      调用 setValue:forKey: 设置键值,但 key 不正确时会触发此方法,此方法的默认实现会引发 NSInvalidArgumentException。

    3. setNilValueForKey:

      调用 setValue:forKey: 设置键值,相应的访问器方法的参数类型是 NSNumber 标量类型或 NSValue 结构类型,但值为 nil 的情况时,会触发此方法。 此方法的默认实现会引发 NSInvalidArgumentException。

    针对以上的三种场景,我们只需要重写默认实现,即可实现崩溃防护:

     - (nullable id)valueForUndefinedKey:(NSString *)key {
       // TODO: 异常上报
     }
     ​
     - (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key {
       // TODO: 异常上报
     }
     ​
     - (void)setNilValueForKey:(NSString *)key {
       // TODO: 异常上报
     }
    

    还有一种场景,当 key == nil时,会触发异常崩溃,我们只需 hook setValue:forKey:方法并对 key 做非空判断即可解决

     swizzleInstanceMethod([NSObject class], @selector(setValue:forKey:), @selector(hookSetValue:forKey:));
     ​
     - (void)hookSetValue:(id)value forKey:(NSString *)key {
         if (key == nil) {
             // TODO: 异常上报
             return;
         }
         [self hookSetValue:value forKey:key];
     }
    

以上我们针对 OC 中一些比较常见的异常崩溃场景,做了相应的异常拦截和防护的措施。当然还有更多的场景,如 NSString、NSData、NSSet 等常用类也可做崩溃防护,在这里就不一一列举。

3.3 异常上报与告警

当遇到可能引发崩溃的异常时,仅进行基础防护是不足够的。崩溃防护作为最后一道防线,单实际的业务逻辑可能已出现异常。因此,我们的首要任务是建立一套全面的异常报告和警报机制,并确保及时解决问题。

实时异常告警: image-20230921225803632.png

详细异常日志: image-20230921230314605.png

4. 总结

总结而言,Crash 防护能提升应用稳定性和用户体验。我们可以利用 Method Swizzling 和 Hook 等技术进行 Crash 防护,调整原有方法的执行流程并添加防护逻辑,以避免应用因异常而崩溃。然而,我们也需警惕这些技术所带来的风险,例如可能使代码流程变得模糊,使问题定位变得困难,尤其是,崩溃防护可能会带来业务逻辑异常。因此,及时上报异常并警告机制是至关重要的一步。