把玩飞镖:Dart VM 集成入门

6,792

飞镖(Dart)既是一种轻巧的武器,也是一门编程语言的名称。这门语言的 Dart VM 虚拟机内置在 Flutter 框架中,在移动端开发中有广泛的应用。那么,我们能否脱离 Flutter,单独为原生项目(如游戏引擎或高性能图形应用)嵌入 Dart VM 呢?这就是本文关注的场景,亦即对虚拟机的嵌入式集成(embedding)。

目前,Dart 已经提供了类似 nodedart 运行时,供终端内直接使用。但这毕竟是个单体应用,不易集成在其他程序中。如何从这个单体应用或者 Flutter 之中,分离出 Dart VM 来单独使用呢?要解决这个问题,大致需要了解这三部分的内容:

  • Dart VM 的基础概念与工作方式。
  • 如何为 iOS 编译出可嵌入的 Dart VM 静态库。
  • 如何在 iOS 上嵌入 Dart VM,执行最简单的 Hello World。

下面本文将以 iOS 平台为例,演示如何脱离 Flutter,在自有的原生项目中编程式地使用 Dart VM 虚拟机。

Dart VM 的工作方式

Dart VM 执行代码的方式,和大家所熟悉的 V8 等脚本引擎是很不同的。其中最大的一点区别在于,Dart VM 并不支持直接解释执行字符串源码。如果想把它当作方便第一的脚本子系统,这无疑是有所不利的。但这点灵活性上的牺牲,换来了引擎在工程能力上很大的突破,主要包括下面这三种不同的工作模式:

  1. 基于二进制 AST(所谓 Kernel Binary)文件的 JIT 执行。
  2. 基于预热快照(所谓 AppJIT)的 JIT 执行。
  3. 基于 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 编译、远程调试等能力有待继续发掘。如果你对此感兴趣,欢迎关注哦。

参考资料