发布于 2024 年 6 月 5 日,妙多技术

Motiff 妙多:高性能的背后

陶文
陶文
研发工程师
梁友
梁友
研发工程师
宇辰
宇辰
研发负责人

做一个 UI 编辑器能有多难?

在两年前我们打算开始做一个 AI 时代的编辑器时,并没有认为这是件非常困难的事情。毕竟现在已经有这么多开源方案了,我们认为只需要在这些开源方案的基础上加入一些 AI 功能就能做成。但随着项目开发的逐渐深入,我们发现编辑器对于性能的要求使得大部分习以为常的功能变得极为困难,比如:

  • 批量移动图层
  • 打开文档
  • 切换页面

也许你会觉得这些场景平平无奇,我们一开始也是这么认为的……

不就是画几个矩形么?能有多难?

Motiff 有很多方便设计师的功能,比如自动布局、组件实例。这些功能导致 Motiff 中很多数据之间存在着关联性。例如:

  • 一个图层的大小发生了变化,那么它的父容器也需要重新进行布局;
  • 一个矢量图层变化之后,受它影响的布尔图层就必须重新绘制;
  • 一个组件发生调整,也有可能影响到几百上千个它派生出的实例。比如调整图层引起父容器重布局,而父容器是组件又要去调整派生出来的实例。

在 2021 年底刚刚开始做 Motiff 时,我们并没有意识到即将面对的在数据计算方面的难度。

项目开始没几天,复用 Skia 的 C++ 代码,我们的前端工程师用常规的 React+Redux 技术栈很快就搭建出来一个能够画几个矩形的 Demo 了。在前期胜利的鼓舞下,团队又用几个月的时间就把常规的编辑器能力实现得七七八八。似乎产品很快就能发布了,除了两朵乌云,一朵是组件实例还没有实现,另一朵是性能压测还没有做。当我们开始着手去思考怎么搞组件实例,并开始压测的时候,才突然认识到,事情远没有想象中那么简单。无论是难以编写的逻辑代码,还是个位数的帧率都告诉我们,路从一开始就走错了。我们重新设计了系统的计算架构,花费了很大精力把相关逻辑从 React+Redux 里剥离出来,迁移到 C++ 写的新架构里。

回过头来看是 Motiff 的功能特性导致了其独特的实现:

  • 相比传统的 GUI:不仅仅是组件实例等功能会引起大量的计算,不少设计师习惯了把大量的图层堆到一个无限画布上,并通过缩放画布进行快速跳转以及框选批量移动。相比之下,传统的 GUI 场景,计算和渲染都不会有如此大批量更新的需求。或者说有大量更新的情况下,用户能容忍卡顿。无论是 React+Redux 还是 Vue 其计算衍生属性的模型都是以在一个大 GUI 上更新一小块内容来设计的。作为传统 GUI 的底层库的 Skia ,在批量绘制大量变更时也不够优化。
  • 相比游戏:游戏对渲染的精度要求不高,对于不在视窗内的物体可以不计算不渲染。同时对于远景的物体可以进行合并简化,甚至是完全忽略掉。很多 3A 大作移植到 switch 掌机的时候,就是通过这样的一些技巧来减少计算量的。但是 Motiff 的用户是不能接受“差不多的渲染”结果的,这些用户对于无限画布快速缩放也有很高的预期。更糟糕的是 Motiff 还跑在 WebAssembly 的内存受限场景下,不是所有的游戏批量计算的技巧都可以被照搬过来的。

这些不同使得我们可以借鉴 GUI 或者游戏架构中的优秀部分,但是对于自己功能特性带来的独特挑战,必须从第一性原理出发,自主思考。

下面就从三个具体业务场景,分享我们所做的针对性优化。

精准化更新 - 动作越小,速度越快

在 Motiff 中,设计稿被视为一个键值(K-V)数据库,其中每个设计元素(如矩形、圆形、文本等)都由一个唯一的标识符(NodeId)和一个属性键(PropKey)组成的键(Key)来标识。每个键对应一个值(Value),这个值可以是数字、字符串、颜色代码、位置坐标等,具体取决于属性的类型。

例如,如果我们在 Motiff 中绘制了一个矩形,那么文档中的数据可以表示为:

KeyValue
(0:1, type)Rectangle
(0:1, width)100
(0:1, height)100
(0:1, position)(0, 0)
(0:1, stoke)(1px, solid, black)

也可以把它看作是一个 Map 来帮助我们理解这篇文章的内容。对于这样的一个 Map,我们有三种基本操作:

  • Create: 创建一个 K-V Pair
  • Update: 更新一个 K-V Pair
  • Remove: 删除一个 K-V Pair

任何的编辑动作,都是这个键值(K-V)数据库中这样一批操作的组合。比如,在设计师通过 Option 复制来开始一个新的界面设计时,当鼠标拖拽的一瞬间,Motiff 就需要在文档中通过 Create 操作创建出成千上万个新的 K-V Pair。

一个图层的大小发生了变化,那么它的父容器也需要重新进行布局。在最初的 Demo 版本中,Motiff 是通过在属性的更新函数中添加额外的逻辑来实现的,如下所示:

setWidth(width: number) {
    this.width = width
    this.getAncestors().forEach(node => node.reLayout())
}

然而,这种即时更新的方法虽然能够保证数据的一致性,但也可能导致性能问题,尤其是在进行批量编辑操作时。比如批量编辑图层:

这个场景下的代码意味着编辑器需要对选中的每一个图层位置进行调整。但调整过程中经常出现卡顿。

在 Motiff 中,这种关联的计算规模要比直接更新的数据量大很多。所以很容易想到,合理的计算方式是在数据更新之后,仅针对更新了的数据触发“相关”的重新计算。

为了提高性能,Motiff 采用了一种批量更新的策略,通过将多个零散细碎的更新操作合并为一个批量操作。合并之后的算法方便了程序员去做性能优化,可以减少不必要的重复计算。理论上,我们可以先批量更新完直接选中的一批 Node。

这样,在整个批量编辑过程中,每个节点的每个属性就只会被计算一次了。这是 Motiff 数据计算的基础算法。 Motiff 将这些更新文档一致性的算法封装成一个个独立的 System。当文档被用户的直接操作修改后,与整个修改相关的 System 会被触发执行,它们按照一定的顺序执行,逐步更新文档的关联属性,确保文档最终是一致的。

下面是两组数据,移动布尔容器内部 1024 个矩形,一个个更新对比批量更新:

  • 一个个更新: 20647 ms
  • 批量更新: 54 ms

打开超大文档 - 和编辑过程截然不同的考验

精准化更新是基于变更事件的。但是文档从压缩档案恢复出来没有必要先转换成变更事件的中间型态。同时基于变更事件的处理算法需要同时兼顾已有的文档数据和新增的改动,其算法复杂度会更高。

为了优化打开文档的速度,每个 System 专门为批量创建场景实现了快速的批量树遍历算法。压缩档案先解压缩反序列化成一棵初始的节点树,然后每个 System 遍历这棵树去填充需要计算出来的属性。

下面是两组数据,打开 4000 个矩形的文档,优化算法对比非优化算法:

  • 非优化算法:758 ms
  • 优化算法:341 ms

除了打开速度,另外一个挑战是能否有足够的内存打开超大的文档。Node 的属性有几百个,但往往只会使用其中的几十个。如果以最直观的方式来定义,每个 Node 是一个几百个属性的 Struct 结构体,会消耗大量的内存来标识这个 Node 没有这个属性。但是如果不用 Struct 结构体,而是像动态类型语言那样用 Map 来存储对象的话,则会造成大量的内存碎片以及降低属性读写的速度。这导致我们的内存管理策略不能选择单一方案,而需要针对不同的属性做不同的内存布局的选择。

下面是两组数据,持有一万个矩形,优化的内存布局对比用 Map 存:

  • 全用 Map 存:183 mb
  • 优化的内存布局:4.3 mb

除了更精细地使用内存之外,我们还支持了最新版本浏览器的新特性,把内存上限从 2G 提升到了 4G。这使得我们可以在一个页面内放入更多的图层。

目前,Motiff 已经能保证用户单个页面100万图层文件的打开。

