“JSPatch 是一个 iOS 动态更新框架,只需在项目中引入极小的引擎,就可以使用 JavaScript 调用任何 Objective-C 原生接口,获得脚本语言的优势:为项目动态添加模块,或替换项目原生代码动态修复 bug。”
JSPatch 的实现原理可参考原作者(bang590)的相关文章。本文给出 JSPatch 部分代码分析纪录。
一、OC (Objective-C) 运行时
OC 是运行时语言,即能够在程序运行的时候执行编译后的代码。OC 中的方法调用通过消息转发(objc_msgSend)实现,即先根据方法名寻找到方法实现,再调用方法实现。并且,通过 Method Swizzling 技术,可以动态修改方法名和方法实现的对应关系。
1. 消息转发
objc_msgSend 函数的重要工作是根据某个方法的 selector 找到相应的方法实现(IMP)。IMP 类型即为函数指针。
_objc_msgForward 是 IMP 类型,当 objc_msgSend 未找到某个 selector 的 IMP,会使用该 IMP 替代。_objc_msgForward 会做消息转发的工作。
_objc_msgForward 消息转发会依次调用如下的方法。
1 | + (BOOL)resolveInstanceMethod:(SEL)name; / + (BOOL)resolveClassMethod:(SEL)name; |
2. Method Swizzling
Method Swizzling 用于修改目标类的方法名和方法实现的对应关系,比如可以增加新方法、替换已有方法的方法实现。
常用函数如下所示:
1 | BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types); |
下面代码片段是一种情况下的使用示例:
1 | SEL originalSelector = @selector(viewWillAppear:); |
二、JavaScriptCore.framework
JSCore 是从 UIWebView 提取出的 JS 解析引擎,封装了 JS 和 OC 桥接的 OC API,使得不依赖于 UIWebView 便能实现 JS 环境和 OC 环境的通信。JSCore 提供不同运行环境之间方法互调的接口,并对不同类型的数据格式进行封装。
1. 方法互调
JSCore 提供了多种方式实现 JS 和 OC 的通信,最常用的方式是使用 Block,如下代码所示:
1 | JSContext *context = [[JSContext alloc] init]; |
JSContext 是 JS 的运行环境。上述代码中,在 JSContext 中声明了名为 log 的函数,该函数的实现是 OC block,实现了在 JS 环境中调用 OC 方法。
[context evaluateScript:@"log()"]
是调用名为 log 的 JS 函数,实现了在 OC 环境中调用 JS 函数。
除了通过 Block 通信,JSCore 还提供 JSExport。JSExport 是协议,JS 能方便的操纵实现了该协议的 OC 对象。
2. 类型转换关系
JS 和 OC 环境通信还伴随着数据的传递,下表是各类型数据的对应关系。
-----------------------------------------------------------------------------
| Objective-C Types | Javascript Types |
|-------------------------------------------------------|-------------------|
| nil | undefined |
| NSNull | null |
| NSString | String |
| NSNumber | Number, Boolean |
| NSDictionary | Object |
| NSArray | Array |
| NSDate | Date |
| object (id or AnyObject) / class (Class or AnyClass) | Object |
| Structure types: NSRange, CGRect, CGPoint, CGSize | Object |
| Objective-C Block | Function |
-----------------------------------------------------------------------------
三、方法调用
JSPatch 通过运行时系统,将错误的 OC 代码逻辑替换为正确的 JS 代码逻辑。本小节描述 OC 方法被替换后,方法调用流程如何发生,包括如下两种情况,调用修改后的方法(即 JS 函数)和调用原始方法(即 JS 环境中调用 OC 方法)。
1. 调用修改后方法
上图描述了调用修改后方法的程序执行流程。
每个方法可以看作两部分组成,selector 和 IMP,分别表示方法的名称和方法的实现。JSPatch 希望将类中错误方法实现修改为 JS 实现时,会执行两处方法修改。一是,将错误方法的实现修改为 _objc_msgForward;二是,将该类的 forwardInvocation 实现替换为自定义的方法实现(JPForwardInvocation)。这样,在调用该错误方法时便会执行到该类的 forwardInvocation 方法中,而 JPForwardInvocation 会判断是否执行相应的 JS 实现。
2. 调用原始方法
上图描述 JS 环境中调用 OC 方法是如何发生的,即 JS 如何调用 OC 类中的任意方法。
JSPatch 通过 __c 元函数实现 JS 调用 OC 方法。如上图所示,整个流程可以分为三个环节,JSPatch 引擎开始、注入修复脚本和程序运行。
开始 JSPatch 引擎时,在 OC 中定义通用回调接口,并在 JS 环境中定义元函数 __c,__c 负责调用通用回调接口。
注入修复脚本时,JSPatch 会修改脚本中 JS 函数的调用方式。使用正则表达式,将所有函数调用交由元函数 __c 解析。
程序运行时,当调用某个元函数 __c 时,__c 会转发到 OC 的通用回调接口,通用回调接口通过类名、方法名和参数实现调用流程,并将结果反馈给 JS 环境。
四、问题发现与解释
在项目中引入了 JSPatch,利用其能力实现了不少针对 app 线上问题的热修复。在使用过程中发现一些问题,并做了调研。
1. 在 OC 中使用快速遍历访问NSArray 中的元素,转换为 JS 后,快速遍历无法得到数组元素。
JSPatch 对 OC 中的数组、字典、字符串进行了封装,在 JS 中被封装成 JPBoxing 对象,而不是原生的 JS 数组、字典、字符串。这种处理使得对应的数据对象在 OC 和 JS 之间传输时,仍能保持其在 OC 中的特性。具体原因见JSPatch-实现原理详解-JPBoxing。因此,在JS中快速遍历时,访问的是相应的JPBoxing对象的可枚举属性。
JS中的数组 JPBoxing 对象可调用方法 toJS(),获取相应的原生 JS 数组。但此时快速遍历的元素是 JS 数组的下标,不同于 OC 中的快速遍历,仍然需要额外的操作才能获得数组元素。
JS中快速遍历的顺序依赖于具体实现,不能保证永远按照索引顺序访问。因此最好使用for(;;)语法访问数组。
2. 通过调用未实现方法以测试自定义的 forwardInvocation 时,在一些情况下直接抛出方法未实现错误,而不是执行 forwardInvocation 中逻辑。
Objective-C 的消息转发会调用一系列方法。在调用 forwardInvocation 之前,methodSignatureForSelector 会被调用。如果 methodSignatureForSelector 能够返回有效的 NSMethodSignature 对象,forwardInvocation 会在后续步骤中被调用,否则 forwardInvocation 将不会被调用(因为 forwardInvocation 的 NSInvocation 参数的形成依赖于 methodSignatureForSelector 返回的 NSMethondSignature 对象)。
所以有两种方法解决这个问题。
a. 在待测试的类中添加方法,并将该方法的实现设置为空(_objc_msgForward)。此时 methodSignatureForSelector 能够基于该方法生成合适的 NSMethodSignature 对象。
b. 直接在待测试类中重载 methodSignatureForSelector 方法,手动构造并返回一个有效的 NSMethodSignature 对象。