腾讯文档Doc Canvas渲染引擎流程改造
为了解决部分历史渲染问题,实现移动端canvas渲染的新功能,以及支持后续功能扩展,对腾讯文档Doc Canvas渲染引擎的流程进行了改造,本文对改造进行介绍和小结。
1. 改造背景
1.1. 解决历史问题
Doc文档滚动过程中偶现渲染空白(safari浏览器出现频率较高):
除了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
Android移动端滚动渲染performance:
由上图对比可以看出,在移动端单次drawImage开销就高达15ms,在单次渲染task中的开销占比非常高,是造成移动端下canvas渲染引擎性能问题的罪魁祸首之一。
2.1.3 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开销, 回收逻辑如下:
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文档。
移动端滚动场景优化前后对比: