6

把玩飞镖:自己动手嵌入 Dart VM

 3 years ago
source link: https://zhuanlan.zhihu.com/p/296388598
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

把玩飞镖:自己动手嵌入 Dart VM

雪碧 | github.com/doodlewind

飞镖(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 编译、远程调试等能力有待继续发掘。如果你对此感兴趣,欢迎关注哦。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK