从 QuickJS 到 Dart VM:稿定跨端渲染工程的运行时演化

309次阅读  |  发布于3年以前

在稿定科技,我们使用 QuickJS 与 Skia 搭建并落地了自研的 App 端编辑器渲染能力。去年北京的 QCon+ 上,笔者为此做了「基于 QuickJS + Skia 的 GUI 框架[1]」分享。下面是一些基于该能力渲染的实际应用截图:

但在短短几个月后,我们就再次升级了这项 QuickJS + Skia 的工程设计,将 Skia 的渲染能力切换到与 Flutter 中的 Dart VM 相集成。本文会介绍这背后的技术演进,共有这么几个部分:

QuickJS 方案演化历程

稿定的跨端工程最早始于笔者一项出于业余兴趣的个人实验,即尝试用 QuickJS 结合 libuv 来接入平台 IO 能力,并在此基础上绑定 Skia 来实现 Canvas 渲染。这相当于实现了一套 HTML5 Canvas 标准的子集,效果如下:

skia-quickjs-poc

我们在这一设计的基础上搭建了编辑器的原型,但并未最终落地。其问题主要在于性能,具体可参见这张图:

js-canvas-arch

上图显示了在将 JS 引擎嵌入原生环境后,从点击事件到执行 UI 更新之间的主要环节。其中,JS 的 Canvas 绘制会直接操作 Skia 的 SkBitmap。这一操作虽然已没有线程通信开销,但一旦每帧进行数百次绘制 API 调用(这对命令式的 Canvas 绘制而言很常见),仍然很容易超出 16ms 的限制。这种高频操作时的性能问题,应当也是 React Native 始终不考虑 Canvas 支持的主要原因之一,在其换用无 JIT 的 Hermes 引擎后更是如此。

但是,解释器的性能是足够支撑 DOM 式的 API 的。为此我们直接借用了 Flutter Engine 中的部分源码,不再将 drawImage 这种绘制 API 开放到 JS 层,改为用 C++ Layer 来建模编辑器中的各类元素对象。也可以认为,这是将命令模式 GUI 封装为了保留模式 GUI[2]。每种 Layer 都具备自己的 paint 方法,每帧更新时,只需递归遍历 Layer 执行其 paint 方法即可:

layer-tree

这种 API 设计,使我们较为容易地实现了渲染线程拆分改造。执行交互逻辑的 QuickJS 线程和执行渲染的 Skia 线程独立运作,QuickJS 每次事件回调中提交的更新不再需要被全部绘制,而是只在渲染线程空闲时绘制最新的任务,同时清空任务队列,从而实现避免卡顿的跳帧能力。可以认为这属于经典生产者 - 消费者模式的变体,如下所示:

最终的 JS 版本架构可以分三层概括如下:

从 QuickJS 到 Dart VM 的探索

虽然上述架构成功支持了业务的初期落地,但它在此过程中也暴露出了一些问题,主要有这么几点:

为此我们需要继续探索解决方案,比如换 Flutter 重写(不是)。

我们首先想到的一条折中路线,是单独抽离 Dart VM,在现有代码库中替代 QuickJS,属于对 VM 的嵌入式集成(embedding)。基于一些工程实验,我们确实搭建出了这一方案的 MVP 原型,具体可参见笔者「自己动手嵌入 Dart VM[4]」这篇专栏。

然而,如果单纯将 QuickJS 换成 Dart VM,并不能解决业务层开发技术栈分歧的问题。而如果引入 Flutter 的 Widget 体系来实现跨平台 UI,这时由于 Flutter 中的 Dart VM 没有对外开放(符号被隐藏),又会存在两份 Dart VM,影响性能和体积。并且,Dart 和 Flutter Engine 存在相当深度的绑定,这种绑定甚至已经深到了「不依赖 Flutter Engine 就无法编译出 Dart VM 的 iOS 和安卓版」的程度。因此抽离 VM 单独使用的工程量相当大,得不偿失。

但还有另一条更彻底的路线,那就是直接在标准 Flutter 环境中接入现有的 C++ 渲染体系,并用同一个 Dart VM 环境控制它。如果基于表层的 Flutter API,这条路线是不可行的。因为 Flutter 默认的 MethodChannel[5] 性质属于 RPC 异步通信,其延迟完全无法达到实时逐帧渲染的需求。但基于 Dart 的 FFI 能力,这一路线最终被证明是可行的,也是我们现在使用的方案。

Dart VM 迁移实践经验

FFI(Foreign Function Interface[6])意为外部函数接口,它允许我们在一门语言中调用另一门语言中的函数。Dart FFI[7] 为我们提供了直通原生动态库函数符号的能力,可以极大优化调用原生 API 时的性能。它此前长期处于 beta 状态,并在前不久正式随 Flutter 2.0 进入稳定。如果基于该能力来复用 Flutter 中的 Dart VM,那么就可以获得相当简单而统一的应用层技术栈:

上述两者都可以在同一个 Dart Isolate 中完成,从而也省下了 Bridge 通信的开销。为此有这么两项主要的工作需要完成:

