1
头图

导读:本文主要阐述了 Docs 在线表格为打造极致渲染性能所做的关键优化和过程思考,作为首个在在线电子表格领域自研基于WebGL渲染引擎的「吃螃蟹」者,整个过程面临诸多不确定性与挑战,Kola2d 的整体设计在此期间也经历了几轮推倒重来,最终的落地方案经过了多番探索与实践,希望给到对WebGL渲染引擎及高性能表格感兴趣的同学一些参考。

一、背景

Docs 电子表格是一款快手效率工程出品的自研协同办公产品,在快手内部被广泛使用:
图片
这类在线表格通常对性能有极高要求,需要面临以下挑战:

  • 多人实时协作:在线电子表格通常支持多用户同时编辑,这意味着系统必须能够快速处理多个用户的操作并实时更新界面,确保所有用户看到的数据是一致的。
  • 海量数据:团队有大量数据存储在在线表格上,在线表格需要能够高效地加载、渲染和操作这些数据。例如,进行复杂的计算、排序和筛选等操作时,性能要求就更高。
  • 复杂功能:现代在线表格通常集成了类 Excel 丰富的功能,如公式计算、图表生成、权限管理等。这些功能对系统性能提出了更高的要求,因为它们需要实时计算和渲染。

为提高 Docs 在线表格渲染性能在前期我们已经做过很多努力和尝试,其核心渲染引擎也由最初的基于 DOM 的渲染变更为了 Canvas 2D 渲染,这在一定程度上提高了 Docs 在线表格的渲染性能,但随着用户数据的增长和使用场景的扩充,我们依旧收到不少用户反馈 Docs 在线表格卡顿,特别是大表格、多人协同场景。

为彻底解决 Docs 在线表格的渲染卡顿问题,追求极致的渲染性能,使电子表格可以适配更多数字作业场景,我们对在线表格进行了深度改造,并自主研发了 WebGL 渲染引擎 Kola2d,最终使得 Docs 在线表格能以 50+FPS 的帧率渲染拥有百万单元格的超大表格。为打造下一代高性能电子表格配置了「核动力」。

当前,在线表格通常采用 DOM 或 Canvas2D 进行渲染,但 DOM 渲染已基本被淘汰。 Kola2d 渲染引擎创新性地引入了 WebGL,这在在线表格渲染领域尚属首创,并在此基础上设计了多种性能提升策略。本文将深入探讨 Docs 在线表格的内部,揭示 Kola2d 在提升渲染性能方面的关键策略和技术实现。

二、为什么是 WebGL

Canvas 2d 有自己的性能瓶颈,在大量级数据渲染上很显吃力。经过调研,我们将目光聚焦到另一种方案:WebGL。

WebGL 在渲染大量级数据的优势这主要体现在以下方面:

  • 硬件加速:WebGL 是基于 OpenGL ES 的,它可以直接利用 GPU 的强大计算能力进行渲染。因此,它能够处理更复杂的图形和更高效的图形渲染,而 Canvas 2D 则主要依赖于 CPU 渲染,性能相对较低。
  • 并行处理:WebGL 可以利用 GPU 的并行处理能力,能够同时处理大量的顶点和片元,这对于需要大量图形计算的应用(如游戏、数据可视化等)非常重要。Canvas 2D 则通常是顺序处理,效率较低。

在前期做也对 WebGL 渲染做了可行性测试,结果显示 WebGL 渲染性能显著强于 Canvas 2d:
截屏2024-10-18 16.13.32.png

通过分析市面上的 WebGL 框架优缺点,我们最终决定自研 WebGL 渲染引擎 Kola2d。
图片

三、Kola2d

Kola2d 职责范围

在设计 Kola2d 渲染器之前,我们对 Docs 在线表格的整体业务做了下梳理,将 Docs 在线表格分为了两层,这使得 Docs 在线表格有清晰的职责划分:

  • 业务层:业务层专注业务封装
  • 渲染层:渲染层专注渲染,渲染层即 Kola2d

业务层数据转换

