(开源)MicroDart:一种轻量级Dart代码解释器及在 Flutter热更新的应用

1,179 阅读23分钟

研究内容

本文在对现有Flutter动态化框架进行分析后,发现了它们的不足。提出了一种基于Dart代码解释器的Flutter动态化解决方案,命名为MicroDart。相较于现有的方案无论是在运行性能上还是兼容性,开发难度,调试难度,代码迁移,社区资源共享等多个方面都有不同程度的改进或提升。它具备以下特点:

1.易学习,MicroDart动态化开发过程与当前Flutter应用开发类似,Dart语法特性兼容性高,无需特别改造与配置,有 Flutter 开发经验的开发者能过很快接入。

2.易迁移,通过代码生成器使得它无需编写复杂的接入层就能够很好的调用非动态化代码。老代码经过少许改造就能实现动态化,第三方插件也可以自动生成桥接代码,无需自己适配。

3.兼容好,它兼容 90% 的 Dart 语法,大部分老代码可以直接解释执行,不需要进行改造。

4.性能高,本身由Dart 开发,省去了各种跨进程通信,和数据类型的转换,经过测试它在实现复杂UI绘制和业务逻辑的同时能有不错的性能表现。且它是轻量级的,无论是动态化产物还是集成动态化插件都不会造成软件的臃肿。

以下为本文完成的工作:

1.开发一套名为MicroDart的Dart脚本编译器与解释器。通过MicroDart编译器能将Dart脚本编译成字节码,并通过MicroDart解释器解析执行。Dart语法兼容性高达90%。在Flutter接入测试中性能无太大损失 。

2.定义了一种桥接模式并开发一种桥接代码生成器,能够自动生成用于MicroDart解析器与原生Dart代码交互的桥接代码。使它能够很容易的与非动态化代码交互,并能够保证Flutter社区的插件库无缝接入。

3.实现一种在Flutter中代码动态执行的方案,能同时具备UI页面与逻辑代码的动态化能力,达到与原生Flutter相同的效果。

Dart编译器

一般编程语言编译器都有“词法分析”、“语法分析”、“生成抽象语法树”、 “语义分析”、“生成中间代码”、“代码优化”、“生成可执行代码”等多个环节。在官方Dart编译器也不例外。

image.png

图2.1  Dart编译器运行流程

如图2.1所示,Dart在编译时经过“词法分析”、“语法分析”、“语义分析”阶段后会生成“抽象语法树”。再根据编译参数不同可以生成不同的代码产物。

MicroDart编译器

image.png

图3.1  MicroDart编译器流程

如图3.1所示,其中灰色虚线部分为官方Dart编译器已经实现的部分,通过官方Dart的编译器可以将源代码进行“词法分析”,“语法分析”后最终获得“抽象语法树”。其中绿色虚线框的部分是文中提到的MicroDart编译器需要实现的部分。通过 MicroDart对“抽象语法树”进行“语义分析”之后,生成MicroDart解释器可识别的元数据集与虚拟指令集,最后生成字节码文件。

MicroDart 解释器

MicroDart解释器本质上是一个基于 Dart开发的轻量级解释器,可以作为一个 Dart 库导入到任何 Dart 项目中使用。它和MicroDart编译器并不是强制绑定的,理论上可以通过设计其它语言的编译器也能编译出符合MicroDart解释器执行规范的中间代码。

MicroDart是轻量级的一个原因是因为它的内存模型,异步调度模型,异常捕捉等语法特性都强依赖于Dart原生的实现。例如MicroDart内存模型实际上是 Dart中数据结构中的List与Map实现的;异步方法调用async与await关键字等只是对 Dart原生一个封装;MicroDart中的异常捕捉也只是对 Dart异常捕捉的一个封装。

image.png

图4.1  MicroDart解释器关键类

图4.1为MicroDart的关键类实现。其中MicroDartEngine类为解释器引擎的实现类,它通过fromData方法初始化,传入的是字节码文件的字节流。通过此方法可以将MicroDart编译器生成的字节码文件反序列化并生成解释器环境。其中ops对应的是字节码文件中的虚拟指令集,constants对应的是字节码文件中的常量表,types对应的是字节码文件中类集,declarations对应的是字节码文件中的引用集以及与指令集序号的对应关系。globals对应的是在引擎运行时的全局变量,libraryMirrors对应的是外部桥接映射。MicorDartEngine初始化后,可以通过调用setExternalFunctions函数设置外部函数映射。通过callStaticFunction方法或者callStaticFunctionWaitClean调用动态化代码中的静态函数,这取决于调用的函数是否用async修饰。

Scope

Scope在MicroDart中表示作用域,它一个局部运行环境,实际上动态化的代码都是以Scope作为代码执行载体的。在Scope的类中frames表示操作栈,它是一个List,用于不同虚拟指令的数据存储与交互。params表示参数表,它是一个Map,用来存放代码执行过程中的临时变量的键值。hasReturn表示是它否需要返回值,returnValue是执行完后的返回值,parent表示该Scope的父Scope,childs表示该Scope的子Scope列表。markNeedRelease表示该Scope标记为需要被释放。opPointer是 Scope 运行时指向虚拟指令集的序号, opPointer改变后则会运行下一条指令,一般情况下opPointer都是自增的,但是通过操作OpJump 类型指令可以让opPointer指向指令集的其他位置。

image.png

图4.2  一个简单程序

图4.2中是一段简单的源代码,程序执行后的输出如图中所示。它在执行过程中实际上创建了多个Scope,其中Scope(main)是在程序执行main函数创建的,在Scope(main)的执行过程中又分别调用了fun1与fun2函数,并分别创建了Scope(fun1)与Scope(fun2)。由此可见Socpe实际上是一种树形结构。

image.png

图4.3  另一个简单程序

在图4.3中,是图4.2中的代码进行了一些修改,其中fun1与fun2函数都添加了async关键字,并添加了延时操作。在main函数的调用过程中,fun2函数进行了await修饰,而fun1没有。可见图4.2程序的输出与图4.2中的不同,这是因为fun1的运行在main函数中是异步的,没有办法去确认fun1与fun2的执行具体开始与结束时间。如果在设计上将所有操作都放在同一个Scope就没有办法管理这种异步调用的情况。但将它们的执行分别放在不同的Scope中就不会出现干扰了。

代码桥接与生成器

之所以需要代码桥接部分是因为MicroDart解释器在设计之初就被定义为一种“粘合剂”。一般而言不需要将整个项目进行动态化编译,而是选择在业务上认为需要动态下发部分代码。这样能够大大缓解了解释器的执行压力。动态化代码与非动态化的代码能够相互调用就十分重要的。因此需要一个桥接层作为动态化代码与非动态化代码的连接纽带。

在编译阶段,将动态化代码单独编写在一个或多个Library里,并设定只有这些Library里的抽象语法树节点是需要进行动态化编译的。也能在解析抽象语法树过程中,通过包名筛选出某个调用是否是外部调用。如果确定是则创建OpCallExternal而不是OpCall或者OpCallAsync指令。

image.png

图5.1  桥接相关类图

从图4.1中可知MicroDartEngine类中有libraryMirrors参数,它表示桥接外部函数包引用类别。而这里的libraryMirrors实际上就是通过桥接代码生成器生成的。在MicroDartEngine初始化后就可以MicroDartEngine的setExternalFunctions函数将生成的桥接代码放入MicroDartEngine中,这样MicroDartEngine就有了调用外部代码的能力。

图5.1中,是桥接代码相关的类图,其中LibraryMirror表示外部函数的一个Library,其name表示包名,getters表示该包中的全局或者全局变量或构造函数,全局函数的声明,setters表示全局变量改变是调用,它们都是Map对象,其中Function类在dart中表示这是个方法参数。

