JSPatch 虽然被禁,但是它的源码是非常值得学习的。可以说,是我看过的各个库中设计的最巧妙也是知识点最多的开源库。非常有学习价值。
这是这个源码解析的第一部分。主要通过一个 Demo 演示如何定义 JS 修复文件。
Demo 演示
JSPatch 的主要功能涵盖于三个文件中:JPEngine.h
,JPEngine.m
,JSPatch.js
。由于语法规则比较多,这里就不进行用法的介绍了。下面跟着 JSPatch 附带的 demo 了解 JSPatch 的修复过程。
修复的 js 文件
通过 js 进行热修复,demo 中定义了要修复的类为 JPTableViewController
,它是一个 UITableViewController。在这个 修复类中,增加了 data 这个 property,并且提供了完整的 UITableView 显示所需要的方法:
1 | defineClass('JPTableViewController : UITableViewController <UIAlertViewDelegate>', ['data'], { |
加载修复的 js 文件
加载修复的 js 文件是在应用启动完毕的回调中完成的:
1 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { |
先调用 startEngine
初始化 JSPatch,这一步的作用主要是创建一个 JSContext,并在其中注入提供给 JS 调用的方法。随后加载提供修复的 js 文件。通过刚才创建的 JSContext 执行。
当执行到 JPTableViewController
的时候,修复方法替换了原本的各种 UITableView 回调执行。
执行流程
初始化 JSContext
初始化 JSPatch 通过 startEngine
方法完成,这个方法为 JSContext 注入了多种方法,并且初始化了 OC 端的各个属性。方法非常长,因此做了非常详尽的注释:
1 | // SECTION 开始JSPatch |
注入 JSContext 的方法会在之后用到的时候详细解释。在注入方法完成后,执行了 JSPatch js 端的初始化代码。
初始化 js 部分
JSPatch 的 js 端初始化部分主要做了两件事:
- 为 js 中的 Object 对象增加方法
- 为 JSContext 的全局对象增加各种属性与方法
1 | // Object 增加的几个方法: |
改写修复文件
现在开始执行修复文件,拿到 js 代码后调用了[JPEngine evaluteScript:script];
方法。这个方法兜兜转转来到了 JPEngine 的 _evaluteScript:withSourceURL:
方法中。方法实现很简单,但是却是实现修复的第一个重点:
1 | // 修改 js 修复文件方法的调用方式 |
在 js 中执行形如 UIView.alloc().init()
这样的调用方式需要预先将要执行的方法定义在对象中,否则一定会报错:UIView.alloc is not a function
。但是预先定义的方式工作量巨大且初始化的效率很低,不适用于涉及到大量类的修复。
换一种思路,由于 OC 动态性的便利,使得我们只需要从 js 端传递要执行的对象,方法名和参数,就可以通过动态调用的方式执行相应方法。而传递这三个要素,只要一个通用方法就可以实现。
JSPatch 作者考虑到开发者的习惯,仍然保留了 UIView.alloc().init()
这样的链式写法。但是在初始化的时候对实现内容作了正则替换,将匹配到的方法名取出,改为调用一个通用方法 __c()
,并将方法名作为参数传入。因此,做了正则替换后的实现变为:UIView.__c('alloc')().__c('init')()
。
定义要重写的类方法与实例方法
js 端定义类
要修复bug那么肯定要定位到要修复的类的某个方法。js 全局对象下的方法 defineClass
提供了这个入口。方法比较长,总共做了几件事,如下所示:
- 设置关联属性
- hook 定义的方法,并将修改后的方法传给 oc
- 将该类的方法实现保存到
__ocCls
中 - 创建全局类对象
接下来我们一个一个看。
设置关联属性
1 | global.defineClass = function(declaration, properties, instMethods, clsMethods) { |
oc 的属性都需要提供其 get set 方法。因此,这一段代码主要就是查看实例方法中有没有相应的 get set 方法,没有的话就添加。关联属性的 get set 方法通过 _propertiesGetFun
和 _propertiesSetFun
完成:
1 | // 返回属性的 get 方法 |
oc 端设置关联对象的方法如下:
1 | context[@"_OC_getCustomProps"] = ^id(JSValue *obj) { |
可以看到,无论 js 端设置了多少 property,在 oc 端,都作为一个属性保存在 key 为 kPropAssociatedObjectKey
的关联属性中。oc 端拿到了这个关联属性后返回给 js 端,js 端的对象则以 _ocProps
属性接收。两者指向的地址相同,因此,之后对于 property 的改变只需直接修改 js 端对象的 _ocProps
属性就行。
hook 方法并传给 oc
oc 中实现 aop 非常麻烦,而 js 端直接操作函数指针就可以完成。
1 | global.defineClass = function(declaration, properties, instMethods, clsMethods) { |
主要过程在 _formatDefineMethods
中:
1 | // 对 js 端定义的 method 进行预处理,取出方法的参数个数。hook 方法,预处理方法的参数,将其转为 js 对象。 |
这里存在三个要点:
- 这个方法是要添加到 oc 端的,oc 端需要知道参数个数,但是 oc 端无法直接获取,只能通过解析方法名。因此就把解析参数个数的工程放在了 js 中进行。js 端在将方法传给 oc 前,先把参数个数拿到,然后以数组形式传递。
- oc 调用 js 方法时传递的参数需要预处理,比如调用对象原本是 js 传递过去的,就会被
{__obj: xxx}
包裹,再比如 oc 的空对象的处理。 - oc 传来的参数第一个是方法的调用上下文。因此需要把调用上下文设置给全局的 self,以便在方法中使用。在调用方法前,还需要把这个上下文和 Selector 从参数列表中取出,因为 js 调用的时候是不需要这个参数的。
js 方法预处理完成后,就会调用 _OC_defineClass
方法,在 OC 中添加相应方法。这个方法是 JSPatch 中最重要的方法了,在后面会解释,先跳过。
将该类的方法实现保存到 __ocCls
中
对于定义好的方法,js 端需要把这些方法保存起来。毕竟真正的实现还是在 js 端进行的。因此定义了一个所有类的方法的暂存地:__ocCls
。所有的类的所有方法都会被保存在这个对象中。
1 | global.defineClass = function(declaration, properties, instMethods, clsMethods) { |
如果父类实现了某些方法,那么子类中需要先把这些方法保存起来。这样如果调用了子类没有实现的这些方法的时候就可以直接调用父类相应的实现。
这个想法是对的,但是我认为有点问题在于,这样的话就需要先对父类 defineClass
才能定义子类,否则子类就没法拿到父类实现的方法了。因此,定义类的时候一定要注意,先定义父类的 class,再定义子类的。
_setupJSMethod
其实也是一个很简单的方法,其实就是把上下文名从 this 切换为 self:
1 | // 替换 this 为 self |
OC 中会保存一份方法实现在
_JPOverideMethods
字典中。还会在 js 端保存在__ocCls
中。保存两份的目的是在 JS 端执行定义的方法并且调用到其他定义方法的时候,可以不用重走一遍 OC 的消息转发,而是直接调用 js 端方法。这样的好处有两点:
- 是不用走消息转发,加快执行速度。
- 区分了调用场景后更容易设置调用上下文。对于 OC,调用的上下文是通过参数传递的,拿第一个参数将其赋给 global.self。对于 js,调用的上下文时 this,把发赋给 global.self
通过 require 方法创建全局类对象
defineClass
的最终返回了一个 require()
方法产生的对象:
1 | global.defineClass = function(declaration, properties, instMethods, clsMethods) { |
在 require(xxx)
某一个类后,会在 js 的全局对象上增加该类的对象,比如:
1 | // js 中 require 一个 UIViewController |
具体的实现如下:
1 | var _require = function(clsName) { |
至此,js 段定义类结束
OC 端定义类
前面 js 预处理过后就会来到 OC 的 defineClass
方法中。这个方法也比较长,
- 将类名、父类名、协议名取出
- 创建类以及给类添加协议
- 添加和重写方法
取出类名协议名
1 | static NSDictionary *defineClass(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) |
其实就是解析传进来的 NSString *classDeclaration
字符串。其实这个直接在 js 解析就可以了。这种 js 解析一遍,OC 又解析一遍的做法有点累赘。
创建类以及给类添加协议
解析好类名和协议名之后就是创建了:
1 | static NSDictionary *defineClass(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) |
添加和重写方法
1 | static NSDictionary *defineClass(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) |
首先是对方法进行预处理,js 端定义的方法都是以下划线为分割的,形如:tableView_didSelectRowAtIndexPath
,在 OC 中要转的 tableView:didSelectRowAtIndexPath:
的形式。这个过程在 convertJSSelectorString()
方法中完成:
1 | // 获取真正的方法名 |
接下来就要给类添加方法了,规则是:
对于已经存在的方法:直接获取方法签名,然后传入
overrideMethod
,修改原来的方法实现的函数指针。对于不存在的的方法:
- 是某个协议中的方法:获取协议中该方法的函数签名,并传入
overrideMethod
实现方法。 - 不是某个协议的方法:即 js 新增的方法,自定义方法签名为
@@:{参数个数个 @}
的形式,表示所有入参和返回值都是 id 类型。传入overrideMethod
,实现方法。
- 是某个协议中的方法:获取协议中该方法的函数签名,并传入
由此可见,最重要的方法是 overrideMethod
。
重写方法 overrideMethod
overrideMethod
实现了 js 方法对 oc 方法的实现和替换。它主要做了三件事:
- 替换目标类的消息转发方法
forwardInvocation:
为自定义方法JPForwardInvocation:
- 各个类的方法命名为
_JP${方法名}
保存到_JSOverideMethods
字典中的对应类中 - 替换原方法实现为
msgForward
, 保存原方法实现为ORIG${原方法名}
通过替换方法实现为 mgsForward
实现消息转发,通过替换 forwardInvocation:
实现在消息转发时调用自己的方法,完成 hook。
OC 端定义的字典
_JSOverideMethods
保存函数指针。消息转发的时候可以找到 js 端方法
1 | // 将方法替换为 msgForwardIMP |
(太长了,编辑器太卡了,转至下一篇。。。)