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
,而 -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 文档里还有关于死锁的例子,有兴趣可以看看。
可变参数方法调用
一直以来这样参数个数可变的方法是不能在 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", {})
这样做的作用是,当添加 Protocol 里定义的方法,而类里没有实现的方法时,参数类型不再全是 id,而是会根据 Protocol 里定义的参数类型去添加。
于是若想添加一些指定参数类型的方法,只需动态新增一个 protocol,定义新增的方法名和对应的参数类型,再在 defineClass 定义里加上这个 protocol 就可以了。这样的不污染 defineClass()
的接口,也没有更多概念,十分简洁地解决了这问题。范例:
defineProtocol('JPDemoProtocol',{
stringWithRect_withNum_withArray: {
paramsType:"CGRect, float, NSArray*",
returnType:"id",
},
}
defineClass('JPTestObject : NSObject ', {
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 文档