一、前言
Flutter 跨端技术一经推出便在业内赢得了不错的口碑,它在“多端一致”和“渲染性能”上的优势让其他跨端方案很难比拟。虽然 Flutter 的成长曲线和未来前景看起来都很好,但不可否认的是,目前 Flutter 仍处在发展阶段,很多大型互联网企业都无法毫无顾虑地让全线 App 接入,而其中最主要的顾虑是包大小与动态化。
动态化代表着更短的需求上线路径,代表着大大压缩了原始包的大小,从而获得更高的用户下载意向,也代表着更健全的线上质量维护体系。当明白这些意义后,我们也就不难理解,在 Flutter 的应用与适配趋近完善时,动态化自然就成为了一个无法避开的话题。RN 和 Weex 等成熟技术甚至让大家认为动态化是跨端技术的标配。
二 动态化方案选型
a. 产物替换
选型中首先考虑到的是下发产物替换,官方在也曾经推出了 Code Push 方案,甚至可以支持 Diff 差量下载,但是在 2019 年 4 月被叫停。这里引用一下官方的发言 Flutter/issues/14330:
To comply with our understanding of store policies on Android and iOS,
any solution would be limited to JIT code on Android and interpreted
code on iOS. We are not confident that the performance characteristics
of such a solution on iOS would reach the quality that we demand of
our product. (In other words, “it would be too slow”.)There are some serious security concerns. Since these patches would
essentially allow arbitrary code execution, they would be extremely
attractive malware vectors. We could mitigate this by requiring that
patches be signed using the same key as the original package, but this
is error prone and any mistake would have serious consequences. This
is, fundamentally, the same problem that has plagued platforms that
allow execution of code from third-party sources. This problem could
be mitigated by integrating with a platform update mechanism, but this
defeats the purpose of an out-of-band patching mechanism.
简而言之,就是官方对动态化后的性能没有自信,并且对安全性有所顾虑。之前,官方提供方案的局限性也十分明显。比如对 Native-Flutter 混合 App 支持不友好,并且无法进行灰度等业务定制操作,所以不能满足通用性和高性能的核心目标。
b. AOT 搭载 JIT
Flutter 在 Release 模式下构建的是 AOT 编译产物,iOS 是 AOT Assembly,Android 默认 AOTBlob。同时 Flutter 也支持 JIT Release 模式,可以动态加载 Kernel snapshot 或 App-JIT snapshot。如果在 AOT 上支持 JIT,就可以实现动态化能力。但问题在于,AOT 依赖的 Dart VM 和 JIT 并不一样,AOT 需要一个编译后的 “Dart VM”(更准确地说是 Precompiled Runtime),JIT 依赖的是 Dart VM(一个虚拟机,提供语言执行环境);并且 JIT Release 并不支持 iOS 设备,构建的应用也不能在 AppStore 上发布。
实现此方案需要抽离一份 DartVM 独立编译,再以动态库的形式引入项目。通过初步测试,发现会增大包体积 20MB+,这超过了 MTFlutter 之前做 Flutter 包体积优化的总和。进一步让 Flutter 包体积成为推广与接入业务方的巨大阻碍,不满足我们对适用性的要求。
c. 动态生产 DSL
Native 侧本身具备 JS 动态执行环境,利用这个执行环境动态生成包含页面和逻辑事件绑定 DSL,进而解析为 Flutter 页面或组件,也可以实现动态化诉求。技术思路接近 RN,但与其不同的是利用 Flutter 渲染引擎和框架。这种先将代码执行起来再获取 DSL 的手段,我们简称为动态生产 DSL。
此方案可以很好地支持逻辑动态化,但弊端也比较明显。首先要对齐 Flutter 框架,JS 侧的开发量很大且开发体验受损。另外,对 JS 的依赖偏重,构建的 JS 框架本身解释执行有一定开销,对于页面逻辑与事件在运行中需要频繁地进行 Flutter 与 JS 的跨平台通信,同样也会产生一定开销。这不能满足 MTFlutter 团队对高性能的诉求。更严重的是,此方案对开发同学的开发习惯并不友好,将 Dart 改为 JS,现有的 Flutter 开发工具无法直接使用,这与低成本诉求背道而驰。
d. 静态生产 DSL
前面说 “将代码执行起来再获取 DSL 的手段,我们简称为动态生产 DSL”,那么代码不执行直接转换 DSL,就称为静态生产 DSL 方案。
静态生产的特点是抹平了平台差异,因为 input 是 Dart source 与平台无关,直接将 Dart source 内的完整信息通过一层转换器转换到 DSL,然后通过 Native 和 Dart 的静态映射和基础的逻辑支持环境,使得其可以在纯 Dart 的环境下渲染与交互。
在具体实现上,可以利用 Dart-lang 官方提供的 Analyzer 分析库(该工具在 Dartfmt、Dart Doc、Dart Analyzer Server 中都有使用)构建 DSL。该库提供了一组 API 能对 Dart source 进行分析,按照文件粒度生成 AST 对象。AST 对象用整齐的数据结构包含了 Dart 文件的所有信息,利用这些信息可以便捷地生成所需的 DSL。 所有的这个分析 + 转换的过程全部在线下进行。接下来, DSL-JSON 以 Zip 的形式下发,Flutter 的 AOT 侧以此为数据源,完成整个 Flutter 项目的渲染与交互。
这种方案,一来可以保持 Flutter/Dart 的开发体验,也没有平台差异,逻辑动态化依赖静态映射和基础逻辑支持,而非 JScore,有效地避免了性能上的开销。综上考虑,静态生产 DSL 最终成为美团和咸鱼团队选型的方案。
三 静态 DSL 方案
3.1 美团动态化Flap框架
如图所示,三处绿色部分为一个阶段的阶段产物,起到承上启下的作用。以绿色部分为界,整体架构自然而然地就被划分成了三个区域:
- 下层第一部分是对开发阶段的赋能,产物是正确且规范(也满足 Flap 规范)的 Dart 源码。
- 第二部分是 DSL 的转换器,产物是 JSON 格式的 DSL,用于标准化的描述页面层级与逻辑。
- 上层的第三部分是运行时环境,准备了所有需要的符号构建 Dart 对象与逻辑,产物是动态化 App 或动态化的模块。
3.2 Flap 的原理与挑战
上图中的核心模块是转换器部分和运行时部分,接下来会介绍下这两个部分的原理与部分实现。
3.2.1 转换器原理
AST & DSL
AST 意为抽象语法树(Abstract Syntax Tree)。Dart 的 AST 和其他语言的 AST 基本概念类似。‘package:front_end/src/scanner/token.dart’ 中定义了所有的 Token,AST 也是通过词法分析、语法分析、解层级嵌套得到。ASTNode 对象作为存储编译单元中重要信息的基本数据结构,派生类基本分为 Declaration、Expression、Literal、Statement。
DSL 意为领域特定语言(Domain-specific Language)。表示专门针对特定问题领域的编程语言或者规范语言。相对自然语言,编程语言是不灵活的,它的语法和语义设计常取决于它的执行环境和特定目的。过去人们总是发明新的编程语言,近年来新出现的语言越来越相近,因此 DSL 也变得流行起来。
那 Flap 的 DSL 具体是什么?对于开发者而言,那这个 DSL 就是 Dart Code。而对于机器或 App 而言,那这个 DSL 就是 JSON。
前面的技术选型中提到:
利用 Dart-lang 官方提供了 Analyzer 分析库,官方的 Analyzer 的能力可以拿来直接用,该库提供了一组 API 能对Dart source 进行分析,按照文件粒度生成 AST 对象,该数据结构包含了 input 的 Dart 文件的所有信息。
我们的 DSL 的基本原理就是对 AST 内数据的一个描述, 并附带一些其他操作。
因为用 Analyzer 的 API 跑出的 AST 也叫 CompilationUnit,实际上是一个编译单元,里面还存有很多编译相关的属性例如 lineInfo、beginToken 等。但使用 DSL 的方式不依赖编译,所以很多不需要的属性会被裁剪或忽略。
在转换器入口会对大类(identifier、statementImpl、literal、methodInvocation 等等)进行分发,每一个大类的数据结构使用一种中间结构 Dart model 来传输,然后对于大类中细分的类型(IfStatement、AssignmentStatement、DoStatement、SwitchStatement 等等),配有足够细粒度的转换接口,以 AST 结构作为输入,以 Map 节点作为输出。最终定义并提炼了 10 种标准的 Map 结构(class、method、variable、stmt 等等)来承载所有类型。
举个例子
一个简单的 Widget 节点经过转换后得到这样的 DSL-JSON,可以看到 DSL 的可读性还是 OK 的(默认下发时产物是一个压缩成单行并加密的二进制文件,这里是解密后 Format 换行后展示的)。我们在转换中会区分普通的字符串、变量名引用、系统枚举等类型,加以不同的符号表示。
关于逻辑
举一个简单的四则运算的例子,可以看出在对于“乘法应当先计算”这个规则上,我们的 DSL 能够自动遵循, 其中的奥秘是 Analyzer 帮我们做了这种运算优先级的判断,归根结底还是一种描述 AST 的工作,我们自己不会去根据静态代码做分析过程。
关于语法糖
语法糖往往画风清奇,结构与众不同,但是在 AST 中还是很诚实的,该什么结构就是什么结构。所以语法糖应该在转换器侧进行展开为常规结构再转 DSL,而不是对特殊格式设置特殊的 DSL 传到运行时再去解析。
这里只举了一些简单的例子,只是 DSL 体系中的一个片段,实际在项目落地时有很多较为复杂的逻辑,类似于循环套循环内进行集合操作或是异步回调内加多重三目逻辑等等。这里因为篇幅原因和涉及到业务代码相关就不展开详细的介绍了,其中的原理是一样的,都是描述 AST 的过程中增加一些特殊处理,最终会将转换产物的 Map 节点根据原有 AST 的层级结构组装起来,再通过 JSONEncode 转为 JSON。
转换器侧能够完整的描述一个 Dart 文件的所有信息,如图 6 所示。值得一提的是,不同的节点还可能出现任意结构,method 里的 Argument 里可能是一个全局变量,条件表达式的右边又可能是一个方法。对于这种相同的结构即使出现在不同的位置也应当使用一套处理逻辑来转换,因此转换器是以迭代为主加小范围递归的设计思路。
将细粒度转换接口按照具体类别分在不同文件中(statement_factory、class_factory、function_factory) 等待解析生产总线的调用。实际操作中各个类之间是近似于网状的调用,因此所有调用应当都是 Static 的,并且内部隔离,不引用不修改外部变量,做到无副作用。
DSL 转换器是一个命令行程序,因此可以无缝的部署到自动化的机器上。新代码合入主干后, 接下来的 Bundle 生成与分发逻辑都可以使用各种图形化界面的发布系统来操作。
3.2.2 运行时原理
Prepare & Running
运行时相关的操作是在 App 内发生的,包括初始化,拉取 DSL,解析与使用。 简言之可以分为 Prepare 和 Running 两个阶段。Prepare 是准备各种运行时所需的符号,包括系统类符号与自定义符号,属性符号与方法符号(这里所说的符号实际就是 Dart 内的对象)。Prepare 阶段完成才能进行后续的 Running 相关操作,具体是页面的构建,事件的绑定,交互与逻辑的正常运转。
万能方法 Function.apply()
Flutter 期望线上产品是编译后的“完全体现”,同时为了避免生成过大的包,并不支持 Dart:Mirror。“Flutter apps are pre-compiled for production, and binary size is always a concern with mobile apps, we disabled dart:mirrors.”那么,在这种前提下,如何将外部符号转内部符号?Function() 对象提供了这样一个万能方法。
// function.dart
external static apply(Function function, List positionalArguments,
[Map<Symbol, dynamic> namedArguments]);
第一个参数是 Function 类型,后两个参数是该函数所需的参数(位置参数与命名参数,这两者在 DSL 中都可以取到),因此只要能获取到某个 Function,那就能在任何时候调用它。
此 Function 若为 Constructor Function 那返回值则为构造出的对象类型。
Proxy-Mirror
DSL 后只能得到字符串的标识,因此需要建立一个 String 与 Function 的映射关系,考虑到类名方法名,数据结构应该是 {String:{String:Function}},通过 className 和 functionName 两个 String Key 即可取得一一对应的 Function(),下面给出一个系统类的类方法(构造方法)的代码片段:
{
'EdgeInsets':
{
'fromLTRB': (left, top, right, bottom) => EdgeInsets.fromLTRB(left, top, right, bottom),
// ...other function
},
// ...other class
};
然后对于系统类的实例方法、getter、setter 则需要在外部多传一个 instance 参数,instance 是外部通过该类的构造方法的 func 创建后传入。
// instance method
"inflateSize": (instance, size) => instance.inflateSize(size),
// getter
"horizontal": (instance) => instance.horizontal,
// setter
"last": (List instance, dynamic value) => instance.last = value,
Custom Class’s meta
对于自定义类,我们需要构建一个模拟的元类系统,存放所有符号信息,在解析时将所有的 JSON 节点转成可处理的对象。所有的属性声明都会构建成 FlapVariable 类型,所有的方法声明都会构建成 FlapFunction 类型。
如图所示,父类和元类也是有相应的指针,父类的成员变量也会填充到子类,并且通过 mixin 的方式将类相关属性注入到派生类类型,例如 FlapState,FlapState 继承自 state,这样既可以让系统类的生命周期方法留个调用链的开口,也可以使用注入的运行时类属性。
Evaluate
如下面代码的例子,一个 if 语句的 JSON 节点下发后,经过 parser 之后会得到一个 IfStatement 对象,这类对象都有一个特点就是包含几个属性,和一个运行时入口方法 evaluate(Scope scope)。这个方法在抽象类 Evaluative 类中,所有语句和表达式的类都会继承于此,自动获得 evaluate 方法,其中属性部分是在解析过程中解析成 Dart 对象后通过构造方法的参数传入的。
class IfStatement extends Statement {
dynamic condition = undefined;
Body thenBody;
Body elseBody;
IfStatement(this.condition, this.thenBody, [this.elseBody]);
// 简化版代码
ProcessResult evaluate(Scope scope) {
bool conditionValue = condition.evaluate(scope)
if (conditionValue){
return thenBody(Scope);
}else{
return elseBody(Scope);
}
}
}
属性中的条件对象与语句对象在解析的过程中并不会被触发, 真正的触发是方法被调用时从运行时的入口方法 evaluate 进入,此时才会通过作用域 Scope 判定条件是 true or false,然后调用到其他需要 evaluate 的 Dart 对象,如下图所示:
经过表达式的堆叠,实现了语句,经过语句的堆叠实现了 body,再补充上形参和返回值,则就构成了我们运行时中的自定义方法 FlapFunction。这里要用到一下仿真函数的概念,FlapFunction 要实现 call 方法,这样在外部调用时就真的和 Function 画风一致了。
Scope
Scope 在逻辑的执行中实际就是充当了 Context 上下文的作用,因为每个方法或表达式被 evalute 时需要一个 Scope 入参,这个 Scope 是从外部传入的,并且这一行语句对象执行后 Scope 还会作为入参传给下一行语句。比如第一行语句声明了一个 “code” 的变量,第二行语句对这个 “code” 进行修改,则需要先通过引用从 Scope 中取出这个 “code” 的值,不但可以从 Scope 中取出声明的属性,也可以取出声明过的方法,方法内也是可以调用方法的。这也就解释了为什么我们可以处理自定义方法中的逻辑。
上图描述了 Scope 在实际运用中的两种场景。左半部分是点击按钮触发 onTap 回调,需要找到 confirm 方法,此时会先从局部作用域的方法列表里找,没找到,则会 outer 一层去类作用域里寻找,此时找到了该方法的实现。
右半部分展示了执行该方法的 body 时是需要传入的 Scope 是如何构建的。先从符号大本营中获取全局变量、全局属性构成全局作用域,再从此类的元类中取出属性和方法构成类作用域,再构建局部作用域,当然参数也是会放到局部作用域里的,以此构建了完整的 Scope 传入 body 的 evaluate 方法支撑后面的逻辑执行。
3.2.2 遇到的挑战
工作量大,需要长期有耐心
首先解释下,这里的工作量大并不是指系统方法映射等这种体力活的工作量大,这些我们都是有自动生成且按需生成的(生态部分会提到)。我们所说的工作量大,主要是指涵盖转换器、运行时的研发以及生态相关建设等,我们要尽可能的满足所有的 Dart 语法才能让业务代码能够低成本的转换,并且有众多的脚本与工具支撑。
项目复杂,需要设计合理的架构以支撑扩展
在项目的分模块开发中,各个模块(parser、intermediate、runtime 等等)严格遵守单一职责原则与最小知道原则,最大化的杜绝了模块间耦合,模块与模块的通信由一些标准的数据结构进行(map 或继承自 ASTNode 的结构)。 这就使得任何一个模块出现重大重构时不会影响到其他模块,其中底层核心的几个类的单侧覆盖率接近100%,有专人负责优化。并且在项目中随处可以抽象类、接口类、mixin 类等,这也就使得随着支持的能力越来越复杂时,项目的可读性不会成反比,代码不会变“恶心”,而是以整齐的方式扩张,文件多而不乱。
疑难杂症较多,对问题保持足够的信心
有时候会遇到一些诸如静态方法调用构造方法时作用域被覆盖、循环语句嵌套时内侧 continue 之后外侧语句也会跟着停、某方法参数的 Function 取完引用之后 Function 也跟着执行了等等的 Bug,解 Bug 是开发中必不可少的一部分,有时候加个 if else 用 easy way 可以很快解决,但我们不会那么做,探索优雅 Right Way 的乐趣是研发过程中的一个重要组成部分。
相比于草草了事之后,每晚睡前都会面临这段代码“灵魂”拷问,我们更愿意多花时间思考把代码写的像 Mac pro 主机的包装那样“丝滑”。这样的工作氛围培养了每位同学的信心,只要是必现问题,基本都能优雅地解决。