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

我心飞翔 分类:javascript

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

image.jpg

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

  • QuickJS 方案演化历程
  • 从 QuickJS 到 Dart VM 的探索
  • Dart VM 迁移实践经验
  • 复盘总结

QuickJS 方案演化历程

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

skia-quickjs-poc.jpg

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

js-canvas-arch.png

上图显示了在将 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。每种 Layer 都具备自己的 paint 方法,每帧更新时,只需递归遍历 Layer 执行其 paint 方法即可:

layer-tree.png

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

image.png

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

  • 基础的画布绘制能力依赖 Skia。我们参考了 Flutter Engine 源码中的 Layer 结构,封装出可树形嵌套的 Layer 类。由于 Flutter 的文字排版实现不符合我们的需求(如缺少竖排,具体可参见 My first disappointment with Flutter 这篇文章),我们还单独维护了基于 Harfbuzz 和 ICU 的 C++ 文字排版库。
  • Layer 化后的绘制能力,绑定到了 QuickJS 引擎上。在此基础上,我们用 TypeScript 实现了处理编辑器画布内交互的框架,其中包含点击检测、手势等能力,基于它承载更上层的业务逻辑。
  • 画布外的常规 UI 控件使用平台原生,如各种滑杆、按钮、面板等。

从 QuickJS 到 Dart VM 的探索

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

  • 画布和平台 UI 面板的业务逻辑分属两套 View 环境,二者需通过较低效的 RPC 式 Bridge 通信,它们在两套环境的 UI 同时更新(如面板展开收起)时容易出现动画状态不同步,其联调较为不便。
  • QuickJS 引擎周边配套不完善,缺少调试器和 Hot Reload。前者属于引擎暂缺的能力,后者虽理论上可基于网络协议自行实现,但也需要较多基础性工作。另外 QuickJS 引擎性能虽然在无 JIT 的 JS 引擎中属于前列,但相对于支持 AOT 的静态语言 VM 仍然较为平庸。
  • 外围面板等控件 UI 无法跨平台,业务层的开发技术栈仍然是分歧的。

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

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

然而,如果单纯将 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 性质属于 RPC 异步通信,其延迟完全无法达到实时逐帧渲染的需求。但基于 Dart 的 FFI 能力,这一路线最终被证明是可行的,也是我们现在使用的方案。

Dart VM 迁移实践经验

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

  • 画布中的内容用 Skia 自行渲染,并包装成 Dart 中的 Layer 类来使用。
  • 面板、按钮等 UI 控件,直接用标准的 Flutter Widget 渲染。

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

  • 将 Skia 改为离屏绘制,渲染到 TextureWidget 而非直接上屏。
  • 将 C++ Layer 的绑定从 QuickJS 切换到 Dart VM。

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

  • Skia 支持 CPU 和 GPU 两种渲染后端。在使用 CPU 渲染后端(Raster Backend)时,可以直接建立 SkSurface 对象使用。而其 GPU 后端涉及子系统 Garnesh,它抹平了不同 GPU 后端的 API 差异。这时需要先建立 Garnesh 实例,再用其建立 SkSurface。具体可参见 SkCanvas Creation 文档。
  • 建立带 GPU 加速的 SkSurface 时,既需要 Garnesh 的 GrContext 实例,也需要 GrBackendRenderTarget 作为绘制的输出目标。这个目标在 OpenGL 体系中,可以用 FBO 的 ID 来指定。iOS 上这个 ID 值可以手动创建,安卓上如果使用 GLSurfaceView,那么使用 0 作为 ID 即可。
  • 需要在对 GL 上下文 makeCurrent 之后,才能开始 Skia 的 GPU 渲染端初始化。

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

在具备支持离屏绘制的 Skia 实例后,就可以用 C++ 的 Layer 来绘制它,进而为 Layer 绑定 Dart 对象了。这里实现 Dart 绑定的核心能力,是 Dart FFI 中的 GC Finalizer。它允许为 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>。这个类型对应于 void*,就像 Dart 中的 Pointer<Int> 对应于 int* 一样。它在 Dart 中不能做任何修改,只能用 C++ 创建并返回。因此我们在实际业务中的方案是这样的:

  • 在 Dart 的 BaseObject 上,添加一个名为 ptrPointer<Void> 类型属性。
  • BaseObject 的构造器中,先通过 FFI 调用一个返回 Pointer<Void> 类型指针的 C++ 函数,赋值给 ptr 属性。
  • 获得 ptr 属性后,将这个 ptrthis(handle 类型)一起传入上面的 _passObjectToC,并让其中建立的 C++ 对象持有该 handle。
  • 后续需要访问 Dart 对象上绑定的 C++ 对象时,从 Dart 侧传入该 ptr 并强转类型即可。

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

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

在完成 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 讨论。

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

  • 如果想在 C++ 侧同步调用 Dart 函数,我们的方式是先建立一个用于「接收 Dart 回调函数」的 C++ 函数,然后在 Dart 侧将回调传入。这样需要写出的 Dart FFI 类型会很复杂,可以用 typedef 缓解。
  • 对于一组各不相同的 Dart 对象,其对应的 Dart_Handle 可能在连续传递给 C++ 接收时存在重复,需要将它们转为 Dart_WeakPersistentHandle
  • 异步情况下,哪怕能够在 C++ 侧拿到 Dart 函数对应的函数指针,也不能直接调用(像 QuickJS 那样执行 JS_Call),否则应用会立刻崩溃。这里必须使用 Port。
  • 如果在 Flutter 中进行多次路由跳转,可能会使单个 Dart Isolate 中共存多个不同页面中的 TextureWidget 实例。这时需要为 Dart 中的 Layer 对象关联到不同的 textureId,使其能各自渲染到正确的 Skia 实例中。

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

本文不限制转载,欢迎交流探讨。

回复

我来回复
  • 暂无回复内容