JSPatch Convertor 实现原理详解

简介

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 语言字符串,加入这个全局字符串,这样看起来是最简单的方法,但是实际上这样处理会很复杂,有三个问题:

  1. 解析和转换代码逻辑混在一起,程序复杂。

  2. 嵌套语法难以处理。例如 [[UIView alloc] init]; 是一个嵌套语法,方法调用的调用者是另一个方法调用,这种顺序解析难以处理。

  3. 解析过程中会需要很多变量去处理状态的问题。例如碰到 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 对象结构如图:

JSPatch Convertor 实现原理详解_第1张图片

蓝色是这个对象或属性里包含的语句。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 块的解析,是这样的:

JSPatch Convertor 实现原理详解_第2张图片

解析到 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 就行了,确定了这个方法,整个思路清晰多了,不用去处理一百多个回调,只需要处理好有限的几个就行, 虽然这是很简单的方式,但像 JSPatch 的正则替换一样是核心点,也是 JSPatch Convertor 可以快速完成最重要的点。

总结

整个 JSPatch Convertor 原理就介绍到这里,总结起来就是:

  1. antlr 生成解析程序

  2. 处理回调,用 JPContext 中间数据结构解决代码耦合,嵌套语法,状态位的问题。

  3. 简化处理流程,只处理有限几个回调,其他原样输出。

更多细节就要看代码了,欢迎一起完善 JSPatch Convertor

你可能感兴趣的:(jspatch,antlr)