腾讯文档Doc Canvas渲染引擎流程改造

2022-11-22 13:09:13 浏览数 (1)

为了解决部分历史渲染问题,实现移动端canvas渲染的新功能,以及支持后续功能扩展,对腾讯文档Doc Canvas渲染引擎的流程进行了改造,本文对改造进行介绍和小结。

1. 改造背景

1.1. 解决历史问题

Doc文档滚动过程中偶现渲染空白(safari浏览器出现频率较高):

图1 滚动渲染空白BUG图1 滚动渲染空白BUG

1.2. 实现新功能(移动端canvas引擎统一渲染)

为了支持在移动端预览和PC端完全一致的文档内容(更完整排版、格式支持),需要在移动端通过canvas渲染引擎统一进行渲染;然而直接移植复用canvas渲染,原有渲染引擎在移动端存在性能问题,例如滚动存在明显卡顿(平均FPS低于15):

图2 移动端canvas渲染滚动卡顿图2 移动端canvas渲染滚动卡顿

1.3. 支持后续功能扩展

后续浮动环绕文本框、图形等内容,可能拥有多个嵌套层级,且每个浮动元素有独立的overlay (高亮、底色)层级,例如下图的多个浮动文本框内容:

图3 Microsoft word 多层浮动文本框示例图3 Microsoft word 多层浮动文本框示例

原有canvas渲染引擎直接复用,还原渲染上图内容的效果如下图所示:

图4 原渲染引擎还原效果图4 原渲染引擎还原效果

所以,为了解决上述问题,需要对原有canvas渲染引擎进行改造优化。

2. 渲染流程剖析

2.1. 渲染层基本流程介绍

渲染层(Render Engine)最基本的能力就是将上层排版层生成的文档视图树形结构LayoutBox进行收集和渲染,最终将文档视图呈现在屏幕上,示意图如下图所示:

图5 渲染层基本能力示意图图5 渲染层基本能力示意图

而要详细说明渲染层的收集和渲染流程,需要先简单介绍LayoutBox,如下图所示,LayoutBox是腾讯文档Doc经过排版后生成的用于描述文档页面信息的树形结构,不同类型的box表示文档中不同的层级和内容:

图6 LayoutBox示意图图6 LayoutBox示意图

渲染层收集的目的,就是通过可视区域等信息判断并计算出需要渲染的文档区域,然后根据需要渲染的区域遍历LayoutBox树进行剪枝并收集需要渲染的box节点,最后对收集的结果按照层级进行排序以便后续渲染。剪枝示意图如下图所示:

图7 收集-剪枝示意图图7 收集-剪枝示意图

渲染收集的剪枝旨在精确缩小需要渲染的内容范围,减少多余部分的遍历和渲染,降低多余的开销;收集过程中对收集的结果按照视图类型和渲染优先级进行排序,除了满足渲染优先级以外,同样也是为了减少渲染过程中canvas状态机的切换从而降低渲染开销、提升性能。渲染层收集、渲染核心流程示意图,如下图所示:

图8 渲染层收集、渲染核心流程图8 渲染层收集、渲染核心流程

2.2. 不同场景渲染流程分析

介绍完渲染层基础流程,接下来针对不同场景的渲染流程进行介绍,以及针对改造背景中的问题进行对应分析。

2.1 滚动场景渲染

2.1.1 滚动场景渲染流程

如下图9所示,滚动场景下针对可重用的文档区域(滚动到下一帧渲染时还在可视范围的区域),为了避免多余的基础渲染流程(收集 渲染),直接使用canvas 基础 API drawImage将对应区域直接绘制到离屏canvas(在内存中创建的canvas元素,未dom挂载在页面上展示);针对新渲染区域(滚动产生的新出现在可视范围的区域),则在离屏canvas中执行基础渲染,并将对应区域drawImage绘回主canvas(展示文档内容的canvas)。

图9 滚动场景渲染流程示意图图9 滚动场景渲染流程示意图
2.1.2 离屏canvas drawImage三宗罪

上述滚动场景的渲染流程,本意是通过drawImage将已经渲染的canvas画布当作图片来复用,从而节省掉多余的基础渲染流程:不需要重复执行遍历layoutBox 树进行裁剪以及后续收集、排序、绘制等开销比较高的逻辑。原本流程本身没有问题,且将canvas的常规性能优化手段都应用上了。

然而,问题就出在不同的浏览器以及系统平台对于canvas的支持度和兼容情况不尽相同,这里根据上述改造背景中的部分问题主要总结离屏canvas drawImage的三宗罪

  • iOS移动端存在canvas画布尺寸以及显存限制

实际上各浏览器对canvas画布最大尺寸都会有限制(超过限制canvas的渲染将会失效):

(来源:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas)(来源:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas)

一般而言应用中的canvas尺寸都不会超过上述限制,可以正常使用,然而在移动端iOS/safari canvas的尺寸限制会小很多:

除了canvas尺寸限制,甚至还有canvas画布占用的显存限制:

所以对于iOS移动端,canvas的使用需要非常谨慎,尽可能减少canvas的数量和尺寸,避免超过限制引发BUG。然而drawImage的使用,依赖额外的离屏canvas,这样相当于直接把canvas的数量乘以了2倍。

  • safari浏览器对drawImage限制,导致渲染白屏

此问题主要集中在safari浏览器,正常滚动文档页面会偶现canvas drawImage不生效导致渲染白屏的问题。

由上述(1)可知,当canvas画布尺寸超过浏览器限制时,会导致canvas绘制失效,safari会在控制台弹出警告:

chrome和safari绘制失败的canvas画布尺寸上限比较一致,但chrome会直接绘制失效,没有任何提示。可以使用试验demo验证: https://xdevilj136.github.io//large_canvas_drawImage_bug.html

canvas画布尺寸超过限制 ,drawImage失效demo演示 width(27000px)* height(10000px)canvas画布尺寸超过限制 ,drawImage失效demo演示 width(27000px)* height(10000px)

然而Doc文档页面的canvas尺寸乘积远远没有达到这个级别,非放大情况下大概width(3600px)*height(1600px) = 5760000,缩放到最大400%时大概23040000,而且在safari复现问题时也并未弹出警告或提示。

由于safari浏览器内核逻辑对开发者来说是个黑盒,所以只能进行对照实验:

  1. 去掉渲染复用逻辑——去掉drawImage调用,全屏重新渲染,渲染空白的问题不再出现(当然全屏重新渲染会影响性能)
  2. 进行对比实验发现增加canvas画布尺寸时,drawImage的失败概率会大大增加导致渲染出现空白(width:4600px ,height:1600px时,失败概率50%以上)

对照实验结果说明渲染空白问题确实和drawImage相关,且在canvas画布尺寸大到一定量级时,浏览器有相应的逻辑限制drawImage绘制。

  • 移动端下drawImage开销巨大

针对移动端渲染性能问题,经过分析发现虽然在PC端drawImage的开销基本忽略不计,但在移动端(Android和iOS)下开销巨大,甚至高于对可重用区域进行重新收集、渲染的开销。

PC端滚动渲染performance:

Android移动端滚动渲染performance:

由上图对比可以看出,在移动端单次drawImage开销就高达15ms,在单次渲染task中的开销占比非常高,是造成移动端下canvas渲染引擎性能问题的罪魁祸首之一。

2.1.3 canvas分层雪上加霜

渲染层针对不同渲染场景,为了避免无效重绘,提升渲染效能,对不同的渲染内容做了分层。每层渲染拥有独立性,减小重绘粒度,降低了层级间的干扰:

图10 渲染分层架构图10 渲染分层架构

其中canvas也做了分层,将文档主内容和overlay(选区、高亮、底色)分为两个canvas层分别进行渲染;主要针对仅切换选区或底色等内容时,可只处理overlay层的渲染,无须重复渲染main canvas (文档主内容)。

canvas的分层旨在进一步提升部分场景的渲染性能,然而经过上述2.1.2的分析后发现,canvas分层增加了canvas的数量导致:drawImage的调用频次增加、canvas占用的显存增加(每一层canvas对应一个额外的离屏canvas,让问题更加突出)。

注:另外canvas的分层还导致后续需要支持的浮动元素(文本框、图形)渲染受限,浮动元素拥有多层嵌套层级,且每个元素拥有单独的overlay(高亮、底色、选区),如果将overlay和主内容分层,则无法按照正常层级顺序渲染:

图11 canvas分层浮动元素层级示意图图11 canvas分层浮动元素层级示意图

overlay和主内容main canvas,两个独立的canvas画布拥有不同的层级上下文,尽管在canvas内部可以管理不同的层级,但overlay和main canvas始终只能被另一方覆盖:

图12 canvas分层导致两个canvas互相覆盖图12 canvas分层导致两个canvas互相覆盖

2.2 编辑场景渲染