首先对于 Skia 离屏上下文的建立过程,其重点可概述如下:

总之,Skia 的离屏渲染虽然有跨平台一致的使用层 API,但其上下文创建过程是平台独立的。这具体还可参考 Flutter Engine 中的源码,在此不再赘述。

在具备支持离屏绘制的 Skia 实例后,就可以用 C++ 的 Layer 来绘制它,进而为 Layer 绑定 Dart 对象了。这里实现 Dart 绑定的核心能力,是 Dart FFI 中的 GC Finalizer[10]。它允许为 Dart 对象外挂一个由 void* 指针指向的任意 C++ 对象,并在 Dart 对象被 GC 时,执行用于销毁(析构)该 C++ 对象的回调函数(Finalizer)。其简单示例如下所示:

// 在 Dart 对象被 GC 时执行的回调,可在此销毁附带的 C++ 对象
static void RunFinalizer(void* isolate_callback_data,
                         Dart_WeakPersistentHandle handle,
                         void* peer) {
    // 将 void* 指针强转为我们需要的类型,然后释放它
    auto foo = reinterpret_cast<Foo*>(peer);
    delete foo;
}

// 每个 Dart 对象会被表示为一个 handle,在此为其绑定 C++ 对象
DART_EXPORT void PassObjectToCUseDynamicLinking(Dart_Handle h) {
  // 在堆上 new 出 C++ 对象
  auto foo = new Foo();
  // 指定其体积以便垃圾回收器参考,可后续更新该体积
  intptr_t size = 2 * 1024 * 1024;
  // 用原始 handle 建立可持久存在的 weak persistent handle
  // 并关联上析构回调
  Dart_NewWeakPersistentHandle_DL(h, foo, size, RunFinalizer);
}

上面的 C++ 可以按这种方式在 Dart 中使用:

// 根据平台加载动态库
final DynamicLibrary nativeLib = Platform.isAndroid
    ? DynamicLibrary.open('libdemo.so')
    : DynamicLibrary.process();

// 在动态库中查找原始函数符号
// 这里的 void Function(Object) 是该函数从 Dart 侧所见的类型
// Void Function(Handle, Pointer<Void>) 是为 FFI 库声明的类型
// FFI 侧的 Handle 类型对应 Dart 侧的 Object 类型
final void Function(Object) _passObjectToC = nativeLib
    ?.lookup<NativeFunction<Void Function(Handle, Pointer<Void>)>>(
        'PassObjectToCUseDynamicLinking')
    ?.asFunction();

// 对所有需绑定 C++ 对象的 Dart 对象,该基类可供其继承
class BaseObject {
  BaseObject() {
    // 将 C++ 对象隐式绑定到 Dart 对象实例上
    // 从而该 Dart 对象销毁时,也会销毁 C++ 对象
    _passObjectToC(this);
  }
}

通过这种形式,就可以形成 Dart 对象到 C++ 对象的一对一绑定了。但是,业务中还有可能需要动态获取到这个 C++ 对象。比如在 C++ 中,经常需要将绑定在 Dart Layer 对象上的 C++ 对象拿来 walk 遍历绘制。这时候 void* 指针并不能直接可见,需要在 Dart 对象上显式添加一个指向 C++ 对象的属性,其用 Dart FFI 定义出的类型为 Pointer<Void&gt;。这个类型对应于 void*,就像 Dart 中的 Pointer<Int&gt; 对应于 int* 一样。它在 Dart 中不能做任何修改,只能用 C++ 创建并返回。因此我们在实际业务中的方案是这样的:

Dart FFI 中 Pointer<Void> 类型和 C++ void* 类型的这种一对一映射关系,可以非常有效地帮助我们理解指针。在笔者「写给前端的手动内存管理基础入门(一)[11]」中,也重度应用了这种从类型出发的视角,来帮助前端同学理解原生语言。如果你对 C 系语言还不熟悉,这里推荐一读。

以上代码示例中还有一个值得注意的地方,那就是名为 Dart_NewWeakPersistentHandle_DL 的函数。这是 Dart VM 特别开放的 DL(动态链接)API,只需引入头文件即可使用,无需显式依赖 Dart VM。这类 API 具有 _DL 后缀,可以用来在 C++ 中将普通的 Dart_Handle 转换为具备长生命周期的 Dart_PersistentHandleDart_WeakPersistentHandleDart_FinalizableHandle。具体可参见 dart_api_dl.h[12]。

在完成 Dart 对象与 C++ 对象的互通后,还需要实现一些常见的平台 API。这部分内容和 QuickJS 等其他引擎很接近,其实也没有什么别的,大概三件事:

  1. 在 Dart 侧同步调用 C++ 函数
  2. 在 C++ 侧同步调用 Dart 函数
  3. 在 C++ 侧异步调用 Dart 函数

