为了解决部分历史渲染问题,实现移动端canvas渲染的新功能,以及支持后续功能扩展,对腾讯文档Doc Canvas渲染引擎的流程进行了改造,本文对改造进行介绍和小结。
1. 改造背景
1.1. 解决历史问题
Doc文档滚动过程中偶现渲染空白(safari浏览器出现频率较高):
1.2. 实现新功能(移动端canvas引擎统一渲染)
为了支持在移动端预览和PC端完全一致的文档内容(更完整排版、格式支持),需要在移动端通过canvas渲染引擎统一进行渲染;然而直接移植复用canvas渲染,原有渲染引擎在移动端存在性能问题,例如滚动存在明显卡顿(平均FPS低于15):
1.3. 支持后续功能扩展
后续浮动环绕文本框、图形等内容,可能拥有多个嵌套层级,且每个浮动元素有独立的overlay (高亮、底色)层级,例如下图的多个浮动文本框内容:
原有canvas渲染引擎直接复用,还原渲染上图内容的效果如下图所示:
所以,为了解决上述问题,需要对原有canvas渲染引擎进行改造优化。
2. 渲染流程剖析
2.1. 渲染层基本流程介绍
渲染层(Render Engine)最基本的能力就是将上层排版层生成的文档视图树形结构LayoutBox进行收集和渲染,最终将文档视图呈现在屏幕上,示意图如下图所示:
而要详细说明渲染层的收集和渲染流程,需要先简单介绍LayoutBox,如下图所示,LayoutBox是腾讯文档Doc经过排版后生成的用于描述文档页面信息的树形结构,不同类型的box表示文档中不同的层级和内容:
渲染层收集的目的,就是通过可视区域等信息判断并计算出需要渲染的文档区域,然后根据需要渲染的区域遍历LayoutBox树进行剪枝并收集需要渲染的box节点,最后对收集的结果按照层级进行排序以便后续渲染。剪枝示意图如下图所示:
渲染收集的剪枝旨在精确缩小需要渲染的内容范围,减少多余部分的遍历和渲染,降低多余的开销;收集过程中对收集的结果按照视图类型和渲染优先级进行排序,除了满足渲染优先级以外,同样也是为了减少渲染过程中canvas状态机的切换从而降低渲染开销、提升性能。渲染层收集、渲染核心流程示意图,如下图所示:
2.2. 不同场景渲染流程分析
介绍完渲染层基础流程,接下来针对不同场景的渲染流程进行介绍,以及针对改造背景中的问题进行对应分析。
2.1 滚动场景渲染
2.1.1 滚动场景渲染流程
如下图9所示,滚动场景下针对可重用的文档区域(滚动到下一帧渲染时还在可视范围的区域),为了避免多余的基础渲染流程(收集 渲染),直接使用canvas 基础 API drawImage将对应区域直接绘制到离屏canvas(在内存中创建的canvas元素,未dom挂载在页面上展示);针对新渲染区域(滚动产生的新出现在可视范围的区域),则在离屏canvas中执行基础渲染,并将对应区域drawImage绘回主canvas(展示文档内容的canvas)。
2.1.2 离屏canvas drawImage三宗罪
上述滚动场景的渲染流程,本意是通过drawImage将已经渲染的canvas画布当作图片来复用,从而节省掉多余的基础渲染流程:不需要重复执行遍历layoutBox 树进行裁剪以及后续收集、排序、绘制等开销比较高的逻辑。原本流程本身没有问题,且将canvas的常规性能优化手段都应用上了。
然而,问题就出在不同的浏览器以及系统平台对于canvas的支持度和兼容情况不尽相同,这里根据上述改造背景中的部分问题主要总结离屏canvas drawImage的三宗罪:
- iOS移动端存在canvas画布尺寸以及显存限制
实际上各浏览器对canvas画布最大尺寸都会有限制(超过限制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
然而Doc文档页面的canvas尺寸乘积远远没有达到这个级别,非放大情况下大概width(3600px)*height(1600px) = 5760000,缩放到最大400%时大概23040000,而且在safari复现问题时也并未弹出警告或提示。
由于safari浏览器内核逻辑对开发者来说是个黑盒,所以只能进行对照实验:
- 去掉渲染复用逻辑——去掉drawImage调用,全屏重新渲染,渲染空白的问题不再出现(当然全屏重新渲染会影响性能)
- 进行对比实验发现增加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分层雪上加霜
渲染层针对不同渲染场景,为了避免无效重绘,提升渲染效能,对不同的渲染内容做了分层。每层渲染拥有独立性,减小重绘粒度,降低了层级间的干扰:
其中canvas也做了分层,将文档主内容和overlay(选区、高亮、底色)分为两个canvas层分别进行渲染;主要针对仅切换选区或底色等内容时,可只处理overlay层的渲染,无须重复渲染main canvas (文档主内容)。
canvas的分层旨在进一步提升部分场景的渲染性能,然而经过上述2.1.2的分析后发现,canvas分层增加了canvas的数量导致:drawImage的调用频次增加、canvas占用的显存增加(每一层canvas对应一个额外的离屏canvas,让问题更加突出)。
注:另外canvas的分层还导致后续需要支持的浮动元素(文本框、图形)渲染受限,浮动元素拥有多层嵌套层级,且每个元素拥有单独的overlay(高亮、底色、选区),如果将overlay和主内容分层,则无法按照正常层级顺序渲染:
overlay和主内容main canvas,两个独立的canvas画布拥有不同的层级上下文,尽管在canvas内部可以管理不同的层级,但overlay和main canvas始终只能被另一方覆盖:
2.2 编辑场景渲染
2.2.1 编辑场景渲染流程
如图13所示,在编辑文档时,无论编辑的内容范围多大,渲染层都会将整个可视区域 buffer区域(可视区域上下缓冲区域) 作为脏区(需要重新渲染的区域),根据脏区对整个文档的排版DocumentBox进行遍历裁剪并将整个脏区对应的内容进行收集和重新渲染。
2.2.2 脏区范围大
对于编辑渲染流程,比较直观的感受便是渲染脏区范围较大,因为在编辑场景渲染层仅仅监听排版变化的layoutChange事件来进行重新渲染,故只能通过可视区域来判断并计算脏区。另外,渲染层仅仅使用两个canvas画布(主内容和overlay)对整个文档进行渲染展示,canvas画布尺寸和脏区大小一一对应,而canvas画布尺寸和canvas渲染耗时是正相关的:
所以渲染脏区越大,渲染开销越高,性能越差。主要体验在两方面:
- canvas画布尺寸大,渲染耗时高
- 渲染的内容多,遍历收集开销更高,特别对于一些嵌套层级可能较深的LayoutBox(如:表格)影响会更大
3. 分页渲染流程改造方案
3.1 滚动场景去掉离屏渲染(drawImage)
通过上述分析,渲染流程上去掉canvas drawImage是比较迫切的需求,而drawImage的调用主要应用在滚动场景的离屏渲染,其作用就是为了尽可能复用渲染内容减少重新渲染。
那么是否有方案可以不使用离屏渲染(drawImage),同时又能复用渲染内容呢?
想到移动端常用的虚拟列表优化方案,可以用来优化长列表滚动性能:
虚拟列表通过缓存列表数据,每次仅渲染可视区域对应的item dom节点,上下滚动时可复用dom节点仅更新dom对应的数据或样式,既避免dom数量过多,又减少了销毁和重新创建dom的开销。
Doc文档的滚动实际非常类似,且分页模式下排版结构中分页LogicPage和item可以天然对应起来:
分页渲染将每次渲染和复用的最小单位固定为文档的分页(对应排版结构LogicPage),滚动过程中仅仅需要对出现在渲染区域的新分页进行渲染,且新渲染分页可以复用脱离渲染区域的分页DOM,未脱离渲染区域的分页则无需任何更新。
通过这样的流程改造后,有以下收益:
- 可以完全弃用离屏canvas和drawImage,解决了drawImage带来的问题,减少了离屏canvas带来的额外显存和总画布尺寸占用
- 一个分页对应一个canvas, 减少了单个canvas的尺寸,一定程度上提升了渲染性能
然而以上流程仅仅适用于分页模式,流式模式下整个Doc文档的排版结构只有一个LogicPage(只有一页),为了解决流式模式仍然存在的以上问题且让渲染流程统一,接下来选择对排版层动手:
如上图所示,对流式模式下的排版进行了调整,将原先整个文档仅有一个分页LogicPage的排版结构,拆分为多个LogicPage,一个LogicPage对应一个虚拟分页。至此,流式模式和分页模式的分页渲染流程完全统一起来。
3.2 编辑场景减少脏区范围
解决完滚动场景下渲染问题,还需要考虑编辑场景。由上述2.2分析可知,原先渲染流程针对编辑场景,是将整个可视区域 buffer视为脏区进行了重新的收集和渲染,渲染脏区范围大。造成这个结果的原因主要是原先渲染层受限于以下两点:
- 流式模式下仅一个分页,编辑更新文档无法通过排版层精确获取脏区范围
- 分页模式下,虽然能通过排版层精确获取脏区对应的分页范围,但渲染上使用单独的canvas(不考虑分层和离屏)对整屏进行渲染,仍然需要对整个文档剪枝、收集
分页渲染则解决了这些限制,将编辑场景的渲染脏区减少为分页范围:
由上图示意,得益于流式模式下的虚拟分页,编辑场景下的脏区范围减少为分页范围,不在脏区的其他分页则可以完全复用,分页模式下也是同理。
注:编辑场景下,也可能出现编辑大范围内容并覆盖了多个分页的情况,这种情况下脏区最大范围也仅仅是可视区域对应的所有分页
3.3 增加canvas回收机制
经过以上改造,分页渲染的基本框架已经确定,但仍然有一些特殊情况需要考虑:
- 流式模式下的虚拟分页,排版层暂时还无法处理长图、长表格等内容的拆分,导致存在这些特殊内容排版结果会存在特别长的虚拟分页,进一步导致单个canvas画布特别大且对应渲染范围过大,严重影响渲染性能
- 放大页面,可视区域覆盖的分页数量减少,此时为了尽可能dom复用,可以保留不在可视区域的分页视图dom;但会导致放大后的分页对应canvas画布过大(如上述2.1.2的描述,在iOS移动端过大的canvas画布会因为尺寸和显存限制导致canvas渲染失效)
所以,针对以上特殊情况,渲染层增加了canvas回收机制:
- 首先对超长的虚拟分页对应的canvas,在渲染层拆分成更细粒度的二级canvas
- 对脱离可视区域的canvas, 进行画布回收
canvas回收机制示意图如下:
其中,对canvas的回收仅仅回收canvas画布,并不对canvas dom进行销毁,避免重新渲染时
增加新建dom开销, 回收逻辑如下:
代码语言:javascript复制canvasElement.width = 1;
canvasElement.height = 1;
直接将canvas画布width和height属性置为1,既能清空canvas绘制内容也能回收掉canvas画布占用的显存。
但……为什么不直接将width和height设置为0呢?
可以看下两种回收设置对比:
如上图所示,在safari浏览器,直接将canvas画布设置为width = 0, height=0,虽然画布尺寸确实更新为0,但是占用的显存并没有被浏览器回收。
(注:设置width和height为0进行回收的方式,在chrome可以正常回收显存;且在safari进行测试也是能正常回收,但safari devtools显示内存一直占用,此点尚且存疑)
增加canvas回收机制后,canvas画布所占尺寸和显存前后对比,canvas占用显存和尺寸均下降40%左右,如下图所示:
3.4 合并canvas,渲染层级统一管理
由上述2.1.3分析,还存在canvas分层带来的部分问题,main canvas和overlay canvas分层导致canvas画布数量翻倍,且渲染层级的管理无法支持后续扩展功能。
canvas分层目的主要针对切换选区或底色等内容时,可只处理overlay层的渲染,无须重复渲染main canvas (文档主内容),从而提升以上场景时的渲染性能。然而经过分析发现,渲染的开销主要集中在遍历、收集阶段,而非绘制阶段:
而canvas分层优化的开销主要是绘制阶段,遍历和收集的开销变化不大;另外,经过分页渲染流程改造后,单次渲染的区域减少进一步降低了绘制的开销。
再者,考虑到要支持环绕浮动元素的层级渲染,将选区、底色等和文档主内容放到同一个canvas层统一进行层级的管理是首选。所以对canvas层级进行合并:
文档主内容和overlay(高亮、底色、选区)全部合并到同一个canvas来进行渲染,不同内容层级可以统一管理,改造后,最终还原多个层级浮动文本框效果如下:
4. 总结
经过分页渲染改造,解决了滚动时渲染空白的历史问题,对后续环绕元素的层级渲染提供了支持;最重要的是解决了canvas渲染引擎在移动端的性能问题,使移动端的“分页视图”新功能可以正常使用,让用户可以直接在移动端浏览到和PC端渲染完全一致的Doc文档。
移动端滚动场景优化前后对比: