JSPatch源码分析

JSPatch是一个可以在线修复bug的轻量级框架,项目中嵌入这个框架可以让你的app具有热更新的能力。你可以通过框架提供的一个平台,在线分发修复bug的js代码,平台的地址是http://jspatch.com/ ,平台适合中小型 APP (日活<5w) 使用,对于用户量大的 APP,建议自行搭建后台使用。这个框架刚刚诞生我就开始关注,目前在github上面已经有了三千多的star,非常值得一读,它在github上面的地址是
https://github.com/bang590/JSPatch 。另外,框架的作者已经写了一篇详尽的实现原理以及相关的说明文档,是辅助分析源码的最佳工具,详见
https://github.com/bang590/JSPatch/wiki/JSPatch-%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86%E8%AF%A6%E8%A7%A3

*几点说明:
  1、框架的核心部分仅仅只有两个文件:JPEngine.m和JSPatch.js,非常的精巧,让人惊叹;
  2、通过阅读作者的实现原理blog(下文简称原理详解blog),可以发现在研发框架的过程中需要实际解决的问题非常多,各种精妙的设计、奇思妙想也非常让人佩服;
  3、原理详解blog全面的介绍了设计的想法,解决问题中的思路和采取的方法,但是,仅仅阅读了blog,而不看源码是远远不够的,框架对js高阶特性和OC Runtime的运用和配合,如神来之笔,非常值得学习和研究,本文仅仅作为一个通过原理详解blog的引导,来学习源码的一个笔记。
  4、这里通过两条主线,分别是修复过程和调用过程,把整个处理的过程连贯的串起来,顺便进行方法的解析。

我们从实际的应用出发,分为三个部分对源码进行解读。当我们引入了框架,准备好了修复bug的js文件,并且按照说明文档调用了相关接口之后,就完成了整个流程。
  那么我们需要关心的就是,如何准备js文件,调用了相关接口之后,到底发生了什么事情。如何就具有了替换OC代码的能力了呢?
  接下来我们将使用框架附带的demo来进行分析,demo的地址也就是上面提到的框架的链接。</br>
  下载下来demo运行可以发现,app的首页就是一个简单的视图控制器(JPViewController),上面只有一个按钮,如下图所示:

Paste_Image.png

  并且对应着一个空方法(handleBtn)。

//JPViewController.m
- (void)handleBtn:(id)sender
{
}

我们的修复js代码文件(demo.js)就是实现这个空方法,创建一个全新的tableViewController,并且给tableView配置代理和数据源,设置点击事件,然后调用navigation 的push方法把这个控制器push出来,如下图所示:

Paste_Image.png

而这一切只需要在app启动时候调用开启JSPatch引擎。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    [JPEngine startEngine];
    NSString *sourcePath = [[NSBundle mainBundle] pathForResource:@"demo" ofType:@"js"];
    NSString *script = [NSString stringWithContentsOfFile:sourcePath encoding:NSUTF8StringEncoding error:nil];
    [JPEngine evaluateScript:script];
    ...    
}

第一部分 源码解析###

备注:
修复已存在的方法,和创建新的类以及方法的过程都是一样的,调用的过程中如果发现没有类和方法,就会创建新的。我们这边对这个流程分析的时候,只分析handleBtn方法的修复,不会介绍创建新的控制器和方法的过程,因为流程都是一样的。

现在我们来开始分析其内部的实现机制,首先看一下修复文件:demo.js文件的代码,这里截取一部分代码,就是上文说的handleBtn的具体实现。如下

defineClass('JPViewController', {
  handleBtn: function(sender) {
    var tableViewCtrl = JPTableViewController.alloc().init()
    self.navigationController().pushViewController_animated(tableViewCtrl, YES)
  }
})

看到这里,可能有很多疑问,handleBtn的方法实现完全是OC的对象和风格,但是又是js的语法,而且,点击button是如何调用到这个js函数的呢?defineClass又做了哪些事情?那么我们就从startEngine开启引擎开始解析。</br>

修复过程第一步:JPEngine startEngine的调用####

我们去看一下这个方法的具体实现,根据JSContext对象可以看到js和native交互的核心引擎实际上是苹果官方提供的JavaScriptCore框架。这个框架很简单,但是非常强大,在网上搜索一个博客,就能掌握它的用法,这里不再细说,但是掌握这个框架的基本使用才能继续分析JSPatch框架。这里先认为你已经掌握了该技能,接着往下说,我们知道JSContext代表了js的执行环境,我们可以通过该对象,为js的上下文注入全局的对象或者方法,下面举个例子:在startEngine中有这样一句调用