2.2.1 编辑场景渲染流程

如图13所示,在编辑文档时,无论编辑的内容范围多大,渲染层都会将整个可视区域 buffer区域(可视区域上下缓冲区域) 作为脏区(需要重新渲染的区域),根据脏区对整个文档的排版DocumentBox进行遍历裁剪并将整个脏区对应的内容进行收集和重新渲染。

图13 编辑场景渲染流程示意图图13 编辑场景渲染流程示意图
2.2.2 脏区范围大

对于编辑渲染流程,比较直观的感受便是渲染脏区范围较大,因为在编辑场景渲染层仅仅监听排版变化的layoutChange事件来进行重新渲染,故只能通过可视区域来判断并计算脏区。另外,渲染层仅仅使用两个canvas画布(主内容和overlay)对整个文档进行渲染展示,canvas画布尺寸和脏区大小一一对应,而canvas画布尺寸和canvas渲染耗时是正相关的:

图14 canvas渲染耗时和渲染尺寸相关趋势图 (来源:https://smus.com/canvas-vs-svg-performance/)图14 canvas渲染耗时和渲染尺寸相关趋势图 (来源:https://smus.com/canvas-vs-svg-performance/)

所以渲染脏区越大,渲染开销越高,性能越差。主要体验在两方面:

  1. canvas画布尺寸大,渲染耗时高
  2. 渲染的内容多,遍历收集开销更高,特别对于一些嵌套层级可能较深的LayoutBox(如:表格)影响会更大

3. 分页渲染流程改造方案

3.1 滚动场景去掉离屏渲染(drawImage)

通过上述分析,渲染流程上去掉canvas drawImage是比较迫切的需求,而drawImage的调用主要应用在滚动场景的离屏渲染,其作用就是为了尽可能复用渲染内容减少重新渲染。

那么是否有方案可以不使用离屏渲染(drawImage),同时又能复用渲染内容呢?

想到移动端常用的虚拟列表优化方案,可以用来优化长列表滚动性能:

图15 虚拟列表优化方案图15 虚拟列表优化方案

虚拟列表通过缓存列表数据,每次仅渲染可视区域对应的item dom节点,上下滚动时可复用dom节点仅更新dom对应的数据或样式,既避免dom数量过多,又减少了销毁和重新创建dom的开销。

Doc文档的滚动实际非常类似,且分页模式下排版结构中分页LogicPage和item可以天然对应起来:

图16 滚动场景分页渲染图16 滚动场景分页渲染

分页渲染将每次渲染和复用的最小单位固定为文档的分页(对应排版结构LogicPage),滚动过程中仅仅需要对出现在渲染区域的新分页进行渲染,且新渲染分页可以复用脱离渲染区域的分页DOM,未脱离渲染区域的分页则无需任何更新。

通过这样的流程改造后,有以下收益:

  1. 可以完全弃用离屏canvas和drawImage,解决了drawImage带来的问题,减少了离屏canvas带来的额外显存和总画布尺寸占用
  2. 一个分页对应一个canvas, 减少了单个canvas的尺寸,一定程度上提升了渲染性能

然而以上流程仅仅适用于分页模式,流式模式下整个Doc文档的排版结构只有一个LogicPage(只有一页),为了解决流式模式仍然存在的以上问题且让渲染流程统一,接下来选择对排版层动手:

图17 流式模式排版虚拟分页图17 流式模式排版虚拟分页

如上图所示,对流式模式下的排版进行了调整,将原先整个文档仅有一个分页LogicPage的排版结构,拆分为多个LogicPage,一个LogicPage对应一个虚拟分页。至此,流式模式和分页模式的分页渲染流程完全统一起来。

3.2 编辑场景减少脏区范围

解决完滚动场景下渲染问题,还需要考虑编辑场景。由上述2.2分析可知,原先渲染流程针对编辑场景,是将整个可视区域 buffer视为脏区进行了重新的收集和渲染,渲染脏区范围大。造成这个结果的原因主要是原先渲染层受限于以下两点:

  1. 流式模式下仅一个分页,编辑更新文档无法通过排版层精确获取脏区范围
  2. 分页模式下,虽然能通过排版层精确获取脏区对应的分页范围,但渲染上使用单独的canvas(不考虑分层和离屏)对整屏进行渲染,仍然需要对整个文档剪枝、收集

分页渲染则解决了这些限制,将编辑场景的渲染脏区减少为分页范围:

图18 编辑场景分页渲染图18 编辑场景分页渲染

由上图示意,得益于流式模式下的虚拟分页,编辑场景下的脏区范围减少为分页范围,不在脏区的其他分页则可以完全复用,分页模式下也是同理。

注:编辑场景下,也可能出现编辑大范围内容并覆盖了多个分页的情况,这种情况下脏区最大范围也仅仅是可视区域对应的所有分页

3.3 增加canvas回收机制

经过以上改造,分页渲染的基本框架已经确定,但仍然有一些特殊情况需要考虑:

  1. 流式模式下的虚拟分页,排版层暂时还无法处理长图、长表格等内容的拆分,导致存在这些特殊内容排版结果会存在特别长的虚拟分页,进一步导致单个canvas画布特别大且对应渲染范围过大,严重影响渲染性能
  2. 放大页面,可视区域覆盖的分页数量减少,此时为了尽可能dom复用,可以保留不在可视区域的分页视图dom;但会导致放大后的分页对应canvas画布过大(如上述2.1.2的描述,在iOS移动端过大的canvas画布会因为尺寸和显存限制导致canvas渲染失效)

所以,针对以上特殊情况,渲染层增加了canvas回收机制:

  1. 首先对超长的虚拟分页对应的canvas,在渲染层拆分成更细粒度的二级canvas
  2. 对脱离可视区域的canvas, 进行画布回收

canvas回收机制示意图如下:

图19 超长虚拟分页canvas拆分图19 超长虚拟分页canvas拆分
图20 放大页面回收canvas图20 放大页面回收canvas

其中,对canvas的回收仅仅回收canvas画布,并不对canvas dom进行销毁,避免重新渲染时

增加新建dom开销, 回收逻辑如下:

代码语言:javascript复制
canvasElement.width = 1;
canvasElement.height = 1;

直接将canvas画布width和height属性置为1,既能清空canvas绘制内容也能回收掉canvas画布占用的显存。

但……为什么不直接将width和height设置为0呢?

可以看下两种回收设置对比:

width = 0, height=0width = 0, height=0
width = 1, height=1width = 1, height=1

如上图所示,在safari浏览器,直接将canvas画布设置为width = 0, height=0,虽然画布尺寸确实更新为0,但是占用的显存并没有被浏览器回收。

(注:设置width和height为0进行回收的方式,在chrome可以正常回收显存;且在safari进行测试也是能正常回收,但safari devtools显示内存一直占用,此点尚且存疑)

增加canvas回收机制后,canvas画布所占尺寸和显存前后对比,canvas占用显存和尺寸均下降40%左右,如下图所示:

图21 增加canvas回收机制前后对比图21 增加canvas回收机制前后对比

3.4 合并canvas,渲染层级统一管理

由上述2.1.3分析,还存在canvas分层带来的部分问题,main canvas和overlay canvas分层导致canvas画布数量翻倍,且渲染层级的管理无法支持后续扩展功能。

canvas分层目的主要针对切换选区或底色等内容时,可只处理overlay层的渲染,无须重复渲染main canvas (文档主内容),从而提升以上场景时的渲染性能。然而经过分析发现,渲染的开销主要集中在遍历、收集阶段,而非绘制阶段:

图22 单次渲染开销耗时图22 单次渲染开销耗时

而canvas分层优化的开销主要是绘制阶段,遍历和收集的开销变化不大;另外,经过分页渲染流程改造后,单次渲染的区域减少进一步降低了绘制的开销。

再者,考虑到要支持环绕浮动元素的层级渲染,将选区、底色等和文档主内容放到同一个canvas层统一进行层级的管理是首选。所以对canvas层级进行合并:

图23 合并canvas示意图图23 合并canvas示意图

文档主内容和overlay(高亮、底色、选区)全部合并到同一个canvas来进行渲染,不同内容层级可以统一管理,改造后,最终还原多个层级浮动文本框效果如下:

图24 合并canvas后还原浮动文本框效果图24 合并canvas后还原浮动文本框效果

4. 总结

经过分页渲染改造,解决了滚动时渲染空白的历史问题,对后续环绕元素的层级渲染提供了支持;最重要的是解决了canvas渲染引擎在移动端的性能问题,使移动端的“分页视图”新功能可以正常使用,让用户可以直接在移动端浏览到和PC端渲染完全一致的Doc文档。

移动端滚动场景优化前后对比:

图25 移动端canvas渲染滚动场景优化前后对比图25 移动端canvas渲染滚动场景优化前后对比

0 人点赞