?如果你喜欢我写的文章,可以把我的公众号设为星标 ?,这样每次有更新就可以及时推送给你啦
原文链接: https://zhuanlan.zhihu.com/p/357382042,转载已获作者 doodlewind 授权
在稿定科技,我们使用 QuickJS 与 Skia 搭建并落地了自研的 App 端编辑器渲染能力。去年北京的 QCon+ 上,笔者为此做了「基于 QuickJS + Skia 的 GUI 框架[1]」分享。下面是一些基于该能力渲染的实际应用截图:
但在短短几个月后,我们就再次升级了这项 QuickJS + Skia 的工程设计,将 Skia 的渲染能力切换到与 Flutter 中的 Dart VM 相集成。本文会介绍这背后的技术演进,共有这么几个部分:
稿定的跨端工程最早始于笔者一项出于业余兴趣的个人实验,即尝试用 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 版本架构可以分三层概括如下:
虽然上述架构成功支持了业务的初期落地,但它在此过程中也暴露出了一些问题,主要有这么几点:
为此我们需要继续探索解决方案,比如换 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 能力,这一路线最终被证明是可行的,也是我们现在使用的方案。
FFI(Foreign Function Interface[6])意为外部函数接口,它允许我们在一门语言中调用另一门语言中的函数。Dart FFI[7] 为我们提供了直通原生动态库函数符号的能力,可以极大优化调用原生 API 时的性能。它此前长期处于 beta 状态,并在前不久正式随 Flutter 2.0 进入稳定。如果基于该能力来复用 Flutter 中的 Dart VM,那么就可以获得相当简单而统一的应用层技术栈:
上述两者都可以在同一个 Dart Isolate 中完成,从而也省下了 Bridge 通信的开销。为此有这么两项主要的工作需要完成:
首先对于 Skia 离屏上下文的建立过程,其重点可概述如下:
0
作为 ID 即可。makeCurrent
之后,才能开始 Skia 的 GPU 渲染端初始化。总之,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>
。这个类型对应于 void*
,就像 Dart 中的 Pointer<Int>
对应于 int*
一样。它在 Dart 中不能做任何修改,只能用 C++ 创建并返回。因此我们在实际业务中的方案是这样的:
BaseObject
上,添加一个名为 ptr
的 Pointer<Void>
类型属性。BaseObject
的构造器中,先通过 FFI 调用一个返回 Pointer<Void>
类型指针的 C++ 函数,赋值给 ptr
属性。ptr
属性后,将这个 ptr
和 this
(handle 类型)一起传入上面的 _passObjectToC
,并让其中建立的 C++ 对象持有该 handle。ptr
并强转类型即可。?Dart FFI 中
Pointer<Void>
类型和 C++void*
类型的这种一对一映射关系,可以非常有效地帮助我们理解指针。在笔者「写给前端的手动内存管理基础入门(一)[11]」中,也重度应用了这种从类型出发的视角,来帮助前端同学理解原生语言。如果你对 C 系语言还不熟悉,这里推荐一读。
以上代码示例中还有一个值得注意的地方,那就是名为 Dart_NewWeakPersistentHandle_DL
的函数。这是 Dart VM 特别开放的 DL(动态链接)API,只需引入头文件即可使用,无需显式依赖 Dart VM。这类 API 具有 _DL
后缀,可以用来在 C++ 中将普通的 Dart_Handle
转换为具备长生命周期的 Dart_PersistentHandle
、Dart_WeakPersistentHandle
和 Dart_FinalizableHandle
。具体可参见 dart_api_dl.h[12]。
在完成 Dart 对象与 C++ 对象的互通后,还需要实现一些常见的平台 API。这部分内容和 QuickJS 等其他引擎很接近,其实也没有什么别的,大概三件事:
为什么没有 Dart 到 C++ 的异步调用呢?因为这可以通过 1 和 3 的组合来解决,亦即先进行一次 Dart 到 C++ 的同步调用,然后 C++ 异步调用回 Dart。对于 3 的异步调用,需要使用 Port 机制进行异步通信。通过建立 Dart_CObject
的方式,可以从任意线程向 Dart Isolate 发送消息。其具体示例可参见 GitHub Issue[13] 讨论。
对于 Dart FFI 的接入应用,这里列出一些令人印象较为深刻的注意事项:
typedef
缓解。Dart_Handle
可能在连续传递给 C++ 接收时存在重复,需要将它们转为 Dart_WeakPersistentHandle
。JS_Call
),否则应用会立刻崩溃。这里必须使用 Port。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 代码库继续在服务端发挥价值。为此赋能的重点之一是笔者正在与 @太狼[14] 合作开发的 @napi-rs/canvas[15] 库。这是一个用 Rust 将 Skia 实现为 Node 扩展的服务端 Canvas 实现,大家不妨期待其后续的进展与分享。至于本文所介绍的框架本身则尚处于内部演化中,暂时尚不开源。另外特别感谢同为国人研发的 Dart Native[16] 项目,它在我们遇到 FFI 问题时提供了重要的帮助。
?如果你喜欢我的文章,希望点赞? 收藏 ? 在看 ? 三连支持一下,谢谢你,这对我真的很重要!
欢迎大家关注我的微信公众号:卤蛋实验室,目前专注前端技术,对图形学也有一些微小研究。
也可以加我的微信 egg_labs,欢迎大家来撩。
[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