context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) {
        return defineClass(classDeclaration, instanceMethods, classMethods);
    };

向js注入了全局的_OC_defineClass方法,其具体实现对应着native的block,这就是JavaScriptCore的强大之处。这样一来,我们在写js代码的时候,就可以调用_OC_defineClass这个方法,如下所示:我们在jsPatch.js 中声明了一个全局方法defineClass,它的内部实现就是调用了_OC_defineClass方法

global.defineClass = function(declaration, instMethods, clsMethods) {
    var newInstMethods = {}, newClsMethods = {}
    _formatDefineMethods(instMethods, newInstMethods)
    _formatDefineMethods(clsMethods, newClsMethods)

    var ret = _OC_defineClass(declaration, newInstMethods, newClsMethods)

    return require(ret["cls"])
  }

在看一看上面一段demo.js的代码:
defineClass('JPViewController'....)。这样以来,我们便发现了如何把OC的实现注入到js的秘密,defineClass方法就是我们使用js修复类的唯一方法入口,而它实际调用的是OC的代码(运用runtime技术,后文在讲)。接下来看看defineClass方法是如何使用runtime技术进行类的修复。</br>

修复过程第二步:global.defineClass方法解析####

让我们来分析一下上一段代码,defineClass方法接收的参数是1、类名字符串,2、类的实例方法和类方法列表(都是js对象的形式,参见demo.js),这个对象的属性是方法名,值是重写方法的具体实现函数。defineClass方法会首先分别对这两个对象调用_formatDefineMethods方法。
  来看一下_formatDefineMethods对这两个对象做了什么,_formatDefineMethods方法接收的参数是一个方法列表js对象,加一个新的js空对象

var _formatDefineMethods = function(methods, newMethods) {
    for (var methodName in methods) {
      (function(){
       var originMethod = methods[methodName]
        newMethods[methodName] = [originMethod.length, function() {
          var args = _formatOCToJS(Array.prototype.slice.call(arguments))
          var lastSelf = global.self
          var ret;
          try {
            global.self = args[0]
            args.splice(0,1)
            ret = originMethod.apply(originMethod, args)
            global.self = lastSelf
          } catch(e) {
            _OC_catch(e.message, e.stack)
          }
          return ret
        }]
      })()
    }
  }

可以发现,具体实现是遍历方法列表对象的属性(方法名),然后往js空对象中添加相同的属性,它的值对应的是一个数组,数组的第一个值是方法名对应实现函数的参数个数,第二个值是一个函数(也就是方法的具体实现)。
  _formatDefineMethods作用,简单的说,它把defineClass中传递过来的js对象进行了修改:

原来的形式是:
    {
        handleBtn:function(){...}
    }
修改之后是:
    {
        handleBtn: [argCount,function (){...新的实现}]
    }

那么为什么要传递参数个数?为什么要修改方法实现呢?  
  传递参数个数的目的是,runtime在修复类的时候,无法直接解析原始的js实现函数,那么就不知道参数的个数,特别是在创建新的方法的时候,需要根据参数个数生成方法签名,所以只能在js端拿到js函数的参数个数,传递到OC端。
  分析修改方法实现,就要看一下是如何修改的。
  1、首先会把参数转换成js对象;

(具体转换的原因可见原理详解blog 的<4.对象持有/转换> ,我们这里不再重复)

2、self的处理,在js修复代码中我们可以像在OC中一样使用self;

(参见原理详解blog的 6.self关键字)

3、args.splice(0,1)删除前两个参数:
  在OC中进行消息转发的时候,前两个参数是self和selector,我们在实际调用js的具体实现的时候,需要把这两个参数删除。

最后,回到defineClass方法,在调用_formatDefineMethods完毕之后,拿着要重写的类名和经过处理过的js对象,来调用_OC_defineClass,对应着OC端的block方法。

修复过程第三步 _OC_defineClass方法实现####

context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) {
        return defineClass(classDeclaration, instanceMethods, classMethods);
    };

在JPEngine中,定了一个名为defineClass的函数,这个函数对类进行真正的重写操作。我们知道runtime重写一个方法,需要几个最基本的参数:类名、selector、方法实现(IMP)、方法签名,defineClass做的就是把这些信息提取出来,当然流程会更复杂一点:
  1、首先是对类名进行解析,把协议名、类名、父类名都解析出来。如果类不存在,那么创建并注册该类。</br>

(对协议的处理可参加 原理详解blog 的 4.新增方法 i.方案 ii.Protocol)

