Skip to content

hikari-ahead/CrashProtector

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

32 Commits
 
 
 
 
 
 
 
 

Repository files navigation

MTCrashProtector - runtime crash protector

0x1.前言

前段时间无意间看到网易前端技术博客中的大白健康系统--iOS App运行时Crash自动修复系统这篇文章,利用Objective-C动态的语言特性,在App将要崩溃(抛出异常)时捕获异常进行处理,消灭异常,进行信息上报,保证App继续正常的运行。其主要面向8个方面:

  1. Unrecognized Selector Crash
  2. KVO Crash
  3. NSNotification crash (below iOS8)
  4. NSTimer Crash
  5. Container Crash
  6. NSString Crash
  7. Bad Access Crash
  8. UI Not On Main Thread Crash

原文中有介绍这几种防护的大致思路,但是没有找到开源的项目,于是思索着并实现了自己一套运行时Crash保护组件:MTCrashProtector

目前组件实现了上述8个方面的前5部分。

0x2.准备

创建SDK工程

既然我们是准备实现一个组件供他人接入使用,显然我们应该创建一个xcworkspace来管理多个target进行开发。在这里有两种可选的方式:

  • 手动新建一个xxDemo.xcworkspace项目,然后依次添加两个target(体力活,不推荐):

    1. 类型为Single View Application名为xxDemo
    2. 类型为Cocoa Touch Framework名为xxSDK (这里我们目标是生成动态库,如果不希望公开源代码,希望生成.a的话,请选择Cocoa Touch Static Library

    通过这种方式进行SDK的开发,并且以xxDemo为入口编写测试代码,最终发布时单独提出xxSDK这个target进行发布即可。

  • 使用Cocoapodspod lib命令创建,进行Development Pod开发。

    切换到工作目录后执行以下命令,按照提示输入:

      $ pod lib create MTCrashProtector    
      
      What platform do you want to use?? [ iOS / macOS ]
       > iOS
      
      What language do you want to use?? [ Swift / ObjC ]
       > ObjC
      
      Would you like to include a demo application with your library? [ Yes / No ]
       > Yes
      
      Which testing frameworks will you use? [ Specta / Kiwi / None ]
       > None
      
      Would you like to do view based testing? [ Yes / No ]
       > No
      
      What is your class prefix?
       > MT
      
      Running pod install on your new library.
      
      Ignoring unf_ext-0.0.7.4 because its extensions are not built.  Try: gem pristine unf_ext --version 0.0.7.4
      Analyzing dependencies
      Fetching podspec for `MTCrashProtector` from `../`
      Downloading dependencies
      Installing MTCrashProtector (0.1.0)
      Generating Pods project
      Integrating client project
      
      [!] Please close any current Xcode sessions and use `MTCrashProtector.xcworkspace` for this project from now on.
      Sending stats
      Pod installation complete! There is 1 dependency from the Podfile and 1 total pod installed.

工程生成后自行按需修改*.podspec,这个后面会用到。注意s.homepage要确保可以访问,使用

pod spec lint *.podspec --allow-warnings

验证podspec通过即可。

目录结构

=> Classes
   - MTCrashProtector.h //快速进行Method Swizzling的宏定义
   => Container         //NSArray类簇/NSCache/NSDictionary类簇/NSObject
   => Notification      //通知相关
   => NSTimer           //NSTimer相关
   => Observer          //Observer相关
   => Selector          //target forwaring
   => Setting           //开关配置
   => Util

0x3. 实现

Unrecognized Selector Crash

Runtime msgSend流程:

  1. 当前对象objc_cache * _Nonnull cache中寻找调用的方法method,如果存在method则转到对应的实现IMP并执行。

  2. 如果未找到,在当前对象的objc_method_list * _Nullable * _Nullable methodLists中去寻找调用的方法method,如果存在method则转到对应的实现IMP并执行。

  3. 如果objc_method_list * _Nullable * _Nullable methodLists中也没有找到,则转向父类Class _Nullable super_class中递归的执行1和2两步,直到到根类。如果存在method则转到对应的实现IMP并执行。

  4. 如果到根类都没有找到method,则转向拦截调用,如果你使用resolveClassMethod:或者resolveInstanceMethod:解析了method(return YES),消息被标记为已处理,不会触发崩溃。

  5. 如果没有实现resolvexxxxxMethod:让类去解析添加实现,则转向forwardingTargetForSelector:让别的对象去执行。如果别的对象接收到信息后并且正常调用了实现,消息被标记为已处理,不会触发崩溃。

  6. 如果没有实现forwardingTargetForSelector:交给其他对象处理,则转向forwardInvocation:处理,如果实现了此方法将消息处理,则不会出发崩溃,否则将会继续调用doesNotRecognizeSelector:抛出异常触发崩溃。

上面流程可以看出:4,5,6三步都可以进行防护,选择5:forwardingTargetForSelector:的原因是因为:resolveInstanceMethod:或者resolveClassMethod:会给当前类添加一些不必要的方法,而forwardInvocation:需要生成一个invocation对象会造成额外的内存开销。这个组件的实现是添加一个stub类,然后所有触发的(需要排除为了特殊目的而特意实现的)forwardingTargetForSelector:指向这个stub类的单例,因为这个stub类也不一定(大部分情况是没有)包含这个method实现,所以会继续调用stub类的resolvexxxxMethod:方法,在这个地方去动态的为stub类添加对应的实现,来保证程序不会crash并且也不会污染已有的类。

Notification Crash

这个Module只针对iOS 9以下,见官方文档:

- addObserver:selector:name:object:
Adds an entry to the notification center's dispatch table with an observer and a notification selector, and an optional notification name and sender.

Declaration

- (void)addObserver:(id)observer selector:(SEL)aSelector name:(NSNotificationName)aName object:(id)anObject;
Parameters

observer
Object registering as an observer.

aSelector
Selector that specifies the message the receiver sends observer to notify it of the notification posting. The method specified by aSelector must have one and only one argument (an instance of NSNotification).

aName
The name of the notification for which to register the observer; that is, only notifications with this name are delivered to the observer.

If you pass nil, the notification center doesn’t use a notification’s name to decide whether to deliver it to the observer.

anObject
The object whose notifications the observer wants to receive; that is, only notifications sent by this sender are delivered to the observer.

If you pass nil, the notification center doesn’t use a notification’s sender to decide whether to deliver it to the observer.

Discussion

If your app targets iOS 9.0 and later or macOS 10.11 and later, you don't need to unregister an observer in its dealloc method. Otherwise, you should call removeObserver:name:object: before observer or any object passed to this method is deallocated.

hook NSNotificationCenter以下几个方法:

// - Add
SEL oriSEL = @selector(addObserverForName:object:queue:usingBlock:);
// - Remove
SEL oriSEL2 = @selector(removeObserver:name:object:);
SEL oriSEL3 = @selector(removeObserver:);
// - Post
SEL oriSEL4 = @selector(postNotification:);
SEL oriSEL5 = @selector(postNotificationName:object:userInfo:);
SEL oriSEL6 = @selector(postNotificationName:object:);

重点在于维护一个notificationInfos,通过不同的Method添加或者删除能够正确的匹配到已有的通知,保证不会重复添加,不会重复移除。

KVO Crash

KVO Crash防护的实现和Notification Crash防护相似,都是使用一个stub去代理检查是否已经注册过相同的观察者(通知),然后再进行真正的添加或删除操作。

hook NSObject以下几个方法:

// - Add
SEL oriSEL = @selector(addObserver:forKeyPath:options:context:);
    
// - Remove
SEL oriSEL1 = @selector(removeObserver:forKeyPath:);
SEL oriSEL2 = @selector(removeObserver:forKeyPath:context:);
    
// - Receive
SEL oriSEL3 = @selector(observeValueForKeyPath:ofObject:change:context:);
    
// - Dealloc
SEL oriSEL4 = NSSelectorFromString(@"dealloc");

在NSObject执行dealloc方法时,根据设置的关联对象mtcp_hasAddedObserver来判断是否需要移除全部的observer来防止crash发生。

Container Crash

以NSArray类簇为例:

Class Name Description
NSArray 不可变数组的工厂类
NSMutableArray 可变数组的工厂类
__NSPlaceholderArray 占位类,真正初始化的时候都是使用这个类的initWithObjects:count:方法
__NSArray0 初始化0元素不可变数组时最终生成这个类的对象
__NSArrayI 非0元素不可变数组对应的类
__NSArrayM 可变数组对应的类
__NSSingleObjectArrayI 单一元素不可变数组对应的类
__NSArrayReversed 作为一个NSArray的代理并以相反的顺序呈现原Array的内容
__NSCFArray CFArrayRef或CFMutableArrayRef。现在大多数CFArrayRefs都是__NSArray*,通过CF创建

以上信息可以通过自身实验得到,但是不同的iOS版本之间可能会有区别,比如说在iOS 8及以下的系统中不存在__NSArray0这个私有类。其他更多详细的类簇可以查看:Class Clusters

  1. init

    由于所有的Array都是有__NSPlaceholderArray来进行初始化的,所以只需要hook

    - [__NSPlaceholderArray initWithObjects:count:]

即可,根据传入的cnt和C style数组检测[0...cnt-1]中是否存在空指针,避免崩溃。

  1. objectAtIndex

    CF中使用到的__NSCFArray不做处理,针对下列类进行objectAtIndex:进行hook

    @"NSArray", @"__NSArray0", @"__NSArrayI", @"__NSArrayM", @"__NSPlaceholderArray", @"__NSArrayReversed", @"__NSSingleObjectArrayI"

    额外注意iOS 10以下不存在__NSSingleObjectArrayI,iOS 9以下不存在__NSArray0即可

  2. objectAtIndexedSubscript

    这个SEL其实是重载了操作符[],不要直接调用这个方法,通过实验测试得知,iOS 11开始 __NSArrayI__NSArrayMobjectAtIndexedSubscript:进行了重写,所以需要hook,其他版本只需要hook父类NSArray中的这个方法即可。

  3. 可变部分的Methods

    针对__NSArrayM需要hook以下方法:

@selector(addObject:);
@selector(insertObject:atIndex:) @selector(removeObjectAtIndex:)) @selector(replaceObjectAtIndex:withObject:) ```

针对`NSMutableArray`需要hook以下方法:

```objC
@selector(insertObjects:atIndexes:)
@selector(removeObjectsAtIndexes:)
@selector(removeObject:inRange:)
@selector(removeObjectIdenticalTo:inRange:)
@selector(replaceObjectsAtIndexes:withObjects:)
@selector(replaceObjectsInRange:withObjectsFromArray:range:)
@selector(replaceObjectsInRange:withObjectsFromArray:)
```

由于系统版本的差异,以下方法需要根据iOS版本号来区分需要hook的具体类:

```objC
// iOS 10 以下系统,__NSArrayM没有重写NSMutableArray的Method:
Class cls = [UIDevice currentDevice].systemVersion.floatValue < 10.0 ? NSClassFromString(@"NSMutableArray") : NSClassFromString(@"__NSArrayM");
@selector(removeObjectsInRange:)
@selector(setObject:atIndexedSubscript:)
```

NSDictionary类簇的实现与NSArray类簇相似,重点是搞清楚各iOS版本之间子类对父类方法重写的情况,hook正确的方法,否则可能会出现循环调用最终程序崩溃的情况。

额外的,组件还对NSObjectvalueForUndefinedKey:valueForKey:进行了hook处理。

NSTimer

主要为了解决NSTimerTureTarget相互强引用导致不手动调用invalidate方法TureTarget无法自动释放的问题。加入中间类TimerStub作为TimerSubTarget,并且储存真实的TrueTargetSEL,其中TimerStub弱引用真实的TrueTarget,保证其可以自由的释放。每当目标函数触发时去检查TrueTarget是否还存在,存在的话去执行目标函数,不存在的话调用- [Timer invalidate]去释放。

time

0x4. 上报

MTCrashProtector捕获处理的异常可以集中进行上报,宿主App通过设置

typedef void(^MTCrashProtectorReporterExecutionBlock)(NSError *error);
@property (nonatomic, copy) MTCrashProtectorReporterExecutionBlock reporterExecutionBlock;

reporterExecutionBlock来实现真正的上报逻辑。 error.UserInfo包含的信息如下:

{
    "reason": "xxxx",
    "stack": "0	MTDZ\nCLSUserLoggingRecordError......"
}

实现例子:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // ...
    [Fabric with:@[[Crashlytics class]]];
    [Crashlytics.sharedInstance setDebugMode:YES];
    [MTCrashProtectorReporter.shareInstance setReporterExecutionBlock:^(NSError * _Nonnull error) {
        // 上报拦截的异常
        [Crashlytics.sharedInstance recordError:error];
    }];
    return YES;
}