业务层和渲染层相对隔离,业务层的数据需要做转换然后提交给 Kola2d。我们将业务层提交的数据起了一个名字:显示对象树。当然,业务层不仅仅显示对象的生产者,同时它需要有一个合理的结构,方便后续进行维护和扩展。

业务层结构
业务层被进一步细分,秉持单一数据流原则更好控制内容的变更:
图片

  • 基础数据:表格基础数据
  • 数据模型:将基础数据做转换后会生成数据模型
  • 视图模型:数据模型的变更会触发视图模型的变更
  • 显示对象树:视图模型变更后会触发更新显示对象树,它就是业务层的最终产出,后面会提供给 Kola2d 消费

显示对象树结构

显示对象树即表格内容的抽象呈现,表格内容大体可分为视图、装饰、插件这几类,每个类下面又有具体的细分:
图片
将视图、装饰、插件做抽象得到三大业务类,将业务类做组装并形成显示对象树的结构:
图片
Grid 即显示对象树,Grid 主要由视图层和装饰层组成:

  • 视图层有多个视图,每个视图内可以嵌入多个插件
  • 装饰层有多个装饰,每个装饰可以嵌入多个插件

Grid 目前只是一个基础的骨架,我们还需要给里面填充内容,Grid 的内容分为两种:

  • 内置内容:Grid 初始化时就会自动订阅视图模型,然后生成内容
  • 嵌入内容:由外部通过插件的形式嵌入进 Grid 里

看到这里你也许会疑惑,难道我们可以随意自定义显示对象树吗?答案当然是否定的!

Kola2d 内部提供以下基础的显示对象,几乎表格页面的所有内容都可以用这些对象表示:
图片
业务层生成的显示对象要么继承自这些对象,要么直接使用这些对象,所以它才能被 Kola2d 识别并最终渲染出来。

Kola2d 高效渲染的原理

Kola2d 的核心目标是能以 50+ FPS 的标准渲染百万单元格级的大表格,同时满足其多人协同场景下的流畅性。

处理百万单元格

我们前面做的性能测试显示 WebGL 底层是完全能扛住 100 万单元格的压力的,但我们的开销并不仅仅是在 GPU 上, CPU 也要处理大量的前置工作,比如排版、生成各种数据模型等,这往往会耗费很大的开销,让页面长时间处于无响应状态。前面提到 Kola2d 的角色仅仅是渲染,它并不负责排版这些前置操作,想要真正实现渲染 100 万单元格需要业务层和渲染层共同协作。

其实不管是大表格还是多人协同场景下的表格用户的关注点都在视图区内,如果视图区内的内容能够快速的呈现出来那么对于用户来说页面就是流畅的。那么可以换一种思路,虽然要渲染 100 万单元格,但我们是否只用在页面真正需要呈现这些内容时才去做对应的前置操作?

答案是可以的,只要业务层能确定目前视图区应该展示什么内容,那么就可以只准备视图区内的数据。但是想要确定视图区应该展示什么内容,就必须要先对就要对内容做排版,然后才能确哪些内容应该展示到视图区。Kola2d 虽然不直接负责排版,但是它对外提供了一个 FormattedText 的文字排版包,而文字排版又是页面排版最复杂的部分,它可以轻松实现文字的度量和排版,它内部会缓存排版结果,遇到样式相同的文字时会直接使用缓存结果,进而加快排版速度:
截屏2024-10-18 16.17.45.png

业务层可以直接调用 FormattedText 做文字排版,有了这个排版包的协助,业务层就可以轻松计算视图区排版结果。

但是,视图区外的数据会影响视图区内数据的相对位置,比如现在我在第 100 行的位置时,前面的数据会影响我后面的相对位置:
图片
因此,业务层启用了一个新的策略,视图区外的行列使用默认宽高,当它需要展示到视图区时再区排版它的真实宽高,然后将排版结果缓存。我们最终吐给 Kola2d 的数据是视图区内的单元格。当然我们也可以将 100 万单元格数据吐给 Kola2d,Kola2d 内部也有对应的优化策略,它内部也提供了一套剔除机制,会将不在视图区的单元格剔除,仅渲染视图区部分:
图片
但我们并不鼓励给 Kola2d 吐太多的数据,Kola2d 内部也有 CPU 计算,光是循环 100 万条数据,CPU 耗时就能达到 10ms 左右,我们希望尽量减少 CPU 的消耗,这样 CPU 可以做更多其它的事情。

处理多人协同

多人协同场景下的表格内容变更会非常频繁,假设有几百个人同时编辑表格,那么页面将疲于奔命去处理这些变更,所以我们需要尽可能的去减少单次变更所需要的时间。

页面上用户看到的内容就是一个个像素,页面的变更到最终变成为对应的像素数据有更专业的称呼:光栅

对于渲染引擎来说,光栅的开销是非常大的,就拿  Chrome 来说,所谓的硬件加速,就是把光栅的操作交给 GPU 来执行:
图片
尽管有 GPU 做光栅,但这还远远不够,Chrome 做了大量工作来减少每次光栅的数量:

  • 由 main 线程对对页面的绘制做分层,生成多层的绘制指令
  • 由 impl 线程做合成,分片等,对光栅区域做优先级区分,优先光栅高优的区域,同时光栅后结果是会做缓存的,避免重复光栅
  • 如果页面变动可以根据前面已经生成的结果做计算,然后仅光栅需要光栅的部分

从 Chrome 得到启发,kola2d 的核心架构思路即:

  • 尽量减少光栅
  • 尽量使用 GPU 做光栅
  • 尽量利用光栅缓存

到这里,或许你会有一个疑问,既然借鉴浏览器内部设计,那为什么不直接用 DOM?

其实最初表格就是直接用 DOM 渲染的,小表格还好,一旦遇到数万行甚至数十万行表格时,浏览器就显得吃力,同时多人协同时频繁变更 DOM 使得页面卡顿非常严重。因此后期做了一版优化,将 DOM 改 Canvas2d 渲染。

但这并不代表浏览器不行,而是在我们表格的这个场景下浏览器比较吃力而已。Kola2d 吸收了 Chrome 内部的一些优秀的设计理念引入了诸如分片、光栅结果缓存等概念,同时结合表格自身的使用场景设计了诸如剔除、视图区光栅等功能。

减少光栅的策略

对于减少光栅,我们不得不提到 Chrome 的分片,Chrome 分片主要用于以下:

  • 减少光栅:页面做分片后,仅对有内容变更的分片需要做光栅
  • 控制内存:分片可以帮助控制内存,对于不在视图区的分片或者离视图区比较远的分片,如果内存告急的时候可以被释放掉

Kola2d 的分片思想也是借鉴了  Chrome,但 Kola2d 的分片跟 Chrome 略有不同,Chrome 是排版完整个页面后再以页面维度做的分片,而 Kola2d 仅以单元格维度做的分片:
图片
Kola2d 为什么以单元格维度做分片?
图片
表格的最小变更范围为一个单元格,以单元格维度做分片,就能保证假如一个单元格内容发生变更那么就仅仅重新光栅化这一个单元格。

这在多人协同场景下十分有利,我们可以最大化的减少需要重新光栅的分片的数量,提高渲染性能。

同时由于业务层只关注视图区,就算多人协同修改了视图区外的数据,这也不会影响到视图区内,进一步减少了额外的损耗。

有了分片,我们可以仅光栅内容变更的单元格,其它单元格直接用缓存的光栅数据就可以了。

但假设协同人数实在太多,那么单位时间要处理的变更也就越多,就算我们把性能做得再极致那么还是会出现卡顿。因此,来自协同者的变更我们并不会立马处理,而是把它放入一个队列中,然后定时到消息队列里取一批消息做处理:
图片

光栅的过程