2、然后分别对实例方法和类方法进行处理,上面一节中,我们知道js函数_formatDefineMethods处理返回的是js对象,传递到OC这边会被JavaScriptCore转换为JSValue对象,可以对该对象直接调用toDictionary把js对象转换成OC的字典。这样我们就可以取到方法名、参数个数、具体实现。

JSValue
可以说是JavaScript和Object-C之间互换的桥梁,它提供了多种方法可以方便地把JavaScript数据类型转换成Objective-C,或者是转换过去。其一一对应方式可见下表:(引用自 http://www.cnblogs.com/ider/p/introduction-to-ios7-javascriptcore-framework.html)

Paste_Image.png

3、遍历字典的key,即方法名,根据方法名取出的值还是JSValue对象,不过它代表的是数组,第一个值是参数的个数,第二个值是函数的实现。</br>

4、方法名的处理:这块涉及到方法名的格式要求和处理,例如,在js中的tableView_numberOfRowsInSection,下划线需要被替换成':'。

这一块可以参见原理详解blog的 细节 3.‘_’的处理。
对于格式的说明可见 <defineClass使用文档>
https://github.com/bang590/JSPatch/wiki/defineClass%E4%BD%BF%E7%94%A8%E6%96%87%E6%A1%A3
<defineProtocol使用文档>
https://github.com/bang590/JSPatch/wiki/defineProtocol%E4%BD%BF%E7%94%A8%E6%96%87%E6%A1%A3

最后拿着处理好的方法名和具体实现等调用overrideMethod函数。

修复过程第四步 overrideMethod函数####

static void overrideMethod(Class cls, NSString *selectorName, 
JSValue *function, BOOL isClassMethod, const char *typeDescription)

1、把selector对应的具体实现使用class_replaceMethod替换成_objc_msgForward,我们知道这个对应着消息转发机制。例如,本文的例子中点击button是不会执行空的handleBtn方法的,而是直接走消息转发的路径。
  2、把forwardInvocation的具体实现替换成JPForwardInvocation(下文分析)。
  3、向class添加名为ORIGforwardInvocation的方法,实现是原始的forwardInvocation的IMP。

这一步的目的是如果原来的类已经实现了自定义的消息转发,重写了forwardInvocation方法。那么我们在上面一步中已经修改了forwardInvocation对应的方法实现怎么办?保存旧的forwardInvocation实现的意义就是,会在新的实现中判断,如果当前的selector不是被js修改重写过的,就执行旧的实现。
具体可见 原理详解blog 3.ForwardInvocation实现

4、向class添加名为ORIG+selector,对应原始selector的IMP。JS 可以通过这个方法调用到原来的实现。
  5、向class添加名为_JP + selector,对应js重写的函数实现。

为什么要把selector加上_JP呢?
实际上_JP是一个标识的字符,我们在js中重写的方法实现,传递到OC这边,还是JSValue的对象。我们存储了一份全局的字典对象,字典的key是classname,值还是一个字典,key是_JP开头的selector name,value就是JSValue类型的js函数。
当进行消息转发时,我们根据经过_JP标示的selector和clsName,在这个全局字典中就可以找到对应js函数的JSValue对象。
而如果想执行这个js函数的话,只需要对JSValue对象调用callWithArgument方法就可以了。

到这里,我们终于可以开始分析handleBtn的点击处理了。

调用过程第一步 JPForwardInvocation函数####

这一步是,OC调用JS重写的方法。
OC:handleBtn空实现--->js:handleBtn function

我们知道,经过上一步的处理,selector对应的实现是objc_msgForward,即走到了消息转发的环节。当我们点击button,调用handleBtn的时候,函数调用的参数会被封装到NSInvocation对象,走到forwardInvocation方法。而我们上一步中把forwardInvocation方法的实现替换成了JPForwardInvocation,那么我们来看一下这个函数是如何执行具体的函数实现的。

1、把selector前面加上 _JP,构成的新的selector,正好是上一步中我们添加的新方法,如果class无法识别,说明这个不是我们重写的方法,那么走原来的消息转发,上一节中介绍过了。

2、把self和其他的参数都转换称js对象,我们知道js端重写的函数,传递过来是JSValue类型,这里对应着js函数,我们可以对其调用callWithArgument方法,所以参数也要是js对象,把js对象参数传递过去,执行函数。上文提到过,类型的转换在原理详解blog中有介绍。
  实际上这个方法的细节是非常多的,根据方法签名,取出每个参数的类型,进行参数的封装、对于结构体的处理等等。

讲到这里,就完成了函数调用环节,那么下面再来分析一下,js代码如何执行的,实际上作者的原理篇开篇就介绍了实现思路和方法,非常清晰,还是拿handleBtn举例:

建议查看原理详解blog 第一部分 方法调用,作者讲解了为何会采取下面这种解决方案的原因和处理过程

handleBtn: function(sender) {
    var tableViewCtrl = JPTableViewController.alloc().init()
    self.navigationController().pushViewController_animated(tableViewCtrl, YES)
  }

在最开始startEngine的时候,会把这些js代码统一进行正则表达式的匹配替换,也就是把所有的函数都替换成对名为__C的函数的调用,也就是JPTableViewController.__C('alloc')().__C('init')(),而作者给 JS 对象基类 Object 的 prototype 加上 __C 成员,具体的实现是调用了_methodFunc函数:

Object.prototype.__c = function(methodName) {
  if (!this.__obj && !this.__clsName) return this[methodName].bind(this);
  var self = this
  return function(){
    var args = Array.prototype.slice.call(arguments)
    return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper)
  }
}
**JPTableViewController.**C('alloc')().__C('init')()
就成了:(require('JPTableViewController')会生成一个全局的js对象,这里用clsObj代替)
   1、_methodFunc(clsObj,'JPTableViewController','alloc',false)