classes表示该包中的类的声明列表。而ClassMirror表示一个外部类,其name表示类名,getters表示该类中的成员变量,成员函数,setters表示类中变量改变时调用。

InstanceBridge实际上是为了解决外部类实例与内部类实例有继承关系是内外相关调用的问题。

代码生成器

根据桥接原理已知,如果解释器需要访问外部代码,需要通过桥接层。如果外部代码太多,那么桥阶层的代码量也是非常多的,如果这些代码全部手写将是一个十分巨大的工作量。因此通过代码生成器生成桥接代码可以大大节省这部分时间。

代码生成在flutter开发中并不罕见,有很多 Dart库为了辅助开发者,都有代码生成的功能。例如json_serializable库,可以辅助在model类中添加辅助json转换方法,这只需要一个命令。在MicroDart中也用到了这种代码生成方式。Dart官方已经提供了方便代码生成的库叫做build_runner。根据这个库的规范,可以设定规则实现想要的代码。

代码解析的另一个很重要的库叫analyzer,它能够将代码解析成抽象语法树。这和MicroDart在编译阶段生成抽象语法树很相似,但实际上并不相同。首先analyzer是一个偏向文本分析的轻量级库,它的设计理念也并不是为了代码编译。而且它的部分语法树节点并不会非常精确,例如函数的签名中的参数是否可空analyzer只能单纯的用是参数声明是否用问号来判断。且它并不能很好的识别参数表中别名方式的函数型参数。再比如函数调用的返回值在analyzer只是一个字符串,并不是一个语法树节点等。可以理解analyzer是编译器生成抽象语法树的弱化版。而MicroDart编译器实际上是一个Dart命令行程序,它的工作是强依赖于dart sdk源码的,它并不适用于放在flutter中去执行。因此代码生成器对源代码的分析是通过analyzer进行的。

代码生成器中,代码生成部分是通过code_builder这个dart库完成的,它可以通过构建语法树节点的方式组代码中的各个组成部分,然后生成代码文本。

按需生成规则

代码生成器在设计之初就支持按需生成的规则。这个设计十分有必要:

1. 根据程序的复杂性,如果引入的库太多,那么桥接代码的代码量是十分巨大的,如果全部生成会对程序大小产生影响,但是动态化代码往往需要桥接的代码往往是比较少的。

2. 动态化代码根据业务需求,对权限有一定的限制,例如网络访问,磁盘访问等,通过桥接代码按需生成,可以去掉部分桥接代码访问接口,达到这个目的。

在MircoDart编译器的编译阶段,可以通过参数设置是否生成最小化外部代码调用列表文件。代码生成器可以通过参数设置是否按需生成桥接代码,通过读取调用列表文件,生成的桥接代码可以最小化满足动态化代码的运行。这么做有利有弊,它可以减少代码量,但是限制了动态化代码的桥接能力。

桥接代码结构

生成器会将生成的桥接代码保存到Dart源码目录的lib/generated/下面,如图5.5所示:

image.png

图5.5  桥接代码结构

在图5.5中,是通过代码生成器生成的dart核心库桥接代码,一般而言会将同一个库的桥接代码生成到一个文件中。例如dart:async 这个库会生成到__dart_async.dart这个文件里。

image.png

图5.6  core.g.dart桥接代码

图5.6为core.g.dart中的代码,它是所有生成代码的整合代码,其中import列表中是所有生成的文件以及其别名,libraryMirrors对象里是库名与对应LibraryMirror对象的集合,每一个LibraryMirror对象都对应一个库文件。libraryMirrors可以在MicroDartEngine初始化后通过调用setExternalFunctions方法让MicroDartEngine具有桥接外部代码的能力。

image.png

图5.7  桥接代码