当某个分片内容发生了变更,我们需要对它重新做光栅,Kola2d 的位图系统应运而生。位图系统会把分片转换成对应的绘制指令,并最终转换成像素数据存到资源管理器。
图片
WebGL 只能绘制点、线、三角形,这意味着页面的所有内容都需要做对应的转换。对于图形这种方式还好,但是对于文字来说这种方式就变得比较吃力,比如下面的字体需要解析其路径信息然后绘制路径并描边:
图片
Chrome GPU 加速的本质就是把图形绘制操作交给在 GPU 进程的 Skia 来绘制,那么也引发了一个思考,我们是否也可以调用 GPU 上的 Skia 来做图形和文字绘制?

其实,目前很多主流浏览器都支持对 Canvas 2d 做硬件加速:

  • Chrome 13
  • Firefox 4
  • Internet Explorer 9
  • Opera 11
  • Safari 5

所以我们可以使用 Canvas 2d 当作一个媒介,前面所说的绘制指令最终都会变成 canvas 绘制指令,最终由底层的 GPU 将绘制指令转换成像素,然后把 Canvas 2d 当作纹理上传给 GPU。Canvas 2d 不支持 GPU 光栅怎么办?浏览器并不一定会对 Canvas 2d 做硬件加速,这种情况下我们仍然可以使用 Canvas 2d 做光栅,由于我们实现了分片以及光栅化结果缓存,即使 Canvas 2d 不支持 GPU 加速,我们也可以借由缓存来极大减少页面需要光栅化的数量从而提升性能。

光栅管理

前面提到光栅缓存,我们为Kola2d 设计了一个专门的类用于管理这些光栅后的数据即资源管理器,它主要提供以下功能:
图片
资源代表每个分片需要的内存已经内存对应的像素数据。位图管理器在对分片做光栅化的时候会先计算分片需要多少资源内存,然后向资源管理器做申请对应的大小,当光栅化完毕后,就会将像素数据写入到对应的内存地址。资源管理器会对它名下的所有资源做统一的管理,本小节不会对资源管理器 API 做讲解,反之会重点介绍它内部的管理功能。

资源优先级管理

资源管理器的空间是有限的,而众所周知像素占用的内存又比较高,我们需要及时清理那些不需要的资源。 Chrome 为了实现这一点将分片加了优先级:
图片
越靠近视图区那么分片所属的资源优先级就会越高。

Kola2d 资源管理器不会对资源做这么细力度的控制,而是在每一次 requestAnimation 周期里,如果执行了渲染,那么 updateId 就会更新,这样的计算机制可以保证,离视图区越近那么其优先级就越高,对于那些非视图区的资源,会根据优先级及时做释放:
图片

资源内存管理

资源管理器的内存使用率需要保持在一个合理的水平,内存不够就要及时清理内存。由于资源的优先级是被计算好的,当内存告急时很容易就知道那些资源需要被清理。

一个分片会占据一个资源内存,处于 Viewport 里的显示对象所拥有的资源内存 updateId 值最高,离 Viewport 越远 udpateId 越低,在每一个渲染周期里,会清理这些 updateId 低的资源:
图片

资源权限管理

Kola2d 资源管理器对资源的更新有严格的限制,比如你申请了 16 X 16 的像素区域的资源时,你写入像素的范围就不能超过这个大小,这是为了防止误操作到其它资源。

因此,每一次像素写入操作都必须先申请写入权限,操作完毕后关闭权限,关键代码如下:
图片
这样做还有另外一个好处,即可以追踪哪些资源被更新了,然后仅将变更的资源上传到 GPU。

页面合成

通过前面的处理,现在分片有了,分片的光栅化数据也有了,我们需要把这些光栅化的数据做组装,然后将它提交给 GPU 最终显示到页面上,Kola2d 的着色器就专门负责这项职能:
图片
每个单元格分片都有一个对应的 Draw Quad,Draw Quad 包含了单元格分片的屏幕位置以及它对应的像素位图的内置地址,这些 Draw Quad 会被放进一个 Array Buffer 中然后提交给 GPU,最终展示给用户。

得益于 WebGL,我们可以直接跟 GPU 打交道,这块儿的耗时几乎可以忽略不计。

四、额外优化

通过上文,我们对 Kola2d 是如何实现高效渲染有了初步的认知。为了进一步提升渲染性能,我们还做了很多额外的工作,结合实际业务场景,精细地设计各种策略,其基本原则是,尽可能的最小化更新任务,每次只变动真正需要重新的部分。

