JSPatch 源码分析
源码信息
JSPatch是iOS平台上的热修复方案,至于什么是热修复以及使用JSPatch是否会被苹果官方拒绝上架,这里不做介绍,这篇博客只关注JSPatch 源码本身。带大家过下JSPatch的源码。
源码解析
用例情景
JSPatch核心代码很精简但是这不意味着简单,还是需要仔细啃才能够理解它的思想。
我们先看下JSPatchDemo中的用法例子:
[JPEngine startEngine]; |
在例子中首先启动JPEngine,紧接着会去加载本地的demo.js。然后在JPEngine中执行本地的demo.js脚本。
我们看下原先的RootViewController JPViewController:
|
整个界面上有一个按钮,点击它会执行handleBtn方法,但是handleBtn目前是一个空方法,但是如果你将整个demo项目跑起来后会发现点击的时候会弹出一个TableView。但是如果注释掉之前将的JPEngine相关代码,点击就不会有任何响应,所以可以确定是JPEngine搞的鬼。我们看下demo.js:
defineClass('JPViewController', { |
即使你不懂JavaScript 看到上面的代码估计也可以猜出个大概,它重写了handleBtn方法,在点击的时候会push一个JPTableViewController页面。并且使用JavaScript语言实现了JPTableViewController。也就是说它可以通过动态加载一个js文件,并且在这个js文件中改变现有代码的行为。既然本地的js文件也是需要转换为string后放到JSPatchEngine引擎中执行,如果将这个文件放到服务端下发下去,那么就可以通过后台动态控制我们应用的行为了,是不是很诱人的功能,当然我们不会将它用于实现大需求,一般如果用于修修线上的一些紧急bug还是比较方便的,特别是苹果平台审核周期有时候会比较长。这种情况如果遇到线上的一些崩溃没有热修复,只能和苹果官方沟通来缩短审核的时间,紧急发布修复版本。
JSPatch 引擎初始化
+ (void)startEngine{ |
由于JSPatch是基于JavaScriptCore的所以在进行JSPatch 引擎初始化的时候会先检查下当前系统中是否支持JavaScriptCore。iOS 7.0 以下不支持 JavaScriptCore 所以在iOS 7.0 以下调用startEngine不会继续执行,直接返回。否则会创建一个JSContext并开始往JSContext里面注册对应的block,这些block会在JSPatch.js 中被调用。然后会注册JSContext 执行出现异常时的回调.最后会加载 JSPatch.js 中的所有 js 代码到JSContext 运行。
在JSPatch.js中会向gloable环境下添加defineClass,require,defineProtocol,block,defineJSClass 这些方法,这些方法可以供我们在热修复js文件中调用。
我们再回到demo.js:
defineClass('JPTableViewController : UITableViewController <UIAlertViewDelegate>', ['data'], { |
上面的defineClass有三个参数,第一个参数是类定义的字符串,第二个[‘data’]代表的是一个类的属性列表,这里只有一个属性参数data。第三个参数是这个类的实例方法列表。
global.defineClass = function(declaration/*定义字符串*/, properties/*属性*/, instMethods/*实例方法*/, clsMethods/*类方法*/) { |
JSPatch.js 的defineClass方法中会调用 JSEngine startEngine方法中向JSContext注入的_OC_defineClass block。传入的是这个类的定义,以及实例方法,类方法。其中实例方法和类方法的结构如下:
key:方法名 |
我们先来看下_OC_defineClass再回过来看JSPatch.js
context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) { |
static NSDictionary *defineClass(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) { |
我们以demo.js为例子,defineClass这里传入的classDeclaration为 JPTableViewController : UITableViewController
第二个参数instanceMethods和第三个参数classMethods分别为包含属性Setter/Getter在内的实例方法和类方法。上面提到了这两个参数在js方法中已经将它组合成
key:方法名 |
这种形式,我们以
tableView_numberOfRowsInSection: function(tableView, section) { |
为例子: key为 tableView_numberOfRowsInSection value为[2,tableView:numberOfRowsInSection:的实现] 我们知道OC中要构建一个selector需要知道这个方法是实例方法还是类方法,方法名,IMP
类名,IMP 都可以直接拿到,我们还缺一个关键的信息,就是selectorName. 我们从js带过来带的key为tableView_numberOfRowsInSection,它其实包含了selectorName所需要的必要信息,只不过它是以”“分隔,所以将将”“替换为”:”
然后检查通过”:”分隔的字符串数和传入的参数个数这个进行进行比较,如果少了则需要在最后加个”:”就可以拿到我们需要的selectorName了。
我们接下来看下热修复最关键的方法overrideMethod:
方法替换
/** |
如果大家看过Aspect源码这部分会比较好理解,两者在这部分思路是类似的,首先会将当前类的forwardInvocaiton替换为JSPatch实现的JPForwardInvocation:如果原来的类已经有实现了forwardInvocaiton那么就会将forwardInvocaiton替换成ORIGforwardInvocation
当前方法的selectorName 替换为_JPselectorName,如果原来有这个方法了,这里是在覆盖原有的方法,那么会将原来的方法selectorName改为ORIGselectorName。最后将selector方法的实现替换为_objc_msgForward 或 _objc_msgForward_stret 从而在代码中调用这个方法的时候,会触发消息下发。
走forwardInvocaiton,也就是会走到JSPatch实现的JPForwardInvocation:来重新控制方法调用的规则。
static void JPForwardInvocation(__unsafe_unretained id assignSlf, SEL selector, NSInvocation *invocation) { |
JPForwardInvocation 会先从 NSInvocation中取出selector 名称,现在selector名称之前加上_JP,然后看下当前对象是否有对应的方法,我们在overideMethod方法中了解到,如果我们热修复js脚本中有对应的方法,那么就会在该对象中多出一个_JP开头的方法,指向热修复补丁中的实现。
所以这里会先看下当前方法中是否有对应的补丁方法,如果没有就调用JPExecuteORIGForwardInvocation走原来的消息分发机制。
static void JPExecuteORIGForwardInvocation(id slf, SEL selector, NSInvocation *invocation) { |
如果有的话,会把NSInvocation中的参数提取出来,传递给对应的补丁方法。然后通过JP_FWD_RET_CALL_JS调用js方法。通过JP_FWD_RET_CALL_JS中的
jsval = [cb callWithArguments:args] |
获取到返回值后设置到NSInvocation中,完成方法的替换。
比如我们上面介绍的例子中有一个handleBtn. 原来的方法实现是空实现,后面通过JSPatch的demo.js 覆盖了这个方法,我们梳理下这是怎么实现的:
由于我们原有对象已经有了这个方法,只不过这个方法是空的,所以在overideMethod方法中会将原来的方法的selector名称改为_ORIGHandleBtn,指向原来的实现:
- (void)handleBtn:(id)sender { |
而会在该类中添加一个_JPhandleBtn,它的实现指向补丁js中的实现。
并且由于该对象没有实现forwardInvocaiton 所以会为该对象新增加一个JPForwardInvocation,并且将原来的对handleBtn的调用转换为_objc_msgForward。这一切工作完成后我们来看下整个调用过程:
我们点击按钮后会触发handleBtn,这时候由于handleBtn的实现被替换为_objc_msgForward所以会走消息分发途径,进而走到forwardInvocaiton,由于forwardInvocaiton的实现在上面被替换为JPForwardInvocation
在JPForwardInvocation中会先尝试将消息转发到_JPhandleBtn看下是否有这个实现,这里由于有这个实现所以就直接将invocation参数提取出来,传递给对应的js方法执行。这就达到了整个方法替换的目的。
如何在JS环境下执行OC方法
我们上面了解到了怎么通过消息转发来达到热修复的目的,接下来我们还有一个问题就是如何在JS中执行OC方法,我们看下_evaluateScript,它的作用是将补丁js方法加载到JSContext中执行。我们还是以demo.js为例子
+ (JSValue *)_evaluateScript:(NSString *)script withSourceURL:(NSURL *)resourceURL { |
demo.js加载后会先通过正则匹配处理后再送到JSContext中,我们看下匹配替换后的formatedScript是什么样的:
;(function(){try{ |
大家可以发现所有的xxx.XXXX 都被替换成了xxx.__c(“XXXX),为什么需要这样转换呢?我们到JSPatch.js寻找这个答案:
__c: function(methodName) { |
__c 里面会调用_methodFunc,_methodFunc方法里面会根据当前方法是实例方法还是类方法决定调用_OC_callI还是_OC_callC。最后将oc中的返回值转换为JS对象返回。
_OC_callI还是_OC_callC是在我们初始化JSPatch引擎的时候注入到JS中的。在js中调用_OC_callI或者_OC_callC会触发调用OC层的callSelector方法,通过它将js中的补丁方法中oc相关的方法交给oc层。
/** |
callSelector代码很长但是它所处理的任务比较比较明确,它主要是通过js传过来的参数来构建一个Invocation 通过[invocation invoke]来执行对应的方法。代码细节部分已经给出了较为详细的注解大家可以看下整个流程。
我们这里回顾下整个过程;
在启动JSPatch 引擎之后会向Context注入一系列的OC方法给JS回调。
紧接着加载JSPatch.js 它里面主要定义了一些关键方法,最主要的是__c,它会从中提取到OC消息转发所必须的一系列参数,通过_OC_callI或者_OC_callC 传给 OC 层, OC 层中调用callSelector 方法,通过js传过来的参数来构建一个Invocation 通过[invocation invoke]来执行对应的方法。
而在OC层中加载补丁js脚本后会将脚本中的xxx.XXXX 正则替换为xxx.__c(“XXXX”),这样通过上面的解释后调用OC层方法,如果忽略掉这部分的整个细节,整个过程是js 补丁脚本中的 xxx.XXXX 最后会转为 OC中的[invocation invoke]
是不是有点巧妙?
总结
这篇博客主要从如何通过消息转发机制来使用js补丁方法/类来替换现有的方法,从而达到热修复的目的。以及如何将js中OC相关的代码交给OC层来处理。第一部分思路有点和Aspect类似,大家可以细细体会下,很巧妙的一个方案。下面是JSPatch的一个简单的框图。