JSPatch 在社区的推动下不断在优化改善,这篇文章总结下这几个月以来 JSPatch 的一些新特性,以及它们的实现原理。包括脱离锁的 performSelectorInOC 接口,支持可变参数方法调用,给新增方法指定类型的 defineProtocol 接口,支持重写 dealloc 方法,以及两个扩展 JPCleaner 和 JPLoader。
JavaScript 语言是单线程的,在 OC 使用 JavaScriptCore 引擎执行 JS 代码时,会对 JS 代码块加锁,保证同个 JSContext 下的 JS 代码都是顺序执行。所以调用 JSPatch 替换的方法,以及在 JSPatch 里调用 OC 方法,都会在这个锁里执行,这导致三个问题:
为解决这些问题,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 ,而 -methodB 里 self.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 文档里还有关于 死锁的例子 ,有兴趣可以看看。
一直以来像 NSString 的 + (instancetype)stringWithFormat:(NSString *)format, … 这样参数个数可变的方法是不能在 JSPatch 动态调用的,原因是 JSPatch 调用 OC 方法时,是根据 JS 传入的方法名和参数组装成 NSInvocation 动态调用,而 NSInvocation 不支持调用参数个数可变的方法。
后来 @wjacker 换了种方式,用 objc_msgSend 的方式支持了可变参数方法的调用。之前一直想不到使用 objc_msgSend 是因为它不适用于动态调用,在方法定义和调用上都是固定的:
需要事先定义好调用方法的参数类型和个数,例如想通过 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; ...
解决上述参数类型和个数定义问题后,还有调用的问题,objc_msgSend 不像 NSInvocation 可以在运行时动态添加组装传入的参数个数,objc_msgSend 则需要在编译时确定传入多少个参数。这对于1-10个参数的调用,不得不用 if else 写10遍调用语句,另外根据方法定义的固定参数个数不一样,还需要调用不同的 new_msgSend 函数,所以需要写10!条调用,于是有了这样的大长篇: gist 。后来用宏格式化了一下,会好看一点: 效果
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"; }, }
具体实现原理原作者已写得挺清楚,参见 这里 。
之前 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,做相应处理了。
有些 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 就可以了。详见源码。
JSPatch 脚本需要后台下发,客户端需要一套打包下载/执行的流程,还需要考虑传输过程中安全问题,JPLoader 就是帮你做了这些事情。
下载执行脚本很简单,这里主要做的事是保证传输过程的安全,JPLoader 包含了一个打包工具 packer.php ,用这个工具对脚本文件进行打包,得出打包文件的 MD5,再对这个MD5 值用私钥进行 RSA 加密,把加密后的数据跟脚本文件一起大包发给客户端。JPLoader 里的程序对这个加密数据用私钥进行解密,再计算一遍下发的脚本文件 MD5 值,看解密出来的值跟这边计算出来的值是否一致,一致说明脚本文件从服务器到客户端之间没被第三方篡改过,保证脚本的安全。对这一过程的具体描述详见旧文JSPatch部署安全策略。对 JPLoader 的使用方式可以参照 wiki 文档