JSPatch近期新特性解析

JSPatch在社区的推动下不断在优化改善,这篇文章总结下这几个月以来 JSPatch 的一些新特性,以及它们的实现原理。

performSelectorInOC

JavaScript 语言是单线程的,在 OC 使用 JavaScriptCore 引擎执行 JS 代码时,会对 JS 代码块加锁,保证同个 JSContext 下的 JS 代码都是顺序执行。所以调用 JSPatch 替换的方法,以及在 JSPatch 里调用 OC 方法,都会在这个锁里执行,这导致三个问题:

  • JSPatch替换的方法无法并行执行,如果如果主线程和子线程同时运行了 JSPatch 替换的方法,这些方法的执行都会顺序排队,主线程会等待子线程的方法执行完后再执行,如果子线程方法耗时长,主线程会等很久,卡住主线程。

  • 某种情况下,JavaScriptCore 的锁与 OC 代码上的锁混合时,会产生死锁。

  • UIWebView 的初始化会与 JavaScriptCore 冲突。若在 JavaScriptCore 的锁里(第一次)初始化 UIWebView 会导致 webview 无法解析页面。

为解决这些问题,JSPatch 新增了 .performSelectorInOC(selector, arguments, callback) 接口,可以在执行 OC 方法时脱离 JavaScriptCore 的锁,同时又保证程序顺序执行。

举个例子:

defineClass('JPClassA', {
  methodA: function() {
    //run in mainThread
  },
  methodB: function() {
      //run in childThread
      var limit = 20;
      var data = self.readData(limit);
      var count = data.count();
      return {data: data, count: count};
  }
})

上述例子中若在主线程和子线程同时调用 -methodA-methodB,而 -methodBself.readData(limit) 这句调用耗时较长,就会卡住主线程方法 -methodA 的执行,对此可以让这个调用改用 .performSelectorInOC() 接口,让它在 JavaScriptCore 锁释放后再执行,不卡住其他线程的 JS 方法执行:


defineClass('JPClassA', {
  methodA: function() {
    //run in mainThread
  },
  methodB: function() {
      //run in childThread
      var limit = 20;
      return self.performSelectorInOC('readData', [limit], function(ret) {
          var count = ret.count();
          return {data: ret, count: count};
      });
  }
})

这两份代码在调用顺序上的区别如下图:

第一份代码对应左边的流程图,-methodB 方法被替换,当 OC 调用到 -methodB 时会去到 JSPatch 核心的 JPForwardInvocation 方法里,在这里面调用 JS 函数 -methodB,调用时 JavascriptCore 加锁,接着在 JS 函数里做这种处理,调用 reloadData() 函数,进而去到 OC 调用 -reloadData 方法,这时 -reloadData 方法是在 JavaScriptCore 的锁里调用的。直到 JS 函数执行完毕 return 后,JavaScriptCore 的才解锁,结束本次调用。

第二份代码对应右边的流程图,前面是一样的,调用 JS 函数 -methodB,JavaScriptCore 加锁,但 -methodB 函数在调用某个 OC 方法时(这里是reloadData()),不直接去调用,而是直接 return 返回一个对象 {obj},这个{obj}的结构如下:

{
__isPerformInOC:1,
obj:self.__obj,
clsName:self.__clsName,
sel: args[0],
args: args[1],
cb: args[2]
}

JS 函数返回这个对象,JS 的调用就结束了,JavaScriptCore 的锁也就释放了。在 OC 可以拿到 JS 函数的返回值,也就拿到了这个对象,然后判断它是否 __isPerformInOC=1 对象,若是就根据对象里的 selector / 参数等信息调用对应的 OC 方法,这时这个 OC 方法的调用是在 JavaScriptCore 的锁之外调用的,我们的目的就达到了。

执行 OC 方法后,会去调 {obj} 里的的 cb 函数,把 OC 方法的返回值传给 cb 函数,重新回到 JS 去执行代码。这里会循环判断这些回调函数是否还返回 __isPerformInOC=1 的对象,若是则重复上述流程执行,不是则结束。

整个原理就是这样,相关代码在 这里这里,实现起来其实挺简单,也不会对其他流程和逻辑造成影响,就是理解起来会有点费劲。

performSelectorInOC 文档里还有关于死锁的例子,有兴趣可以看看。

可变参数方法调用

一直以来这样参数个数可变的方法是不能在 JSPatch 动态调用的:

- (instancetype)initWithTitle:(nullable NSString *)title message:(nullable NSString *)message delegate:(nullable id)delegate cancelButtonTitle:(nullable NSString *)cancelButtonTitle otherButtonTitles:(nullable NSString *)otherButtonTitles, ...