图5.7 为某个库文件生成的桥接代码,它实际上生成的是一个LibraryMirror对象,它有4个参数,第一个为库名称,第二个为getterMirrors表示全局的函数调用,类初始化等;setterMirrors表示全局的属性设置调用;classMirrors表示全局类桥接映射。其中getterMirrors与setterMirrors都是Function对象的集合。classMirrors是ClassMirror对象的集合,ClassMirror在图4.1中已经对其有描述。它和LibraryMirror类是类似的,区别在于一个是对全局库的桥接,而另一个则只是对某个类的桥接。

image.png

图5.8  num的桥接函数

image.png

图5.9  _Enum类函数的桥接代码

图5.8中为类num的桥接函数实现,其中#as 与#is表示为两个特殊函数分别代表了桥接了关键字as与is。而“==”,“+”,“-”,“*”等为运算符函数的桥接。BigInt.zero,BigInt.one,BigInt.two等都是对BigInt的静态函数或者factory函数的桥接实现。

图5.9是通过代码生成器生成的对_Enum类函数的桥接代码,从上图中可以看出Enum被桥接实现为$_Enum,它通过mixin的方式实现了InstanceBridge,这表示它是一个内部函数可识别的Instance类。通过此种方式,所有继承Enum的类实际上都会通过桥接的方式被实例化为一个InstanceBridge对象。

语法兼容性测试

为了保证编译器生成的字节码能够被准确地解析,且支持完整的dart语法,MicroDart专门设计了用于语法兼容性测试的Benchmark。目前Benchmark的测试用例已有40多个,且在不断增加中。大部分测试用例参考了dart sdk中的测试用例设计,部分测试用例整合了多个语法测试项。在项目不断迭代开发过程中,通过运行benchmark能保证编译器与解释器功能的健壮性。

image.png

图6.1  一个测试用例

图6.1为其中一个测试用例,它用于测试for循环以及if判断,图中左边代码段为动态编译的代码,右边代码段为测试用例的具体实现,在测试用例中会先通过compileSource方法对动态化代码进行编译,并输出编译产物字节流;然后通过MicroDartEngine.fromData进行解释器的初始化;通过setExternalFunctions设置外部桥接代码;最后通过callStaticFunction方法执行动态化代码中的main函数,并得出结果。最后通过expect 函数判断结果的正确性。 其他测试可以参考源码中micro_dart_compiler/test/目录下面的测试用例

MicroDart与原生Dart性能测试对比

在此时实验中,会分别将有一定代表性场景下的六个代码片段在相同的硬件设备上,以不同的编译环境进行编译执行,并统计它们的运行速度。

image.png

图6.2  测试代码片段

图 6.2 中为 6 段测试用代码片段,其中“代码段1”是普通for循环,“代码段2”是在for循环的基础上添加了随机数的调用。“代码段 3”,“代码段 4”, “代码段 5”分别是对 Dart 中常用数据存储结构 List,Set,Map的性能测试,“代码段 6”是模拟类的继承,初始化与函数调用,是最常见的调用场景。

一般而言原生的dart代码可以编译成kernel,jit,oat三种不同的产物,其中kernel是编译器优化的字节码文件,jit是经过动态编译技术运行在Dart虚拟机上的字节码文件,而oat则是根据操作系统编译出的二进制机器码。本次测试会将代码段的调用耗时统计实现分别以原生与MicroDart实现,并编译成kernel,jit,oat三种不同执行产物去测试,会循环测试100次并算出其平均值。

Dart性能测试结果:

运行环境代码段1代码段2代码段3代码段4代码段5代码段6
dart+jit2ms303ms25ms39ms43ms169ms
dart+oat0ms226ms18ms20ms20ms173ms
dart+kernal34ms631ms281ms1556ms498ms574ms
microdart+jit284ms577ms651ms349ms366ms1777ms
microdart+oat92ms291ms108ms102ms108ms1056ms
microdart+kernel6597ms1264ms1550ms2192ms1372ms4956ms

总结:MicroDart的运行效率相较于原生dart而言,平均相差在5-10倍浮动。又因为dart本身媲美原生的运行效率,虽然MicroDart和原生dart在运行效率上有一定的差距,但相较于JavaScript,Lua等其它脚本语言仍然有不小的优势,因为它不需要跨进程通信,也不需要嵌入相对复杂的虚拟机,其本身就是基于dart开发,其很多语法特性只是对原生 Dart做的一种代理消耗上会更小。

Flutter动态化代码与原生Flutter程序对比

对于Flutter应用可以通过“程序包大小”,“内存占用”,“CPU占用”,“UI帧率”等多个维度去分析。其中除了程序包大小,另外几个维度数据都可以通过 flutter 开发工具里的devtools[40]采集得到。

本次实验将Flutter分为三个场景:

“场景一”为模拟商品展示的单页面,在此场景中的图片均会通过第三方 Flutter 库extend_image作为图片加载与显示的插件。在此场景中可以测试 MicroDart 对单页面 UI 代码的运行情况,以及集成第三方 Flutter 库的可行性。

“场景二”为 Flutter官方提供的动画演示程序,本意是让开发者更好的学习如何在 Flutter 中绘制动画。而动画效果意味着 UI 需要不断刷新,在不同的动画下去分析程序帧率会更加的客观。

“场景三”为谷歌开发的样例程序flutter_gallery,它用于初学者入门flutter开发。里面包含了多个UI的场景以及几乎全部的UI控件,如果MicroDart能够解释运行flutter_gallery,那么就证明MicroDart解释器已经能够动态解释运行大部分Flutter的UI控件。

在这三个场景实验中,会先在原生flutter与MicroDart下运行情况下分别统计它们的“程序包大小”,“内存占用”,“CPU占用”与“UI帧率”。且在测试MicroDart的过程中会将桥接代码分为“全量桥接桥接代码”与“最小桥接代码”两种情况,它们的区别在于前者会生成全量的dart与flutter api的桥接代码,而后者只会生成动态化代码调用的那部分。

image.png

图6.3  Flutter 场景一测试程序页面

image.png

图6.4  Flutter 场景二测试程序页面

image.png 图6.5  Flutter 场景三测试程序页面

场景一测试结果:

运行环境原生FlutterMicroDart全量桥接MicroDart最小桥接
程序包大小19.2mb42.3mb19.2mb
动态化包大小028kb28kb
平均运行内存8mb41mb8.4mb
CPU平均占用率1-10%1-10%1-10%
UI帧率60fps60fps60fps

 

场景二测试结果:

运行环境原生FlutterMicroDart全量桥接MicroDart最小桥接
程序包大小50.7mb103mb52.3mb
动态化包大小0141kb141kb
平均运行内存8mb48mb8.4mb
CPU平均占用率1-10%1-10%1-10%
UI帧率60fps60fps60fps

 

场景三测试结果:

运行环境原生FlutterMicroDart全量桥接MicroDart最小桥接
程序包大小146.5mb216mb151mb
动态化包大小06mb6mb
平均运行内存25mb106mb60mb
CPU平均占用率1-15%1-15%1-15%
UI帧率60fps60fps60fps

从数据上来看,可以分析出MicroDart与原生Flutter在“CPU平均占用率”与“fps帧率”上几乎没用太大损失,它们“CPU平均占用率”在1%-15%之间波动,而“UI帧率”基本在满60帧,即使在刷新频繁的场景二中的 UI 动画场景里也是如此。之所以会这样是因为Flutter的UI渲染占用了整个应用80%的运行时间,而Flutter的UI渲染是由Widget,Element,RenderObject这3个类担任的。Widget是随用随销毁的,最终参与UI渲染的是RenderObject对象,但是在Flutter的UI开发中,接触最多的UI组件就是Widget,在动态化脚本运行过程中作为本地代码桥接的最多的也是Widget,而Widget的建立与销毁并不会占用太多的UI渲染时间,这也就最终造成了虽然MicroDart在运行效率上弱于原生 Dart ,但是在Flutter上的表现并没用太大的降低。