为什么没有 Dart 到 C++ 的异步调用呢?因为这可以通过 1 和 3 的组合来解决,亦即先进行一次 Dart 到 C++ 的同步调用,然后 C++ 异步调用回 Dart。对于 3 的异步调用,需要使用 Port 机制进行异步通信。通过建立 Dart_CObject 的方式,可以从任意线程向 Dart Isolate 发送消息。其具体示例可参见 GitHub Issue[13] 讨论。

对于 Dart FFI 的接入应用,这里列出一些令人印象较为深刻的注意事项:

在完成 Dart FFI 的改造后,还有一项工作是重写已有的 TS 框架到 Dart。这主要是件体力活,只需按照原有代码的字面意义,将 TS 中的逻辑搬运到 Dart 中即可。由于 Dart 不支持 JSON 式的对象字面量语法,因此对于一些形如 {a:{b:{c:1}}} 这样存在嵌套的状态结构,需要将它们逐层拆分为 class,这一点较为繁琐。另外 Dart 的 intdouble 区分较严格,JSON 转换时应注意相应的类型。除此之外,这部分改造并没有遇到太多值得一提的麻烦。

复盘总结

完成这项迁移后,最后还有一条灵魂的拷问,那就是这样开发技术栈的搭建和切换,是否有「劳民伤财」的折腾之嫌呢?

首先需要明确的是,我们确实需要自己控制 Skia,因为 Flutter 默认缺乏竖排等一些必要的排版能力。如果没有对特殊渲染能力的需求,直接使用 Flutter 自带的 Widget 与 Canvas 是最方便的选择。但只要走通了 Dart FFI,不论是特殊的竖排文字还是更底层的 GL 操作,这些依赖 C++ 库的能力,原理上都已经可以无缝地接入 Dart 了。伴随着 Flutter 2.0 中 Dart FFI 的稳定,我们应当有望见到更多这类「深度嵌入」的混合渲染技术栈。

另外整套方案中,Dart VM 关键的 GC Finalizer 能力,在我们选择 QuickJS 的时间点还没有推出。并且 QuickJS 的 API 非常友好易懂,它的集成为我们培养了从 0 到 1 的入门经验,在项目早期发挥了很大作用。回头看来,这仍然是一条选择从头自研时的必经之路。如果把 Dart VM 比喻成我们吃饱的第四个包子,那么 QuickJS 就是前三个——没有办法只靠吃最后一个就吃饱。但一旦发现更优的路线,个人仍然认为应当(在有条件的前提下)做到尽早切换,避免因技术债而积重难返

最后在开发成本方面,从最早引入 QuickJS 到现在接入 Dart VM,从 C++ 渲染层到 TS 和 Dart 的编辑器框架,我们对整套基础设施的搭建实际上只有两个人全职投入,再加上一位帮助实现业务层需求的校招同学就足够了。这并不需要大型的 infra 团队,最后搭建出的方案也仍然处于对 Flutter 无侵入性的轻量级。对于有同类场景的中小团队,个人认为本文分享的这套实践应当是务实且具备参考价值的

在未来,我们希望使原有的 TS 代码库继续在服务端发挥价值。为此赋能的重点之一是笔者正在与 @太狼[14] 合作开发的 @napi-rs/canvas[15] 库。这是一个用 Rust 将 Skia 实现为 Node 扩展的服务端 Canvas 实现,大家不妨期待其后续的进展与分享。至于本文所介绍的框架本身则尚处于内部演化中,暂时尚不开源。另外特别感谢同为国人研发的 Dart Native[16] 项目,它在我们遇到 FFI 问题时提供了重要的帮助。

参考资料

[1]基于 QuickJS + Skia 的 GUI 框架: https://qconplus.infoq.cn/2020/beijing/presentation/2763

[2]这是将命令模式 GUI 封装为了保留模式 GUI: https://www.zhihu.com/question/39093254/answer/1351958747

[3]My first disappointment with Flutter: https://suragch.medium.com/my-first-disappointment-with-flutter-5f6967ba78bf

[4]自己动手嵌入 Dart VM: https://zhuanlan.zhihu.com/p/296388598

[5]MethodChannel: https://flutter.dev/docs/development/platform-integration/platform-channels

[6]Foreign Function Interface: https://en.wikipedia.org/wiki/Foreign_function_interface

[7]Dart FFI: https://dart.dev/guides/libraries/c-interop

[8]TextureWidget: https://api.flutter.dev/flutter/widgets/Texture-class.html

[9]SkCanvas Creation: https://skia.org/user/api/skcanvas_creation

[10]GC Finalizer: https://github.com/dart-lang/sdk/issues/35770

[11]写给前端的手动内存管理基础入门(一): https://zhuanlan.zhihu.com/p/356214452

[12]dart_api_dl.h: https://github.com/dart-lang/sdk/blob/master/runtime/include/dart_api_dl.h

[13]GitHub Issue: https://github.com/dart-lang/sdk/issues/37022

[14]@太狼: https://www.zhihu.com/people/Broooooklyn

[15]@napi-rs/canvas: https://github.com/Brooooooklyn/canvas

[16]Dart Native: https://github.com/dart-native/dart_native

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8