JSPatch Convertor 实现原理详解
2015-10-13
简介
JSPatch Convertor 可以自动把 Objective-C 代码转为 JSPatch 脚本。
JSPatch 是以方法为单位进行代码替换的,若 OC 上某个方法里有一行出了bug,就需要把这个方法用 JS 重写一遍才能进行替换,这就需要很多人工把 Objective-C 代码翻译成 JS 的过程,而这种代码转换的过程遵循着固定的模式,应该是可以做到自动完成的,于是想尝试实现这样的代码自动转换工具,从 Objective-C 自动转为 JSPatch 脚本。
方案 / Antlr
做这样的代码转换工具,最简单的实现方式是什么?最初考虑是否能用正则表达式搞定,如果可以那是最简单的,后来发现像 方法声明 / get property / NSArray / NSString 等这些是可以用正则处理的,但需要匹配括号的像 block / 方法调用 /set property 这些难以用正则处理,于是只能转向其他途径。
Antlr
接下来的思路是对 Objective-C 进行词法语法解析,再遍历语法树生成对应的 JS 代码。Objective-C 词法语法解析 clang 可以做到 ,但在找方案过程中发现了 antlr 这个神器,以及为 antlr 定制的几乎所有语言的语法描述文件,更符合我的需求。antlr 可以根据语法描述文件生成对应的词法语法解析程序,生成的程序可以是 Java / Python / C# / JavaScript 这四种之一。
也就是说,我们拿 ObjC.g4 这个语法文件,就可以通过 antlr 生成 Objective-C 语法解析程序,程序语言可以在上述四种语言中任挑,我挑选的是 JavaScript,生成的程序可以在 [这里] 看到。官方文档有生成的流程和使用方法,可以自己试下。
于是我们得到了一个 Objective-C 语法解析器,这个解析器可以针对输入的 Objective-C 代码生成 AST 抽象语法树,并对这个语法树进行遍历,遍历过程的所有回调方法可以在 [这里] 看到,我们要做的就是处理这些回调,转为 JS 代码。
遍历过程
先来看看遍历语法树的过程是怎样的,举个简单例子,我们输入这样一句 Objective-C 语句:
[UIView alloc];
程序对这句话进行词法语法解析后,遍历语法树,会按顺序回调这几个方法:
JPObjCListener.prototype.enterMessage_expression = function(ctx) { //检测当前进入方法调用语法,ctx是整个方法调用语法树,包含了receiver/selector等信息,也就是匹配了[UIView alloc];这整个语句。 }; JPObjCListener.prototype.enterReceiver = function(ctx) { //检测方法调用者,这里 ctx 包含了 UIView 这个 token }; JPObjCListener.prototype.exitReceiver = function(ctx) { //方法调用者 token 结束,ctx 还是 UIView 这个token }; JPObjCListener.prototype.enterMessage_selector = function(ctx) { //检测方法名 selector,ctx 包含了 alloc 这个token,若有多个参数或参数值,都会保存在 ctx 里 }; JPObjCListener.prototype.exitMessage_selector = function(ctx) { //selector token 结束,ctx同上。 }; JPObjCListener.prototype.exitMessage_expression = function(ctx) { //方法调用结束 };
每个回调的 ctx 都包含了各种信息,包括这个当前解析字符串起始/终止位置,包含的子 ctx 等,具体可以在控制台打出 ctx 观察。整个解析过程就是按顺序遇到什么类型的 token 就回调什么。
解析 / JPContext链
接下来就是要考虑怎样处理这些回调,然后生成 JS 代码,最容易想到的就是在一开始定义一个全局空字符串,在解析过程中直接生成 JS 语句,加入这个全局字符串,最终拼接成 JS 程序。这样看起来是最简单的方法,但是实际上这样处理会很复杂,有三个问题:
- OC 代码解析和 JS 代码生成逻辑混在一起,程序复杂。
- 嵌套语法难以处理。例如 [[UIView alloc] init]是一个嵌套语法,方法调用的调用者是另一个方法调用,这种解析难以处理。
- 解析过程中需要很多变量记录上下文。例如碰到 UIView 这个 token,是出现在方法调用中,还是出现在变量声明中,所做的处理是不一样的,需要知道当前处于什么上下文。
于是考虑设计一个中间数据结构,解决这三个问题。这个数据结构就是 JPContext 以及它的子类们,对于不同的语法块会有对应不同的 JPContext 子类,例如对应方法调用的 JPMsgContext,方法定义的 JPMethodContext 等。
来看看这个数据结构是怎样解决这三个问题的。
1.拆分
JSContext 最基本的用途就是拆分 Objective-C 代码的解析和 JS 代码的生成,不让这两个逻辑混合在一起,在解析 Objective-C 时生成一个个相连的 JSContext,最后从第一个 JSContext 开始遍历整个链调用 JSContext 的 parse() 函数生成 JS 代码,举个例子:
self.data = @{}; [[UIView alloc] initWithFrame:CGRectZero]; JPBlock blk = ^(id data, NSError *err) { [self handleData:data]; callback(data, err); } NSString *str = @"";
这段 OC 代码最终解析成以下 JPContext 链:
解析的方法是设一个全局变量 currContext 保存当前解析链上最后一个对象,每次解析到新内容,生成下一个 JPContext 对象时,就把 currContext.next 设为这个新的 JPContext 对象,同时 currContext 也替换为这个新的 JPContext 对象,这样循环直到代码结束,就生成了一条 JPContext 链,从第一个 JPContext 开始遍历整个链调用 parse() 函数就可以组合成最终的 JS 程序了:
var script = ''; while (ctx = ctx.next) { script += ctx.parse(); }
不同的 JPContext 子类有不同的 parse() 实现去生成相应的 JS 代码,具体可以看代码。
2.封装语句
上面举的例子中,[[UIView alloc] initWithFrame:CGRectZero]; 实际上是一个嵌套调用的语法,initWithFrame: 的调用者是 [UIView alloc],是另一个方法调用语句,但最终在 JPContext 链上看到的只有一个 JPMsgContext 对象,这个对象把方法调用里的细节都封装了,无论这个方法调用里有多少层嵌套,或者参数有多复杂,对外的表现都是只有一个 JPMsgContext 对象,实现了把语句封装,降低复杂度的目的。
每个 JPContext 子类都有自己封装的规则, 对于 JPMsgContext 来说,解析上述语句生成的 JPMsgContext 对象结构如图:
蓝色是这个对象或属性里包含的 OC 语句。JPMsgContext 有 receiver 和 selector 两个属性,receiver 可以是另一个 JPMsgContext 对象,也可以是字符串,selector 保存调用方法名和参数。这里外层 JPMsgContext 的 receiver 属性值就是 JPMsgContext 对象,因为它的调用者是另一个方法调用,而里面这个 JPMsgContext 对象 receiver 是字符串 UIView。就这样实现了嵌套调用的封装。
每个 JPContext 子类对象都有自己的封装规则,这里只以 JPMsgContext 为例,其他的请看代码。
3.上下文
解析过程中的上下文问题,还是以这份代码为例:
self.data = @{}; [[UIView alloc] initWithFrame:CGRectZero]; //1 JPBlock blk = ^(id data, NSError *err) { [self handleData:data]; //2 callback(data, err); } NSString *str = @“”;
这份代码出现了两次方法调用(标注1、2),其中一个是在 block 块里,在解析这两个方法调用时都会进入同一个回调,但对应的是两种上下文,一种是这个语句处于全局,另一种是这个语句属于 block 块,解析过程中怎样处理这两种情况?
解决方法是稍微扩展一下第一点说到的 currContext 概念,不把它当 JPContext 链上的最后一个元素,而是作为游标,表示当前处于哪个 JPContext 上。说得太抽象,举例说明,细化一下这份代码最终的 JPContext 链,展开 block 块的解析,是这样的:
解析到 block 时,会生成 JPBlockContext,但 currContext 不指向这个 JPBlockContext,而是指向它的一个属性 JPBlockContentContext,在 block 块结束时,currContext 重新指向 JPBlockContext。
这样解析①和②这两个方法调用语句时,程序做的事情都是一样的,让 currContext.next 指向生成的新的 JPMsgContext,只不过①的 currContext 是 JPAssignment,②的 currContext 是 JPBlockContentContext,相当于靠 currContext 这个游标保存上下文信息,程序处理时无需关心。
简化
解决这三个问题后,还有第四个问题:Objective-C 语法特性太多。粗略计算有 100 多个语法特性回调,把这些回调全部处理一遍得耗多大精力和时间?有没有更简单的办法?
仔细想想,Objective-C 跟 JS 语法上很多是一样的,我们主要需要处理的就是 方法调用/方法定义/block 这有限的几种,其他的都不需要转换,像 赋值/运算/循环 这些代码都是一样的,而像 struct / 指针 等可以暂时不支持,只需要覆盖日常使用 80% 以上的情况就可以了。
于是想到可以只处理 方法调用/方法定义/block 等有限几个回调,其他的原样输出到 JS 就行了,确定了这个方案,整个思路清晰多了,不用去处理一百多个回调,只需要处理好有限的几个就行。具体实现上就是用 JPCommonContext 表示原样输出的字符串,解析过程中找出未处理的语句,生成 JPCommonContext 加入到当前 JPContext 链中就可以了。
虽然这是很简单的方式,但像 JSPatch 的正则替换一样是核心点,也是 JSPatch Convertor 可以快速完成最重要的点。
总结
整个 JSPatch Convertor 原理就介绍到这里,总结起来就是:
- antlr 生成解析程序
- 遍历语法树,用 JPContext 解决代码耦合,嵌套语法,上下文的问题。
- 简化处理流程,只处理有限几个回调,其他原样输出。
更多细节就要看代码了,欢迎一起完善 JSPatch Convertor。
如何用JSPatch覆盖一个宏呢?急~~~~~