高性能渲染过程 - 流畅性的最后一轮计算

在开发初期,我们选择 Skia 作为渲染引擎。Skia 是一个开源的图形库,也是 Chrome、Android、Flutter 的渲染引擎。通过 Skia,我们可以很容易地绘制出各种图层:

void draw(SkCanvas* canvas) {
    SkPaint p;
    p.setColor(SK_ColorRED);
    p.setAntiAlias(true);
    p.setStyle(SkPaint::kStroke_Style);
    p.setStrokeWidth(10);
    canvas->drawLine(20, 20, 100, 100, p);
}

一开始,我们乐观地认为,Skia 既然能作为这么多主流 GUI 库的渲染引擎,那么也应该能高效地实现 Motiff 的渲染。我们只需要在图层数据变更结束后,对有变化的图层调用 Skia 提供的绘制 API,就能在浏览器中绘制出设计稿。但在我们通过 Skia 实现了这一切之后,却发现性能并不理想,原因也不简单:

  1. 1.Skia 会对渲染指令强制重排,但并没有我们在理解业务场景下的手工编排性能更好,而且重排本身性能开销也很大
  2. 2.因为需要重排,Skia 会将渲染指令积累起来,再重排,一次性执行所有渲染指令,导致难以将渲染时间限制在指定时间范围内
  3. 3.同一个路径,有不同的渲染算法,Skia 是根据固定的规则选择算法,而我们可以根据业务场景,选择更优的算法

最终我们直接使用 WebGL 实现 Motiff 自己的渲染引擎,完全节省了指令重排时间,同时优化路径和效果算法,整体性能为使用 Skia 实现的初始版本的 2.27 倍。

实现高性能渲染的复杂度不止于此。任何渲染的卡顿极易被用户察觉到,尤其是在进行画布区域的缩放时,从而影响使用体验。但一味减少每帧用于渲染的时间,会导致整体渲染时间被大大拉长,用户将长时间看到文档内容的空缺。渲染中最大的挑战是如何在一次显示器刷新周期内,渲染出更多内容,同时将超时概率降到最低。

文档的规模是无限的,而设备的性能是有限的。显然谁都无法保证在任何文档规模下,都能在一个显示器刷新周期内渲染出所有用户可见的文档内容。我们使用分块的技术,将屏幕划分为多个固定大小的区域。渲染的目标不再是整个屏幕而是一个个分块。

分块的好处有很多,既能分割任务,又是确立了缓存单位。比如在用户的编辑过程中,只有编辑区域内的分块才需要更新,其他分块保持原样。

在用户画布区域的缩放过程中,新的分块也可用之前不同缩放比例的分块替代,因为渲染内容的差异仅是缩放比例不同。

如果一个分块包含了非常多的内容,可能在一个显示器刷新周期内都无法被完全渲染出来。在我们初版渲染引擎中,要么是强制渲染出这个分块,导致超时;要么是将这个分块再次分块,拉长整体渲染时间。

在目前 Motiff 的渲染引擎中,我们有能力中断当前的渲染任务,在下一个显示器刷新周期内继续这个渲染任务,从而整体渲染时间最小。

此外,我们构造了专门用于渲染的特殊数据结构 RenderTree 来提升访问效率。它仅包含渲染所需数据,保证数据在内存中的连续性,利用 CPU 对内存访问的空间局部性原理,提升访问效率,同时缓存渲染相关的计算结果,在文档未被编辑的渲染过程中减少计算量。

正如上文所述,Motiff 目前可以保证用户丝滑打开单个页面百万图层文件的同时,还能拥有流畅的编辑体验。这可能是全球首个达到该水平的高性能编辑器。

小结

性能对于 Motiff 来说始终是最重要的事情。这篇文章只列出了 Motiff 在做的一部分性能优化。

通过基础的数据存储、内存分配、数据更新机制与渲染策略,Motiff 使得设计师流畅编辑百万图层的设计稿成为可能。工具支持的设计稿规模越大,对设计师的创意约束就越小。

随着设计实践的不断发展,Motiff 将继续进化数据计算框架,以满足设计师们对于性能、稳定性越来越高的需求。