飞镖(Dart)既是一种轻巧的武器,也是一门编程语言的名称。这门语言的 Dart VM 虚拟机内置在 Flutter 框架中,在移动端开发中有广泛的应用。那么,我们能否脱离 Flutter,单独为原生项目(如游戏引擎或高性能图形应用)嵌入 Dart VM 呢?这就是本文关注的场景,亦即对虚拟机的嵌入式集成(embedding)。
目前,Dart 已经提供了类似 node
的 dart
运行时,供终端内直接使用。但这毕竟是个单体应用,不易集成在其他程序中。如何从这个单体应用或者 Flutter 之中,分离出 Dart VM 来单独使用呢?要解决这个问题,大致需要了解这三部分的内容:
- Dart VM 的基础概念与工作方式。
- 如何为 iOS 编译出可嵌入的 Dart VM 静态库。
- 如何在 iOS 上嵌入 Dart VM,执行最简单的 Hello World。
下面本文将以 iOS 平台为例,演示如何脱离 Flutter,在自有的原生项目中编程式地使用 Dart VM 虚拟机。
Dart VM 的工作方式
Dart VM 执行代码的方式,和大家所熟悉的 V8 等脚本引擎是很不同的。其中最大的一点区别在于,Dart VM 并不支持直接解释执行字符串源码。如果想把它当作方便第一的脚本子系统,这无疑是有所不利的。但这点灵活性上的牺牲,换来了引擎在工程能力上很大的突破,主要包括下面这三种不同的工作模式:
- 基于二进制 AST(所谓 Kernel Binary)文件的 JIT 执行。
- 基于预热快照(所谓 AppJIT)的 JIT 执行。
- 基于 AOT 快照(所谓 AppAOT)的 AOT 执行。
Dart VM 实际上还能配置出很多其他工作方式,比如关闭 JIT 的解释执行之类。这里列出的只是最关键的几种。
在 Dart VM 的这些工作模式中,除了第一种最接近现在的 JS 引擎以外,剩下的两种模式都是 JS 技术栈下较难做到的(当然 V8 也部分支持快照)。不仅如此,真实世界下的 Dart VM 甚至还玩出了更多的花样。在深度支持 Flutter 的过程中,Dart VM 在移动端的执行模式,与原本的桌面端产生了进一步的差异。这些区别非常容易使人感到困惑,有必要首先理清楚。
简单说来,在下面列出的场景下,Dart VM 都具备不同的工作模式:
- 在桌面端执行标准的
dart
命令时,引擎会通过内部的 CFE 通用前端(Common Front End)组件,将.dart
格式源码解析为二进制形式的 AST 语法树,然后在主 Isolate(类似主线程)JIT 执行,对应模式 1。 - 在桌面端执行
dart --snapshot-kind=app-jit
命令时,引擎会在解析.dart
源码后,用训练数据来 JIT 执行它,并把虚拟机的状态保存为.snapshot
格式的快照文件。这份快照可以被 VM 重新读取,一步到位地恢复 JIT 后的现场,从而优化启动性能。作为典型例子,Flutter 的flutter run
命令执行的就是flutter_tools.snapshot
快照, 对应模式 2。 - 在桌面端执行
dart2native
命令时,Dart 源码会被编译成平台机器码,获得.aot
格式的产物。这个产物类似原生的 ELF 可执行格式,可以被预编译出的dart_precompiled_runtime
运行时动态加载执行,对应模式 3。 - 在移动端 Flutter 的 Debug 模式下,Dart 源码会在开发者的桌面端被编译成
.dill
格式的 Kernel Binary,然后这些.dill
文件会通过 RPC 服务动态更新到移动设备上。这是 Flutter 支持增量编译和热重载等黑科技的基础,对应模式 1 的变体。 - 在移动端 Flutter 的 Release 模式下,Dart 源码会在开发者的桌面端被交叉编译成 ARM 机器码,与预编译出的运行时相链接,对应模式 3 的变体。
听起来是不是很复杂?实际使用中,记住这几条简单粗暴的规则就够了:
- 最简单的 Kernel Binary 格式是
.dill
,平台通用。 - AppJIT 预热生成的快照是
.snapshot
格式,平台不通用。 - AOT 编译命令生成的是
.aot
格式文件,平台不通用。
Dart VM 基于 Kernel Binary 的标准运行模式
上图中展示的是标准的 Dart VM 在桌面端运行时的场景,注意 CFE 编译前端和 VM 在架构上的分离:虽然在直接执行 .dart
文件时我们对此无感,但在 Flutter 的移动端场景下,情况就不同了。Flutter 直接把 CFE 封装到了桌面端的 Flutter Tool 命令行项目中(纯 Dart 实现),从而在移动端的 Flutter Engine(C++ 与 Dart 混合实现)当中只包含了 VM 部分。如下所示:
Flutter 上的 Dart VM 运行模式
在 Flutter 中,Dart VM 的编译细节被框架封装掉了。但这并不难通过 VSCode 中断点调试 Flutter Tool 的方式来详细了解,这里不再展开。
进入下面的动手阶段前,最后科普几个常见问题:
- 二进制 AST 不是字节码,更类似于将 Babel 编译出的语法树 JSON 结构以二进制形式表示。这方面可参考个人对 TC39 Binary AST 提案的科普。
- 高级语言也可以直接编译成机器码,只需要链接到一个支持垃圾回收和平台 IO 等基础能力的原生运行时就行。像 Go 和 Static TypeScript 都是这么实现的。你说那些类似 JS 的特别动态的部分怎么办呢?脚本解释器也可以编译成机器码,原理上回退成解释执行就可以了(所以并不是说编译成机器码就一定快,有时科技就是以换壳为本的)。
- Dart 之所以做 AOT 编译,并不是因为 AOT 一定强过 JIT。相反,Java 等高级语言的 JIT 性能上限往往高于 AOT。Dart VM 此举的主要出发点,是满足 iOS 长年以来禁用 JIT 的政策限制,并匹配移动端场景的特性(如页面驻留时间短,需快速达到峰值性能,对代码体积敏感等)。
编译 Dart VM 静态库
现在,我们已经充分熟悉了在 PPT 上安利 Dart VM 时的要点,下面可以动手干活了。
首先,假设我们有一个 C++ 项目,如何为其接入 Dart VM 作为脚本引擎来使用呢?和使用任何其他 C++ 库一样地,这需要第三方库的头文件和库文件。一般的 C++ 库都会在其 include
目录里放头文件供外部使用,并默认编译出各类 .a
库文件供复用。但令人困扰的是,Dart VM 并没有按约定俗称的方式这么做,并且源码树里也没有像 Skia 那样附带可用的此类示例。所幸坐镇 Dart 团队的 Vyacheslav Egorov(就是那个把 JS 性能优化到超越 Rust 的家伙)近期给出了非官方性质的 Embedder Example。只要直接把他提供的这个 patch 放到 Dart 源码里,就能基于 Dart VM 现有的构建系统,编译出嵌入 Dart VM 后的 C++ 项目示例了。其 C++ 部分的具体代码有些冗长,概括说来分这么几步:
- 在
dart::embedder::InitOnce
之后Dart_Initialize
。 - 用
Dart_CreateIsolateGroupFromKernel
加载 Kernel Binary,创建相应的 Isolate。 - 启动
Dart_RunLoop
,正式执行 Dart 代码。
相应的 GN 构建配置如下(这块较为冷门,但谷歌系项目的构建系统在熟悉后还是很不错的,个人可能后续做个系统的整理介绍):
# 嵌入 Dart VM 的可执行文件入口
executable("embedder_example_1") {
# 该可执行文件依赖下面定义出的静态库
deps = [ ":libdartvm_for_embedding_nosnapshot_jit" ]
sources = [ "embedder_example_1.cc" ]
include_dirs = [ ".." ]
}
# 包含 Dart VM 的最小静态库
static_library("libdartvm_for_embedding_nosnapshot_jit") {
deps = [
":standalone_dart_io",
"..:libdart_jit",
"../platform:libdart_platform_jit",
"//third_party/boringssl",
"//third_party/zlib",
]
sources = [
"builtin.cc",
"dart_embedder_api_impl.cc",
]
}
这个例子的编译使用方式是这样的:
# 基于 Dart 的构建系统,编译出 C++ 产物
$ ninja -C xcodebuild/ReleaseX64/ embedder_example_1
# 基于 Dart 的基础设施,将 hello.dart 编译成 hello.dill
$ dart pkg/vm/bin/gen_kernel.dart \
--platform xcodebuild/ReleaseX64/vm_platform_strong.dill \
-o /tmp/hello.dill \
/tmp/hello.dart
# 用编译出的可执行文件,执行 hello.dill
$ ./xcodebuild/ReleaseX64/embedder_example_1 \
out/ReleaseX64/vm_platform_strong.dill \
/tmp/hello.dill
基于 Dart VM 自带的构建系统,这个过程是可以顺利实现的。但如果要在第三方项目中编译上面的 C++ 逻辑,那么除了手动从 Dart VM 构建产物中挑出 libdartvm_for_embedding_nosnapshot_jit.a
静态库以外,还需要复制出这些头文件,以便正常链接:
dart/runtime/include
目录下的所有头文件。dart/runtime/platform
目录下的这些头文件:assert.h
(会造成 Xcode 冲突,可改名为dart_assert.h
)floating_point.h
globals.h
hashmap.h
memory_sanitizer.h
走通第一步后,自然要尝试交叉编译出面向 iOS 的静态库了。这时有个诡异的问题:Dart VM 中居然既并没有提供面向 iOS 的编译配置项(准确地说是有 is_ios
配置,但设置它只会导致编译失败),也没有提供相应的文档,该怎么办呢?这个问题一度困扰了我很久,为此我翻阅了很多关于 GN 和 Ninja 构建系统的资料,甚至尝试了直接修改它们生成的 Xcode 构建配置,但都没有成功。后来还是 Vyacheslav Egorov 给我指了条路:依赖 Flutter 的构建环境来编译 Dart 即可。
个人仍然认为这种做法是不合理的,因为你说如果我想编译 V8,为什么需要依赖 Chromium 的编译环境呢?但现阶段暂时只能这么做,具体来说是这样的:
首先进入 Flutter Engine 的第三方依赖目录中找到 dart 目录,将如下构建配置加入 runtime/bin/BUILD.gn
文件中:
static_library("libdartvm_with_utils") {
complete_static_lib = true # XXX
deps = [
":standalone_dart_io",
"..:libdart_jit",
"../platform:libdart_platform_jit",
"//third_party/boringssl",
"//third_party/zlib",
]
defines = [ "DEBUG" ]
sources = [
"builtin.cc",
"dartutils.cc",
"dart_embedder_api_impl.cc",
]
}
然后借助 Flutter Engine 的编译配置来执行构建就行了:
# 在 Flutter Engine 的工作目录执行构建
$ ninja -C out/ios_debug_sim_unopt libdartvm_with_utils
这样我们就可以从 Flutter Engine 构建产物中获得 libdartvm_with_utils.a
文件了,这就是可以在 iOS 上接入的 Dart VM 静态库(这里通过暴力配置加入了所有依赖,因此体积会非常大。但不难后面手动配置规则来优化)。
嵌入运行 Dart 版 Hello World
有了静态库、头文件和 C++ 入口,我们就可以在 iOS 上独立运行 Dart VM 了。但这里还需要获得面向 iOS 平台的 .dill
格式 Kernel Binary 文件。要怎么做呢?
如果按照上面 gen_kernel.dart
的方式,平台代码也会被打包进 .dill
文件里,使得最简单的 Hello World 都需要若干 MB 的体积。这里更「极致」的方式是借助 Flutter。在启动 Flutter Run 命令时,它会先编译出 .dill
文件,获得全部静态资源,然后才去执行 Xcode 的 iOS 应用构建。这里 Flutter Tool 会启动它所接入的 CFE 编译服务,相应的编译产物会放在系统的临时目录里,其路径会以进程间通信的消息形式传递,可以在 flutter run -v
的日志中搜索 .dill
找到。
所以,整个 iOS 上独立接入 Dart VM 的试验性流程,大致包括这么几个步骤:
- 建立一个新的 Flutter 空项目。
- 把 Flutter 项目的入口改为空的 Dart 版 Hello World,截取 Flutter Tool 的编译目录,取出其
app.dill
文件。 - 将 Flutter Engine 编译产物中的
vm_platform_strong.dill
文件取出来。 - 用 OC 的
pathForResource
方法,将上面这两个.dill
格式文件打开为char *
形式的 buffer,将它们输入前面集成 Dart VM 的 C++ Demo。 - 执行同样的 Embedding Example C++ 入口函数。
然后,第一次尝试果然顺利失败了。当时从 flutter run
命令中提取中的 .dill
文件,无法在 iOS 模拟器上运行。
略过这里曲折的心路历程,最后的排查结果出乎意料地简单:搭建 Flutter 环境的时候,Flutter Engine 和 Flutter Tool 两个仓库必须使用完全一致的 revision,否则无法互相兼容。如果你自行编译使用 Flutter Engine,那么机器上会有两个可以运行的 Dart 版本,一个在 Flutter Engine 的 host 编译产物里,另一个由 Flutter Tool 开箱自带。我们只需分别用 dart --version
验证它们的版本是否一致即可。
解决版本问题后,即可顺利执行 Hello World 对应的 .dill
文件了。最后嵌入成功的效果很简单,如图所示:
和 QuickJS 那种引擎本体完全不带 IO 能力的形式不同,一旦成功集成了 Dart VM,异步 IO 等能力就都能正常使用了。因此只要走通这一步,这个示例至少已经具备了一定的实用性。但由于本文只是可行性试验,尚且无法提供现成「开箱即用」的示例工程源码。建议大家如果有兴趣尝试,以 Embedding Examples 的上游 patch 作为起点会比较容易。
总结
由于文档欠缺与 Dart 的小众性,这次实验走了不少弯路,也算体验了一把谷歌是怎么做「开源寡头」的:东西本身的料确实非常足,但与自身产品的集成深度实在很高。如果谷歌能以更接近社区模式的方式来运作 Dart,目前使用 Dart 的典型案例或许不至于像今天这样,几乎仅限 Flutter 一家。
但我们都知道,正是 Ryan Dahl 当年把 V8 从 Chromium 中剥离出来的尝试,才带来了今天的 Node.js。那么如果像这样把 Dart VM 从 Flutter 中剥离出来,是否能为社区带来新的可能性呢?例如对于鸿蒙 LiteOS 一类的嵌入式系统,Dart VM 能否成为比嵌入式 JS 解释器更好的应用开发方案呢?当然,并不是所有创新都能成为下一个 Node.js,但这至少值得我们动手尝试。
在集成 Dart VM 方面,这篇文章只是开了个头。Dart 虚拟机的能力相当强大,还有增量编译、热重载、快照预热、AOT 编译、远程调试等能力有待继续发掘。如果你对此感兴趣,欢迎关注哦。