在稿定科技,我们使用 QuickJS 与 Skia 搭建并落地了自研的 App 端编辑器渲染能力。去年北京的 QCon+ 上,笔者为此做了「基于 QuickJS + Skia 的 GUI 框架」分享。下面是一些基于该能力渲染的实际应用截图:
但在短短几个月后,我们就再次升级了这项 QuickJS + Skia 的工程设计,将 Skia 的渲染能力切换到与 Flutter 中的 Dart VM 相集成。本文会介绍这背后的技术演进,共有这么几个部分:
- QuickJS 方案演化历程
- 从 QuickJS 到 Dart VM 的探索
- Dart VM 迁移实践经验
- 复盘总结
QuickJS 方案演化历程
稿定的跨端工程最早始于笔者一项出于业余兴趣的个人实验,即尝试用 QuickJS 结合 libuv 来接入平台 IO 能力,并在此基础上绑定 Skia 来实现 Canvas 渲染。这相当于实现了一套 HTML5 Canvas 标准的子集,效果如下:
我们在这一设计的基础上搭建了编辑器的原型,但并未最终落地。其问题主要在于性能,具体可参见这张图:
上图显示了在将 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
方法即可:
这种 API 设计,使我们较为容易地实现了渲染线程拆分改造。执行交互逻辑的 QuickJS 线程和执行渲染的 Skia 线程独立运作,QuickJS 每次事件回调中提交的更新不再需要被全部绘制,而是只在渲染线程空闲时绘制最新的任务,同时清空任务队列,从而实现避免卡顿的跳帧能力。可以认为这属于经典生产者 - 消费者模式的变体,如下所示:
最终的 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
上,添加一个名为ptr
的Pointer<Void>
类型属性。 - 在
BaseObject
的构造器中,先通过 FFI 调用一个返回Pointer<Void>
类型指针的 C++ 函数,赋值给ptr
属性。 - 获得
ptr
属性后,将这个ptr
和this
(handle 类型)一起传入上面的_passObjectToC
,并让其中建立的 C++ 对象持有该 handle。 - 后续需要访问 Dart 对象上绑定的 C++ 对象时,从 Dart 侧传入该
ptr
并强转类型即可。
以上代码示例中还有一个值得注意的地方,那就是名为 Dart_NewWeakPersistentHandle_DL
的函数。这是 Dart VM 特别开放的 DL(动态链接)API,只需引入头文件即可使用,无需显式依赖 Dart VM。这类 API 具有 _DL
后缀,可以用来在 C++ 中将普通的 Dart_Handle
转换为具备长生命周期的 Dart_PersistentHandle
、Dart_WeakPersistentHandle
和 Dart_FinalizableHandle
。具体可参见 dart_api_dl.h。
在完成 Dart 对象与 C++ 对象的互通后,还需要实现一些常见的平台 API。这部分内容和 QuickJS 等其他引擎很接近,其实也没有什么别的,大概三件事:
- 在 Dart 侧同步调用 C++ 函数
- 在 C++ 侧同步调用 Dart 函数
- 在 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 的 int
和 double
区分较严格,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 问题时提供了重要的帮助。
本文不限制转载,欢迎交流探讨。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!