JSPatch动态补丁

基于OC的动态特性和JavaScriptCore,实现的热补丁修复的JSPatch,的一些学习

2016-05-22 | 阅读

先总结一下,再研究原理,最后再看具体用法

首先,JSPatch 符合Apple规则,在iOS Developer Program License Agreement里3.3.2提到不可动态下发可执行代码,但通过通过JavaSriptCore.frameworkWebKit执行的代码除外,所以我们可以下发动态的JS代码.

3.3.2 An Application may not download or install executable code. Interpreted code may only be used in an Application if all scripts, code and interpreters are packaged in the Application and not downloaded. The only exception to the foregoing is scripts and code downloaded and run by Apple’s built-in WebKit framework.

然后,原理就很简单了,使用JavaScriptCore来进行JS与OC的互调,来实现方法的替换,作者对实现的原理有很详细的说明了.原理的学习的话,那我只对几个作者认为的重点问题进行一下复述吧 :

基本原理的话,因为OC是动态语言,可以动态添加方法,替换方法,添加类等等,这部分内容还是要仔细研究OC runtime会有更深刻的理解.

2017年,苹果禁用了jspatch

方法的调用

在JSPatch的方法中调用OC的函数的写法类似 :

UIView.alloc().init()

原理是,在传入JS文件时,先使用正则表达式将JS代码的方法调用进行转换,转换成以下形式:

UIView.__c('alloc')().__c('init')()

这样就很明显了,通过一个__c的函数来统一通过调用方法,传入的参数为方法名和方法的参数.关于正则表达式部分的代码:

static NSString *_regexStr = @"(?<!\\\\)\\.\\s*(\\w+)\\s*\\(";
static NSString *_replaceStr = @".__c(\"$1\")(";
if (!_regex) {
    _regex = [NSRegularExpression regularExpressionWithPattern:_regexStr options:0 error:nil];
}
NSString *formatedScript = [NSString stringWithFormat:@";(function(){try{ %@ }catch(e){_OC_catch(e.message, e.stack)}})();", [_regex stringByReplacingMatchesInString:script options:0 range:NSMakeRange(0, script.length) withTemplate:_replaceStr]];

然后就是对象的传递,参考上文JSValue的传递的学习.当在JS函数中调用__c函数时,会先检查对象是否是一个OC对象,所以通过require方法声明的对象,都是有一个属性__isCls,而从OC传入JS的对象,也都通过将对象封装成一个NSDictionary,赋值到__obj上,然后在__c函数时,每次检测这两个属性,一个表示其是OC中的类,一个表示是OC中的对象.封装对象通过以下方法:

static NSDictionary *_wrapObj(id obj) {
    return @{@"__obj": obj};
}

最后是最关键的一个问题,方法的参数传递.参数传递时,由JS传递给OC的都是对象,如调用view.setAlpha(0.5),传递的0.5是 NSNumber.OC中通过调用NSMethodSignature来知道方法的参数实际需要的是float类型,然后再将NSNumber转为float类型,供OC方法调用.

方法替换

JSPatch用defineClass接口来替换任意类的方法,方法替换的实现是一个重点,JS中调用OC的方法,如何传递参数,是其中的重点.

一开始JSPatch采用了va_list的可变参数的实现:

static void commonIMP(id slf, ...)
  va_list args;
  va_start(args, slf);
  NSMutableArray *list = [[NSMutableArray alloc] init];
  NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:selector];
  NSUInteger numberOfArguments = methodSignature.numberOfArguments;
  id obj;
  for (NSUInteger i = 2; i < numberOfArguments; i++) {
      const char *argumentType = [methodSignature getArgumentTypeAtIndex:i];
      switch(argumentType[0]) {
          case 'i':
              obj = @(va_arg(args, int));
              break;
          case 'B':
              obj = @(va_arg(args, BOOL));
              break;
          case 'f':
          case 'd':
              obj = @(va_arg(args, double));
              break;
          …… //其他数值类型
          default: {
              obj = va_arg(args, id);
              break;
          }
      }
      [list addObject:obj];
  }
  va_end(args);
  [function callWithArguments:list];
}

但在arm64位的机型上,va_list的结构变了,一些参数被放在寄存器上,一些参数被放在栈上,所以没法像32位上用同样的写法来获取va_list中的参数了,参考这篇文章.

所以作者使用forwardInvocation来实现功能.在objc_msgSend的处理流程中,如果没有找到消息,会一步一步执行方法-resolveInstanceMethod:, -forwardingTargetForSelector:, -methodSignatureForSelector:,-forwardInvocation:.在forwardInvocation中会使用到NSInvocation对象,这个对象保存了方法调用的所有信息,可以从中获取所有的参数类型和参数值.然后具体的实现流程,以修改viewWillAppear为例

  1. 将要修改的方法使用class_replaceMethod()指向 _objc_msgForward,这个方法来负责来实现消息转发的流程,resolveInstanceMethod:, -forwardingTargetForSelector:, -methodSignatureForSelector:,-forwardInvocation:,最后封装一个NSInvocation对象,然后调用-forwardInvocation:.
  2. UIViewController添加-ORIGviewWillAppear:-_JPviewWillAppear: 两个方法,前者指向原来的IMP实现,后者是新的实现,里面包含了回调JS函数.
  3. 改写-forwardInvocation,将NSInvocation对象中的参数解析出来,并结合_JPviewWillAppear函数的参数做一些转换,然后调用_JPviewWillAppear函数. 在改写这个函数时,有可能会有其他地方也对forwardInvocation解进行转发,所以要在这里做判断,如果转发给改写了的方法,继续走下去,如果不是,则调用-ORIGforwardInvocation:走原来的流程.

对于类的新增方法,JSPatch中新增的方法参数都是id类型的,即对象类型.但要实现protocol中的方法,所以要兼容protocol中带有非id类型的参数方法,在新增protocol中已定义的方法时,参数类型会按照protocol中的定义去实现:

defineClass("JPViewController: UIViewController <UIAlertViewDelegate>", {
  alertView_clickedButtonAtIndex: function(alertView, buttonIndex) {
    console.log('clicked index ' + buttonIndex)
  }
})

JSPatch会去解析protocol,来判断是普通新增方法,还是protocol的新增方法.其他的实现细节,可以去JSPatch的Wiki上查看.

基本用法

基础用法

JS 断点调试

JSPatch可以使用Safari自带的调试工具对JS脚本进行断点调试.

启动APP后,在Safari -> 开发 -> 机器 -> JSContext,就可以进行断点调试了.