前言
GaiaX【https://github.com/alibaba/GaiaX】是由优酷应用中心技术团队研发的一款跨端高性能渲染引擎,目前该方案已经向技术社区开源,其核心目标是解决多端卡片化UI组件的研发效能问题。
先看一下GaiaX构建的总体链路:
图 - GaiaX构建总体链路
可以看到,在 GaiaX 中从结构化的模板文件到端渲染,经过了模板解析、节点树构建、视图树构建、表达式运算、扩展交互等步骤。
本文主要将主要从表达式的方案设计、语法树构建以及表达式的跨平台实现这几个方面对表达式运算这个模块进行介绍。
表达式介绍
在GaiaX模板的构建过程中,表达式是较为重要的一个模块,其主要的能力是对数据进行取值或运算,并绑定到对应的视图中,它作为逻辑动态化的基础,承接着上层业务对视图数据绑定的具体描述,视图的数据变化和具体表述均由表达式作为纽带。
{ "data":{ "gx-expression-value":{ "value": "$data.text" }, "gx-expression-calculate":{ "value": "1+2*3%3+$data.num" }, "gx-expression-function":{ "value": "Size($data.array)" } }}
在表达式中,我们支持取值运算、函数计算以及表达式运算的能力,为了实现双端一致性,我们使用了C++作为表达式的底层开发语言。
技术方案设计
表达式的解析过程
图 - 表达式各端解析流程
表达式的解析过程其实就是编译的过程,编译即把通过源语言编写成的源程序转化为目标程序的过程,通常编译的完整流程是把源程序通过词法分析、语法分析、语义分析、中间代码生成等步骤生成计算机可以理解的机器语言,考虑到GaiaX的表达式的作用范围,我们只考虑前半部分,即词法分析、语法分析以及语义分析。
我们先通过一个简单的流程图,了解一下表达式的整个解析流程:
- 词法分析
词法分析是对表达式输入的字符流进行扫描,根据对应的构词规则对每个词进行划分和分类,将其组成有意义的词素序列。
- 语法分析
语法分析是在词法分析的基础上,将词法分析生成的词素组成各类语法短语,并判断表达式在结构上是否正确,最终构建出符合语法规则的语法树以及对应的符号表。
- 语义分析
语义分析则是结合语法分析中生成的语法树和符号表,对输入的表达式的语义规则进行分析,判断语义是否符合规则。
在GaiaX表达式中,我们使用LR(1)文法进行语法分析并构建语法树,并将语法分析和语义分析结合起来,即在语法树的构建过程中,判断表达式是否符合语义规则,并在语法树构建完成后返回最终的结果。
语法树的构建 - LR(1)文法
上文说到表达式的解析过程,其实就是一个编译的过程。在GaiaX表达式方案中,我们采用了编译原理中经典的LR(1)文法作为表达式语法树的构建方案。
LR(k)分析方法是1965年Knuth提出的,括号中的k表示向右查看输入符号的个数。这种方法比起自顶向下的LL(k)分析方法和自底向上的优先分析方法对文法的限制要少得多,也就是说,对于大多数用无二义性上下文无关文法描述的语言都可以用相定的LR分析器进行识别,而且这种方法还具有分析速度快,能准确即时地指出出错位置的特点。
LR(1)文法
LR(1)文法的意思是从左向右扫描,最右推导,往前多看一个字符。
字符 |
含义 |
L |
从左到右输入串 |
R |
利用最右分析方法来识别句子 |
(1) |
向右展望1个字符 |
在具体介绍LR(1)分析法之前,我们需要先了解两个基本概念:
名称 |
含义 |
推导 |
一个字符串x通过一个规则变换成另一个字符串y,称为x推导出y,即x→y |
规约 |
与推导相反,语法规则为x→y时,由y规约到x即为规约 |
GaiaX表达式语法树构建流程
LR(1)的分析过程是规约的过程,我们以GaiaX表达式中的运算语法为例,深入了解GaiaX表达式语法树的构建流程。
在GaiaX表达式中,四则运算的语法如下:
S -> S + E #加 |S - E #减 |E E -> E * num #乘 |E / num #除 |num
假设我们有表达式:a*b+c
在这个表达式中,我们可以看到,乘法的优先级是要大于加法的,那么在LR(1)文法中,我们应该怎么去撰写我们的语法,使得乘法的优先级要大于加法呢?
在前文中,我们了解到,推导是从上往下一直推导出整个式子,而LR(1)文法是规约的过程,规约与推导相反,是从下往上,从底部一直推到到根部节点的过程,根据这点,我们可以了解到,在LR(1)文法中,语法的推导层级越深,那么就会越先被运算,它的优先级就越高。
所以在GaiaX的四则运算语法中,我们让乘除法的层级在加减法之下,从而实现乘除法的优先级大于加减法的优先级。
语法树的构建流程
在确认了具体的语法之后,我们就可以根据语法去构建具体的语法树了,我们先来看一下语法树的构建流程:
图 - 语法树构建流程
在初始化以及词法分析流程后,得到了分析表和词素序列,接下来我们通过不断把词素入栈并匹配判断是否可以规约,来构建最终的语法树。
示例的语法树构建流程如下:
步骤 | 栈 |
输入 |
说明 |
1 |
null | num*num+num |
表达式输入 |
2 |
num |
*num+num |
移进 |
3 |
num* |
num+num |
移进 |
4 |
E |
+num |
移进,num*num规约为E |
5 |
E+ |
num |
移进 |
6 |
S |
null | 移进,E+num规约为S,分析成功 |
在一步步移进和规约的过程中,当规约到最后的根结点时,便生成了一棵完整的语法树,如下图所示,可以看到,乘法的优先级更高,所以离根节点越远,加法的优先级较乘法低,离根节点越近。
图 - 示例语法树
跨平台实现方案设计
在前文中,我们简要介绍了GaiaX表达式各端调用的链路流程,我们了解到,表达式的计算能力可以被Android、iOS端所调用,接下来我们来了解一下GaiaX表达式跨平台能力的实现。
C++层调用各端取值与方法函数
在跨平台方案中,主要涉及两种情况:
第一种情况是双端调用C++层的方法,在iOS端,我们可以直接通过添加头文件引用直接调用C++函数;而在Android端,我们则需要使用中间层JNI来间接调用C++层的代码。
第二种情况则是C++层调用双端实现的方法,相较于端侧调用C++的代码,由C++层调用端侧实现的方法会更为复杂,我们先来看一下C++层调用端侧代码的大致链路:
图 - 双端取值和函数方法的调用与实现
由流程图我们可以看出,C++层调用Android以及iOS端的数据获取和函数调用方法,都是需要双端进行实现的,要达到双端实现C++层能够调用的方法,我们需要先在C++层定义GXAnalyze类并提供对应的方法虚函数
class GXAnalyze { public: //获取数据 $ virtual long getSourceValue(string valuePath, void* source) = 0; //获取方法 Function virtual long getFunctionValue(string funName, long *paramPointers, int paramsSize, void* source) = 0;}
C++调用Android端方法的实现
在Android端中,为了实现C++提供的虚函数,并返回给C++层调用,我们创建了GXAnalyzeAndroid类,并在类中创建出对应的取值和方法调用接口。
class GXAnalyzeAndroid { // 计算逻辑的扩展 interface IComputeExtend { // Computed value expression fun computeValueExpression(valuePath: String, source: Any?): Long // Computed function expression fun computeFunctionExpression(functionName: String, params: LongArray): Long } init { initNative(this) //初始化表达式并存储GXAnalyzeAndroid对象 }}
在JNI层,我们实现了两个方法,分别是getSourceValueFromJava取值方法调用以及getFunctionValueFromJava函数方法调用,以getSourceValueFromJava为例,我们通过获取在initNative时存储的GXAnalyzeAndroid对象,通过CallLongMethod调用其实现的取值方法。
# object_in:initNative时存储的GXAnalyzeAndroid对象 static jlong getSourceValueFromJava(string valuePath, jobject source, jobject object_in) { JNIEnv *env = getJNIEnv(); //获取环境env变量 if (env != nullptr) { jclass clazz; clazz = env->GetObjectClass(object_in); jfieldID analyze_fieldID = env->GetFieldID(clazz, "computeExtend", "Lcom/expressionDir/GXAnalyzeAndroid$IComputeExtend;"); jobject jobject = env->GetObjectField(object_in, analyze_fieldID); jclass analyzeJni = env->GetObjectClass(jobject); jmethodID getArrId = env->GetMethodID(analyzeJni, "computeValueExpression", "(Ljava/lang/String;Ljava/lang/Object;)J"); jlong res = env->CallLongMethod(jobject, getArrId, str2jstring(env, valuePath.c_str()), source); env->DeleteLocalRef(clazz); env->DeleteLocalRef(jobject); env->DeleteLocalRef(analyzeJni); return res; } return 0L;}
需要注意的是,仅仅实现了JNI调用Java\Kotlin的方法还不足以实现在C++层调用Android端的代码,我们在JNI层在实现调用解析表达式的函数的同时,通过继承的方式,实现了GXAnalyze类的虚函数。
class GXJniAnalyze : public GXAnalyze { public: long getSourceValue(string valuePath, void *source) override { jobject dataSource = static_cast<jobject>(source); return getSourceValueFromJava(valuePath, dataSource, globalSelf); }};extern "C"JNIEXPORT jlong JNICALLJava_com_alibaba_gaiax_analyze_GXAnalyze_getResultNative(JNIEnv *env, jobject thiz, jobject self, jstring expression, jobject data) { GXJniAnalyze *jAnalyze = getJniAnalyze(env, self); long res = jAnalyze->getExpressionResult(jstring2str(env, expression), data); return (jlong) (res);}
这样,我们在调用表达式解析的方法时,实际上调用的是继承自GXAnalyze并实现虚函数后的GXJniAnalyze里的getExpressionResult方法,在C++层,我们在调用取值或函数方法时,只需要使用this->getSourceValue(string,void*)就能调用在端侧实现了的虚函数,根据端侧的逻辑返回我们需要的结果。
C++调用iOS端方法的实现
在iOS端中,为了实现C++提供的虚函数,并返回给C++层调用,我们首先实现了GXAnalyzeBridge类,并在其中对取值以及函数调用方法进行了实现。
@interface GXAnalyzeBridge : NSObject - (long)getFunctionValue:(NSString *)funName paramPointers:(long *)paramPointers paramsSize:(int)paramsSize; - (long)getSourceValue:(NSString *)valuePath source:(id)source; @end
紧接着,我们创建出了GXAnalyze的继承类GXAnalyzeImpl,在类中,我们重写了函数以及取值调用方法,在方法中通过调用中间层GXAnalyzeBridge的具体实现方法,达到取值以及函数能力的实现。
class GXAnalyzeImpl: public GXAnalyze { public://解析取值long getSourceValue(string valuePath, void* source); //解析方法long getFunctionValue(string funName, long *paramPointers, int paramsSize, string source); };
最终,在C++层,我们在调用表达式的取值或函数方法时,实际上调用的是在iOS端已经实现了的继承类GXAnalyzeImpl里对应的方法。
统一数据类型
在GaiaX表达式中,会有端侧调用C++层的方法和C++层调用端侧这两种情况,但是各端的数据类型是不一致的,而且在表达式的计算过程中,每个数据的类型也是不一致的,为了能够实现一套数据类型,我们实现了GXValue数据类,在各端可以通过调用GXValue的多个数据创建方法创建对应类型的数据,作为结果返回即可。
class GXValue {public: int64_t tag; int32_t int32; //Bool 1,0 float float64; //Float int64_t intNum; //long void *ptr; //Array,Map char *str; //String};
成果展示
图 - GaiaStudio表达式编辑&运算
性能及稳定性保障
质量保障
由于采用了跨平台技术方案,因此稳定性保障是项目的重中之重。在项目交付过程中,针对不同复杂度、取值类型进行了充分的单元测试用例设计,尽可能覆盖线上可能出现的各种边界异常情况。
图 - 单元测试用例
性能分析
由于技术方案底层由C++实现,因此基础性能表现是有保障的。
计算类型 |
表达式 |
耗时/次 |
单值 |
10000 |
0.017ms |
取值 |
$data.xxx |
0.024ms |
取数据源 |
$$ |
0.019ms |
数值运算 |
10%5 |
0.029ms |
三元表达式 |
true?1:2 |
0.048ms |
函数计算 |
Size('1000') | 0.029ms |
复杂嵌套表达式 |
$data.b % 5 > 2 ? $data.b % 5 : 1 |
0.060ms |
表 - Android端真机测试性能结果
总结
GaiaX表达式充分吸收了编译原理的精髓,通过跨平台技术方案从根本上保证了多端的一致性问题,为GaiaX在移动平台的落地提供了较高的可扩展性。目前来看,该方案在性能方面还有一定的优化空间,如规约算法、词法分析、数据结构等方面的优化,这也是我们后续重要的发力点。