原因是 JSPatch 调用 OC 方法时,是根据 JS 传入的方法名和参数组装成 NSInvocation 动态调用,而 NSInvocation 不支持调用参数个数可变的方法。

后来 @wjacker 换了种方式,用 objc_msgSend 的方式支持了可变参数方法的调用。之前一直想不到使用 objc_msgSend 是因为它不适用于动态调用,在方法定义和调用上都是固定的:

1.定义

需要事先定义好调用方法的参数类型和个数,例如想通过 objc_msgSend 调用方法

- (int)methodWithFloat:(float)num1 withObj:(id)obj withBool:(BOOL)flag

那就需要定义一个这样的c函数:

int (*new_msgSend)(id, SEL, float, id, BOOL) = (int (*)(id, SEL, float, id, BOOL)) objc_msgSend;

才能通过 new_msgSend 调用这个方法。而这个过程是无法动态化的,需要编译时确定,而各种方法的参数/返回值类型不同,参数个数不同,是没办法在编译时穷举写完的,所以不能用于所有方法的调用。

而对于可变参数方法,只支持参数类型和返回值类型都是 id 类型的方法,已经可以满足大部分需求,所以让使用它变得可能:

id (*new_msgSend1)(id, SEL, id,...) = (id (*)(id, SEL, id,...)) objc_msgSend;

这样就可以用 new_msgSend1 调用固定参数一个,后续是可变参数的方法了。实际上在模拟器这个方法也可以支持固定参数是N个id的方法,也就是已经满足我们调用可变参数方法的需求了,但根据@wjacker 和 @Awhisper 的测试,在真机上不行,不同的固定参数都需要给它定义好对应的函数才行,官网文档对这点略有说明。于是,多了一大堆这样的定义,以应付1-10个固定参数的情况:

id (*new_msgSend2)(id, SEL, id,id,...) = (id (*)(id, SEL, id,id,...)) objc_msgSend;
id (*new_msgSend3)(id, SEL, id,id,id,...) = (id (*)(id, SEL, id,id,id,...)) objc_msgSend;
id (*new_msgSend4)(id, SEL, id,id,id,id,...) = (id (*)(id, SEL, id,id,id,id,...)) objc_msgSend;
...

2.调用

解决上述参数类型和个数定义问题后,还有调用的问题,objc_msgSend 不像 NSInvocation 可以在运行时动态添加组装传入的参数个数,objc_msgSend 则需要在编译时确定传入多少个参数。这对于1-10个参数的调用,不得不用 if else 写10遍调用语句,另外根据方法定义的固定参数个数不一样,还需要调用不同的 new_msgSend 函数,所以需要写10!条调用,于是有了这样的大长篇(gist代码)。后来用宏格式化了一下,会好看一点。

defineProtocol

JSPatch 为一个类新增原本 OC 不存在的方法时,所有的参数类型都会定义为 id 类型,这样实现是因为这种在 JS 里新增的方法一般不会在 OC 上调用,而是在 JS 上用,JS 可以认为一切变量都是对象,没有类型之分,所以全部定义为 id 类型。

但在实际使用 JSPatch 过程中,出现了这样的需求:在 OC 里 .h 文件定义了一个方法,这个方法里的参数和返回值不都是 id 类型,但是在 .m 文件中由于疏忽没有实现这个方法,导致其他地方调用这个方法时找不到这个方法造成 crash,要用 JSPatch 修复这样的 bug,就需要 JSPatch 可以动态添加指定参数类型的方法。

实际上如果在 JS 用 defineClass() 给类添加新方法时,通过某些接口把方法的各参数和返回值类型名传进去,内部再做些处理就可以解决上述问题,但这样会把 defineClass 接口搞得很复杂,不希望这样做。最终 @Awhisper 想出了个很好的方法,用动态新增 protocol 的方式支持。

首先 defineClass 是支持 protocol 的:

defineClass("JPViewController: UIViewController<UIScrollViewDelegate, UITextViewDelegate>", {})

这样做的作用是,当添加 Protocol 里定义的方法,而类里没有实现的方法时,参数类型不再全是 id,而是会根据 Protocol 里定义的参数类型去添加。

于是若想添加一些指定参数类型的方法,只需动态新增一个 protocol,定义新增的方法名和对应的参数类型,再在 defineClass 定义里加上这个 protocol 就可以了。这样的不污染 defineClass() 的接口,也没有更多概念,十分简洁地解决了这问题。范例:


