欢迎访问 生活随笔!

生活随笔

当前位置: 首页 > 前端技术 > javascript >内容正文

javascript

JSPatch近期新特性解析

发布时间:2025/4/5 javascript 64 豆豆
生活随笔 收集整理的这篇文章主要介绍了 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 childThreadvar 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 childThreadvar 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 herereturn @"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 文档

总结

以上是生活随笔为你收集整理的JSPatch近期新特性解析的全部内容,希望文章能够帮你解决所遇到的问题。

如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。