返回一个JPTableViewController的对象,这里用jpObj代替,接着调用init初始化方法
   2、_methodFunc(jpObj,'JPTableViewController','init',false)
这样就完成了JPTableViewController的alloc和init调用

参见源码我们可以知道_methodFunc函数会调用_OC_call函数,而在startEngine的一开始,我们就为JSContext注入了_OC_call函数,具体实现是一个调用了OC的callSelector的block:

context[@"_OC_callI"] = ^id(JSValue *obj, NSString *selectorName, JSValue *arguments, BOOL isSuper) {
        return callSelector(nil, selectorName, arguments, obj, isSuper);
    };

调用过程第二步 callSelector函数####

这一步是js重写方法中,调用OC的对象方法等
JS:JPTableViewController.alloc().init()实际是通过callSelector调用OC的方法

1、把js对象和js参数转换为OC对象;

2、判断是否调用的是父类的方法,如果是,就走父类的方法实现;

3、把参数等信息封装成NSInvocation对象,并执行,然后返回结果;

具体的实现细节包括对methodSignature的字符处理,根据这些字符对js对象进行处理和转换,还有对结构体对支持等。
  本文对这一块介绍的比较简单,实际上,通过作者的原理详解,可以发现这里面包含很多的设计思路,可以说是一个基于这些设计思路一个集大成的方法实现。涉及到:

消息转发的处理
formatOCToJS等的用意
overrideMethod函数的应用
内存的处理
nil对象的处理
JPBoxing
结构体处理等等,

如果基于前面的分析、作者的原理详解和对源码的实际分析,理解这些应该不是问题。
  第一部分对于js修复的过程以及调用的详细环节进行了分析,接下来讲一下框架的延伸部分。

第二部分 JPExtension###

我们知道使用JSPatch修复代码,在写js的修复代码时,终究是对callSelector函数的调用,那么对于非对象的函数调用怎么办呢?实际上js对代码的修复能力取决于我们给它扩展的能力。例如Core Graphic的一些C的函数UIGraphicsGetCurrentContext等是无法支持的,那么JSPatch给我们提供了支持扩展的解决方案。

例如JPUIGraphics

context[@"UIGraphicsGetCurrentContext"] = ^id() {
        CGContextRef c = UIGraphicsGetCurrentContext();
        return [self formatPointerOCToJS:c];
    };

我们看到这个调用给js的执行环境注入了全局的方法,可以让我们在js中使用UIGraphicsGetCurrentContext。

还有JPUIGeometry中添加UIEdgeInsets结构体的支持等。
框架现在也有了相当一部分的这种扩展,主要是UIKit和CoreGraphics框架的。我们也可以根据自己的需要,添加扩展,向github上的项目提交pull request。

第三部分 使用###

作者提供了在线OC代码翻译成js的工具
  (http://bang590.github.io/JSPatchConvertor/
  作者还有一篇文章提供了调试这些js代码的方法
  https://github.com/bang590/JSPatch/wiki/JS-%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95
  我们可以调试这些修复的js代码,非常的实用。
  翻译工具进行的一些转换还是需要手动调整,例如在OC中如果属性和方法有下划线,例如promote_type,那么js中要修改为promote__type,否则就会被转换成promote:type:的格式,还有不能写CGRectMake,要修改成{x:,y:,width:,height:}的形式,还有项目中原有的一些宏定义也不能使用,除非你是用扩展,把这些都注入到JSContext中去。实际上,这样的规则并不多,写一个修复方法差不多就能掌握了,有了调试工具,迅速的解决问题还是比较简单的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容