defineProtocol('JPDemoProtocol',{
   stringWithRect_withNum_withArray: {
       paramsType:"CGRect, float, NSArray*",
       returnType:"id",
   },
}

defineClass('JPTestObject : NSObject <JPDemoProtocol>', {
    stringWithRect_withNum_withArray:function(rect, num, arr){
        //use rect/num/arr params here
        return @"success";
    },
}

具体实现原理原作者已写得挺清楚,参见这里

支持重写dealloc方法

之前 JSPatch 不能替换 -dealloc 方法,原因:

1.按之前的流程,JS 替换 -dealloc 方法后,调用到 -dealloc 时会把 self 包装成 weakObject 传给 JS,在包装的时候就会出现以下 crash:

Cannot form weak reference to instance (0x7fb74ac26270) of class JPTestObject. It is possible that this object was over-released, or is in the process of deallocation.

意思是在 dealloc 过程中对象不能赋给一个 weak 变量,无法包装成一个 weakObject 给 JS。

2.若在这里不包装当前调用对象,或不传任何对象给 JS,就可以成功执行到 JS 上替换的 dealloc 方法。但这时没有调用原生 dealloc 方法,此对象不会释放成功,会造成内存泄露。

-dealloc 被替换后,原 -dealloc 方法 IMP 对应的 selector 已经变成 ORIGdealloc,若在执行完 JS 的 dealloc 方法后再强制调用一遍原 OC 的 ORIGdealloc ,会crash。猜测原因是 ARC 对 -dealloc 有特殊处理,执行它的 IMP(也就是真实函数)时传进去的 selectorName 必须是 dealloc,runtime 才可以调用它的 [super dealloc],做一些其他处理。

到这里我就没什么办法了,后来 @ipinka 来了一招欺骗 ARC 的实现,解决了这个问题:

1.首先对与第一个问题,调用 -dealloc 时 self 不包装成 weakObject,而是包装成 assignObject 传给 JS,解决了这个问题。

2.对于第二个问题,调用 ORIGdealloc 时因为 selectorName 改变,ARC 不认这是 dealloc 方法,于是用下面的方式调用:

Class instClass = object_getClass(assignSlf);
Method deallocMethod = class_getInstanceMethod(instClass, NSSelectorFromString(@"ORIGdealloc"));
void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod);
        originalDealloc(assignSlf, NSSelectorFromString(@"dealloc"));

做的事情就是,拿出 ORIGdealloc 的 IMP,也就是原 OC 上的 dealloc 实现,然后调用它时 selectorName 传入 dealloc,这样 ARC 就能认得这个方法是 dealloc,做相应处理了。

扩展

JPCleaner即时回退

有些 JSPatch 使用者有这样的需求:脚本执行后希望可以回退到没有替换的状态。之前我的建议使用者自己控制下次启动时不要执行,就算回退了,但还是有不重启 APP 即时回退的需求。但这个需求并不是核心功能,所以想办法把它抽离,放到扩展里了。

只需引入 JPCleaner.h,调用 +cleanAll 接口就可以把当前所有被 JSPatch 替换的方法恢复原样。另外还有 +cleanClass: 接口支持只回退某个类。这些接口可以在 OC 调用,也可以在 JS 脚本动态调用:

[JPCleaner cleanAll]
[JPCleaner cleanClass:@“JPViewController”];

实现原理也很简单,在 JSPatch 核心里所有替换的方法都会保存在内部一个静态变量 _JSOverideMethods 里,它的结构是 _JSOverideMethods[cls][selectorName] = jsFunction。我给 JPExtension 添加了个接口,把这个静态变量暴露给外部,遍历这个变量里保存的 class 和 selectorName,把 selector 对应的 IMP 重新指向原生 IMP 就可以了。详见源码。

JPLoader

JSPatch 脚本需要后台下发,客户端需要一套打包下载/执行的流程,还需要考虑传输过程中安全问题,JPLoader 就是帮你做了这些事情。

下载执行脚本很简单,这里主要做的事是保证传输过程的安全,JPLoader 包含了一个打包工具 packer.php,用这个工具对脚本文件进行打包,得出打包文件的 MD5,再对这个MD5 值用私钥进行 RSA 加密,把加密后的数据跟脚本文件一起大包发给客户端。JPLoader 里的程序对这个加密数据用私钥进行解密,再计算一遍下发的脚本文件 MD5 值,看解密出来的值跟这边计算出来的值是否一致,一致说明脚本文件从服务器到客户端之间没被第三方篡改过,保证脚本的安全。对这一过程的具体描述详见旧文 JSPatch部署安全策略。对 JPLoader 的使用方式可以参照 wiki 文档

    原文作者:bang590
    原文地址: https://segmentfault.com/a/1190000004612317
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