若干年前,我写过一篇介绍浏览器渲染流水线的文章 - How Rendering Work (in WebKit and Blink),这篇文章,一来部分内容已经过时,二来缺少一个全局视角来对流水线整体进行分析,所以打算重新写一篇新的文章,从一个更高抽象层次和高度简化的方式对浏览器的渲染流水线进行解析,能让大部分页端同学都能够看的明白,并以此作为指引来分析和优化页面的渲染/动画性能。
有些基本概念如图层,分块,光栅化基本没有发生变化,如果读者不理解的话请参考 How Rendering Work (in WebKit and Blink),本文不再过多解释。
写这篇文章是始于年初对页端开发高性能(交互/动画) Mobile WebApp 的一些思考,实际开始写已经是年中… 陆陆续续写了几个月才终于写完。感觉最难的还是开始的部分,要能够先把基础的部分讲解清楚,然后再循序渐进去讲解更复杂的概念。思考了很久,最终决定从帧的概念入手,然后把动画定义成一个连续的帧序列的组合这种方式来对动画进行解析,最终成文的效果还是比较让自己满意的。
文章是在作业部落上写的的,然后再把 Markdown 贴到其它网站,无法保证每个网站最终呈现的排版效果,如果读者觉得排版欠佳,可以访问在作业部落上发布的原网址。
本文基于当前版本的 Chrome 浏览器写成(60 左右),理论上部分知识可以应用于其它浏览器(当然术语会有一定差别)或者 Chrome 后续的版本,但是并不完全保证这一点。
上图显示了 Chrome 一个高度简化后的渲染流水线示意图:
当我们说 Compositor,在没有加修饰语的情况下,一般都是指 Layer Compositor。另外术语 Child Compositor(子合成器)也是指 Layer Compositor,相对于作为 Parent 的 Display Compositor 而言。
一个 Chrome 浏览器一般会有一个 Browser 进程,一个 GPU 进程,和多个 Renderer 进程,通常每个 Renderer 进程对应一个页面。在特殊架构(Android WebView)或者特定配置下,Browser 进程可以兼作 GPU 进程或者 Renderer 进程(意味着没有独立的 GPU 或者 Renderer 进程),但是 Browser 跟 Renderer,Browser 跟 GPU,Renderer 跟 GPU 之间的系统架构和通讯方式基本保持不变,线程架构也是同样。
Display Compositor 后面应该会移到 GPU 进程的主 GPU 线程,当然对父子合成器进行调度的部分仍然是在 Browser 进程的 UI 线程,不太确定各个不不同平台的状况,Android WebView 平台是已经实现了。
所有的渲染流水线都会有帧的概念,帧这个概念抽象描述了渲染流水线下级模块往上级模块输出的绘制内容相关数据的封装。我们可以看到 Blink 输出 Main Frame 给 Layer Compositor,Layer Compositor 输出 Compositor Frame 给 Display Compositor,Display Compositor 输出 GL Frame 给 Window。我们觉得一个动画是否流畅,最终取决于 GL Frame 的帧率(也就是目标窗口的绘制更新频率),而觉得一个触屏操作是否响应即时,取决于从 Blink 处理事件到 Window 更新的整个过程的耗时(理论上应该还要加上事件从 Browser 发送给 Compositor,再发送给 Blink 的这个过程的耗时)。
Main Frame 包含了对网页内容的描述,主要以绘图指令的形式,或者可以简单理解为某个时间点对整个网页的一个矢量图快照(可以局部更新)。当前版本的 Chrome,图层化的决策仍然由 Blink 来负责,Blink 需要决定如何根据网页的 DOM 树来生成一颗图层树,并以 DisplayList 的形式记录每个图层的内容(未来图层化决策应该会转移到 Layer Compositor,Blink 只输出 DisplayList 树和 DisplayList 节点的关键属性,同时 DisplayList 不再以图层作为单位,而是以每个排版对象作为单位)。
图层化决策一般由以下几个因素决定:
第三点是可以被页端所直接控制来优化图层结构及 Main Frame 性能,像传统的 translate3d hack 和新的 CSS 属性 will-change。
Layer Compositor 接收 Blink 生成的 Main Frame,并转换成合成器内部的图层树结构(因为图层化决策仍然由 Blink 负责,所以这里的转换基本上可以认为是生成一棵同样的树,再逐个对图层进行拷贝)。
Layer Compositor 需要为每个图层进行分块,为每个分块分配 Resource(Texture 的封装),然后安排光栅化任务。
当 Layer Compositor 接收到来自 Browser 的绘制请求时,它会为当前可见区域的每个图层的每个分块生成一个 Draw Quad 的绘制指令(矩形绘制,指令实际上指定了坐标,大小,变换矩阵等属性),所有的 Draw Quad 指令和对应的 Resource 的集合就构成了 Compositor Frame。Compositor Frame 被发送往 Browser,并最终到达 Display Compositor(未来也可以直接发给 Display Compositor)。
Display Compositor 将 Compositor Frame 的每个 Draw Quad 绘制指令转换一个 GL 多边形绘图指令,使用对应 Resource 封装的 Texture 对目标窗口进行贴图,这些 GL 绘图指令的集合就构成了一个 GL Frame,最终由 GPU 执行这些 GL 指令完成网页在窗口上占据的可见区域的绘制。
Chrome 渲染流水线的调度是基于请求和状态机响应,调度的最上级中枢运行在 Browser UI 线程,它按显示器的 VSync(垂直同步)周期向 Layer Compositor 发出输出下一帧的请求,而 Layer Compositor 根据自身状态机的状态决定是否需要 Blink 输出下一帧。
Display Compositor 则比较简单,它持有一个 Compositor Frame 的队列不断的进行取出和绘制,输出的频率唯二地取决于 Compositor Frame 的输入频率和自身绘制 GL Frame 的耗时。基本上可以认为 Layer Compositor 和 Display Compositor 是生产者和消费者的关系。
动画可以看做是一个连续的帧序列的组合。我们把网页的动画分成两大类 —— 一类是合成器动画,一类是非合成器动画(UC 内部也将其称为内核动画或者 Blink Animation,虽然这不是 Chrome 官方的术语)。
合成器动画又可以分为两类:
Blink 触发的动画,如果是 Transform 和 Opacity 属性的动画基本上都可以由合成器运行,因为它们没有改变图层的内容。不过即使可以交由合成器运行,它们也需要产生一个新的 Main Frame 提交给合成器来触发这个动画,如果这个 Main Frame 包含了大量的图层变更,也会导致触发的瞬间卡顿,页端事先对图层结构进行优化可以避免这个问题。
非合成器动画也可以分为两类:
合成器动画和非合成器动画在渲染流水线上有较大的差异,后者更复杂,流水线更长。上面四种动画的分类,按渲染流水线的复杂程度和理论性能排列(复杂程度由低到高,理论性能由高到低):
长久以来,浏览器渲染流水线的设计都主要是为了合成器动画的性能而优化,甚至在某种程度上导致非合成器动画性能的下降,比如说合成器的异步光栅化机制。不过这两年,随着对 WebApp 渲染性能包括 WebGL 性能的重视,并且随着主流移动设备的硬件性能持续提升,合成器动画的性能也已经基本不成问题,Chrome 的渲染流水线已经更多地针对非合成器动画的性能进行优化,甚至会导致在某些特定状况下合成器动画性能的下降,比方说倾向于为了维持图层树的稳定性,减少变更,而生成更多的图层。不过总的说来,目前 Chrome 的渲染流水线,在主流的移动设备上,大部分场景下,两者性能都能获得一个较好的平衡。
这里的性能分析主要是针对移动设备,以桌面处理器的性能,大部分场景下都不存在性能问题。目前移动设备的屏幕刷新率基本上都是 60hz,而浏览器跟其它应用一样,需要跟屏幕刷新保持垂直同步,也就是动画帧率的上限是 60 帧,这也是我们能够达到的最理想的结果。不过考虑浏览器本身的复杂程度,可能有很多后台任务在运行,而且操作系统本身也可能同时运行其它后台任务,并且移动平台要考虑能耗和散热,CPU/GPU 的调度策略会频繁地发生变化,要完全锁定 60 帧是非常困难的。
如果上限超过 60 帧,实际平均帧率超过 60 反而不难,但是如果上限是 60 帧,垂直同步下要锁定 60 帧是非常困难的,要求每一帧的各个环节耗时都要保持非常稳定。
一般而言:
要达到 50 帧以上的水平,我们就需要对动画在渲染流水线的每个重要环节进行性能计算,需要知道这些环节最长允许的耗时上限和网页影响这些环节耗时的主要原因,虽然实际上很难完全锁定 60 帧,但是一般来说性能分析/优化还是会以 60 帧为目标来倒推各个环节的最大耗时。
如果是场景比较复杂的 Canvas/WebGL 游戏,以 30 帧为目标帧率是一个合理的诉求。
在对动画性能进行分析之前,需要先说明一下目前的 Chrome 的光栅化机制。合成器会监控是否需要安排新的光栅化任务,当需要光栅化调度时:
实际的光栅化区域会比当前可见区域要更大一些,一般是增加一个分块大小单位,对不可见区域的预光栅化有助于提升合成器动画的性能和减少出现空白的几率。
从上可知,合成器的光栅化调度完全是异步的,合成器在 Compositor 线程需要执行的就是安排光栅化任务和检查哪些任务已经完成,Compositor 线程本身不会被真正运行光栅化任务的 Worker 线程所阻塞。
上图显示了合成器动画的渲染流水线示意图,根据 Android WebView 平台的实现进行绘制,其它平台可能略微不同,但对后面的性能分析,在大部分情况下影响不大
整个流水线的大概过程是:
上述流程的一些关键点是:
总的来说影响合成器动画性能的最关键因素就是过度绘制系数(Overdraw,可以理解为绘制的面积和可见区域面积的比例),如果网页本身存在大量图层堆叠情况,导致过度绘制系数过高,就会严重影响合成器动画的性能。经验显示,过度绘制系数比较理想的值是在 2 以内,一般建议不超过 3,这样可以保证在中低端的移动设备上也有不错的性能表现。
另外,合成器动画过程中,Compositor 和 GPU 线程是前台线程,它们虽然理论上不会被 Worker 和 Renderer 线程阻塞,但是在真实的运行场景中,移动设备的 CPU/GPU 和内存带宽等硬件资源是有限的,如果 Worker 和 Renderer 线程处于高负荷状态下,也会导致前台的 Compositor 和 GPU 线程阻塞,最终导致合成器动画掉帧。
这种现象常见于:
根据上述的耗时分析,我们可以给出一个页端优化合成器动画性能的简单 Checklist:
如何判断网页的图层结构是否稳定,一般而言,如果是位于叶子节点的图层增加或者移除,对整个图层结构影响并不大,但是如果是中间节点的图层增加或者移除,对图层结构的影响就比较大了,并且越是接近根节点,影响就越大。
现在的页端都会大量使用异步加载来优化加载性能和流量,但是容易出现导致动画掉帧的现象。要平衡好这一点意味着需要实现一个加载和关联 DOM 操作的调度器,如果检查到动画正在运行,则停止加载或者通过节流阀机制降低加载的并发数量和频率,同时可以通过事先生成相应的 DOM 节点和图层作为占位符来避免加载后的图层结构发生剧烈变化。
前面已经我们已经把非合成器动画区分为 Blink 触发,无法由合成器运行的动画和由 Timer/RAF 驱动的 JS 动画两类,因为前者可以认为是后者的一个简化版本,所以这一章主要讨论 Timer/RAF 驱动的 JS 动画。
从上图可以看出非合成器动画的流水线比合成器动画更长更复杂,并且非合成器动画的后半段跟合成器动画是一致的。
上述流程的一些关键点是:
总的来说对非合成器动画性能影响最大的通常是 JavaScript 和 Rasterize,要实现高性能的非合成器动画,页端需要很小心地控制 JavaScript 部分的耗时,并避免在每一帧中引入大面积的网页内容变化和大幅度的图层结构变化。另外非合成器动画的后半段就是合成器动画,所以对合成器动画的性能优化要求也同样适用于非合成器动画。
另外对于 WebGL 来说,当在 JavaScript 里面调用 WebGL API 时,这些命令只是被 Chrome 缓存起来,并不会在 Renderer 线程调用真正的 GL API,所以 WebGL API 在 JavaScript 部分的耗时只是一个 JS Binding 调用的 Overhead,最终绘制 WebGL 内容的 GPU 耗时实际上是被包含在最后的 GPU 的步骤里面。但是在移动平台上一个 JS Binding 调用的 Overhead 是相当高的,大概在 0.01 毫秒这个范围,所以每一帧超过 1000 个 WebGL API 调用的 WebGL 游戏,性能阻塞的瓶颈有很大概率会出现在 JavaScript 也就是 CPU 上,而不是 GPU。
看完非合成器动画流水线分析的读者,第一感觉恐怕是觉得太过复杂了,比起合成器动画,更多的线程,更多的中间环节,有时即使每个环节都做到了完美,最终也有可能因为线程之间的通讯和等待而导致掉帧。正如前面所说的一样,长久以来,浏览器的渲染流水线都是为了合成器动画而优化的,它的主要特征是:
这两点实际上是有利于合成器动画而不利于非合成器动画的,浏览器渲染流水线当前和未来的演化理所应当地要解决这些问题。Firefox 新的渲染引擎 WebRender 比较激进,看起来是采用流行的 UI Toolkit 如 Qt,Android 的 DisplayList/Scene Graph + Direct Rasterize 的方式,这样当然是有利于非合成器动画的,但是否会造成合成器动画的性能衰退还很难说。
Direct Rasterize 的前提条件是浏览器渲染引擎要实现 GPU 光栅化,并且性能和兼容性要足够好。Chrome 当前也是在努力推进 GPU 光栅化,即使整个流水线的架构没有发生变化,GPU 光栅化也可以大幅度减少光栅化在 Worker 线程部分的 CPU 耗时,将这些耗时转移到 GPU 线程上去,这样 Main Frame N Active 这部分的耗时就可以大大减少了。从目前的统计数据来看,可以使用 GPU 光栅化的移动设备占比已经很高,大概 6 成以上的样子,随着老旧设备的淘汰,这个比例未来会越来越高,并且随着 GPU 性能的提升,GPU 光栅化的效果也会越来越好。
Chrome 另外一个重点改进渲染性能的项目是 Slim Painting,未来 Blink 跟 Firefox 类似只输出 DisplayList 树,并且每棵 DisplayList 不再是以图层为单位而是以排版对象为单位,这样合成器可以更自由地选择 Layerize 和 Rasterize 的策略,Async 或者 On Demand 或者 Direct Rasterize 都可以混用来最大化动画的性能,合成器可以变得更 Adaptive 而不像现在光栅化和合成区隔的那么泾渭分明。
除了浏览器持续改进自身渲染流水线外,提供更多 API 供页端使用来最大化 WebApp 的性能也是一个重要的方向,包括:
但是这些新特性要真正应用起来,对页端对浏览器渲染流水线的理解要求就更高了。