在程序包大小方面,全量桥接与原生相比大了23mb-50mb,而最小桥接与原生相比相差有1 mb -10 mb的大小差距。经过分析可得出原因:Flutter的sdk代码量是很大的,这让生成的全量桥接代码也变的很大。而在场景三中,最小桥接与原生Flutter应用有 5 mb的增加首先是因为flutter_gallery的代码量原本就很大,就算是最小桥接的代码量也有不少,且生成的安装包里也包括了动态化字节码的6 mb。

运行内存方面,在场景一中最小桥接与原生Flutter差距只有 0.4 mb,而全量桥接与原生的差距为 33 mb,全量桥接占用内存比最小桥接多了 32 mb,分析可得出结论:全量桥接代码载入内存后占用了大量的内存。在场景二与场景三中,全量桥接与最小桥接在内存占用上的差距分别是 40 mb与 46mb,这符合在场景一中获得的数据,与原生Flutter相比有1-3倍的差距。为了运行速度考虑,桥接代码的引用本身是用 const 修饰的,这会让原生 dart 运行时就将所有桥接代码载入内存,因此桥接代码越多消耗内存就越大。考虑到场景三生成的动态化字节码就有6 mb,而场景一的只有28kb,场景二的只有 142kb。由此可见动态化包的大小也对MicroDart 运行内存有重要影响。从原理上分析,动态化包越大则意味着虚拟指令集越多,在解释器初始化后会将所有虚拟指令载入内存,这部分是内存消耗是恒定的。

最后可以得出结论,在 Flutter运行性能上通过MicroDart执行的代码与原生 Dart相比差距很小。在程序包大小方面与桥接代码量以及动态化字节码大小有很大关系。在实际应用中需要对这方面做出平衡。而在运行内存方面,主要影响方面是桥接代码量与动态化包的大小。总体而言MicroDart在Flutter上运行性能是十分不错的。

工作展望

虽然MicroDart在现阶段已经基本达到解决Flutter的代码动态化问题的目标,但它仍然有不小的改进空间。

首先MicroDart解释器在运行速度与内存占用上仍有一定的优化空间。在解释器的内存模型上,可以将部分代码通过C或C++语言进行改写以加快它的运行速度并减少内存消耗。虚拟指令方面也可以进行优化,对部分指令进行合并或者拆分能够进一步加快运行的速度。在const修饰的对象也能进一步优化,现阶段是当成普通对象来对待的,这样实际上并没有享受到const带来的性能提升。

其次现阶段的代码生成器虽然能够解决大部分的桥接代码生成工作,但是在偶尔的情况生成的桥接代码会有代码报错的情况,需要手动将桥接代码修改正确,有一定的优化空间。

在Dart语法兼容性方面,虽然当前MicroDart已经兼容了dart 语言90%的语法,但是仍然有部分语法无法兼容,例如dynamic关键字,这是因为通过dynamic声明的对象无法确认它具体的类型,在方法调用时无法定位到具体的指令集。在泛型的支持也有一些缺陷,虽然在MicroDart动态化代码内部已经支持泛型,但是在代码桥接时只能对具体的泛型类型进行桥接,这部分桥接代码仍然需要收到添加。这是因为在移动端Dart对反射进行了阉割,没有办法通过反射的方式去调用泛型方法。

现今Dart语言仍是在快速发展的,很多的语法新特性在不断提出,这也意味着MicroDart需要不断更新迭代来兼容这些语法新特性。这是无疑是不小的工作量。

   当前MicroDart动态化生态并不闭环,比如未实现动态下发的服务器端;也没有代码运行日志分析,错误排查功能;在Flutter动态化上还只停留在动态化解释器执行字节码的地步,暂未设计模块化小批量更新的方案;也未设计资源动态化的方案。

MicroDart在现阶段仍然不算十分成熟,但我不会放弃对它的更新迭代,当前版本仍然在开发探索阶段,希望能吸引更多对此项目感兴趣的开发者,并给一些参考意见。

另附上开源地址: github.com/lancexin/mi…