对象复用

由于我们只会渲染视图区域内的,随时有可能出现一部分单元格需要新增,一部分单元格需要删除,比如在滚动或者增删行列的场景下。创建单元格和销毁单元格都有一定开销,为此删除的单元格会被回收到回收池里,下次需要创建单元格时会优先复用回收池里的单元格。
图片

对象合并

样式相同的对象,虽然形状不一样,但是可以做合并处理,减少显示对象树的体积同时减少排版时间。
图片

排版优化

在整个渲染过程中,除了真正的绘制任务,还有很大一部分耗时在排版阶段.

排版的主要任务是根据行列信息,滚动位置等确定单元格在屏幕中的位置。由于单元格是有内容的,其展示的真实行高与模型行高可能不一致,我们需要对单元格内部的文字进行排版才能得到最终的高度。这个过程非常耗时,为此我们也采用了一系列的手段来优化排版性能。

精准标脏

导致表格重新渲染的原因很多,频率也很高,然而,并非所有操作都需要触发排版,也可能只需要局部排版而非全局排版。考虑到排版是一个耗时较长的任务。我们借鉴浏览器的实现,结合表格实际业务场景,设计了一套标脏更新机制,在不同场景下进行精准标脏。每次绘制前会检查脏区,以避免任何不必要的更新。
图片
同样标脏也是一种惰性更新策略,只有真正需要使用数据的时候才会执行任务,避免了大范围频繁操作下贪婪更新可能会造成的性能影响。

异步分片

一个在线表格可能有数十万乃至百万单元格,为了保证性能,我们默认只会排版并绘制视野范围内的单元格。然而在滚动等场景下,会有大量新的单元格不断出现在视野中,需要执行排版任务,会延长页面真正显示的时间,造成卡顿掉帧。

针对这一问题,我们借鉴经典的异步分片的思想,将排版任务拆分到行,利用浏览器运行的空闲时间异步执行,并缓存排版计算过程中的关键结果,排版后的内容会取消标脏,在后续滚动过程中无需再次执行排版任务。// 利用空闲时间对表格排版,后续滚动过程中无需再次执行排版任务,
截屏2024-10-18 16.25.07.png

文本排版优化

最后让我们来到文本排版任务本身,看看有没有优化的空间文本排版主要发生在自动换行场景下,其算法可概述为:

  • 根据 unicode 断行算法,找到断行可能发生的位置
  • 逐个检查断行位置,计算文本累积的宽度,如果可用空间不够就开始新的一行。
    图片
    该过程的主要耗时在文本测量阶段,在非 dom 场景下,我们依赖 ctx.measureText 方法测量文本宽度。由于字符在不同组合下的宽度是不一致的,不能单独将宽度相加,必须以组合的形式进行测量。目前比较普遍的优化方案是采用二分法(如 Konva Text ),但二分过程中会存在重复测量情况,且测量耗时是跟文本长度成正比的,在文本较长时表现也不够好。这里我们借鉴了 Chrome 浏览器的实现,结合表格实际场景,进行了如下的优化:
  • 根据可能发生断行的位置预先对文本进行分词,缓存分词以及单词宽度测量的结果,以便在后续排版任务中复用。
  • 当单词仅含有单个中文字符时是等宽的,可以使用预设标准字符的测量结果,也无需重复测量。
    图片
    由此,我们大大减少了文本排版耗时,经评测,相较于 Knova ,Kola2d 文本排版在长文本及中文场景下,有明显的性能优势。
    图片
    最后,我们会对单元格变更原因也进行归类,力求做到最小化精准排版
  • 当排版无关属性(如单元格背景色)发生变更时,不会触发文本排版任务
  • 当排版相关属性(如单元格layout)发生变更时,分词和测量结果均从缓存中读取,仅重新进行排版计算
  • 当单元格内容发生变更时,重新进行分词、测量和排版,复用部分缓存的测量结果。

本文作者:快手效率工程部


快手技术
7 声望3 粉丝