本文是 Chrome 团队新人入职学习资料《Life of a Pixel》的概要版。 原文 Slides 地址:https://bit.ly/lifeofapixel 中文字幕演讲视频地址:https://www.bilibili.com/video/av35265997/
《Life of a Pixel》内容讲的是开发者编写的 web 内容(也就是通常所说的 HTML CSS JS 以及 image、video 等其他资源)渲染为图形并呈现到屏幕上的整个过程。我将其演讲内容分为以下三个部分,第一个是静态渲染过程,讲述一个完整的从 content 到 pixel 的渲染过程;第二个是动态更新过程,讲述浏览器如何高效更新页面内容。
概览
首先看一下整个过程的概览。在了解详细内容前,我们也大概知道浏览器最终是通过调用 GPU 完成像素到屏幕的绘制。但这个过程中有很多的步骤。注意概览图中浏览器的渲染进程是放在沙箱进程中由 Blink 处理的,这也是其安全策略。
静态渲染过程
这一页的内容对于广大前端从业者来说应该都比较熟悉。首先是 HTML 通过 HTMLDocumentParser 转换为 DOM 树,CSS 通过 CSSParser 转换为 StyleRule 集。每个 StyleRule 包含 CSSSelector 和 CSSPropertyValue,当然二者间存在对应关系。再加上浏览器提供的每种类型元素的 DefaultStyle,经过一系列的计算(这一步称为 recalc)生成所有元素包含所有 style 属性值的 ComputedStyle,如右上角的图所示。ComputedStyle 也通过开发者工具和 JS API 暴露了出来,相信大家也不陌生。
接下来一步就是 layout。layout 的功能是根据上一步得到的所有元素的 computedStyle,将所有元素的位置布局计算好。每个元素在这一步会生成一个 LayoutObject,简单来说其包含四个属性:x、y、width 及 height 用于标识其布局位置。layout 最简单的情况就是,所有的块按照 DOM 顺序从上往下排列,也就是我们常说的流。layout 也包含很复杂的情况,比如带有 overflow 属性的元素,浏览器会计算其 border-box 的长宽和实际内容的长宽。如果设置为 scroll 并且内容超出,还要为其预留滚动条的位置。此外, float、flexbox 等布局也会使得 layout 变复杂。。所以为了解决复杂性的问题,layout 阶段浏览器首先会生成一个和 DOM 树节点大致一一对应的 layout 树,然后遍历该树,将经过计算后得出的位置布局数据填入节点。对于这个过程,Chrome 团队认为没有很好地分离输入和输出,因此下一代的 layout 系统会进行重构,使得分层更加清晰。
然后进入 paint 阶段。需要注意的是这一步并不是真的绘制,只是生成对应的指令。对于每个 LayoutObject,浏览器会生成一个列表,列表中的每一项记录着绘制指令(比如画个红色的矩形)。记住这个待绘制列表项,后面会出现很多次。绘制按照堆栈也就是 z 轴的顺序在多个阶段进行。每个阶段只根据当前元素对应的属性(background->floats->foregrounds->outlines)进行绘制。注意,绘制并不严格是按照上述四种元素属性顺序,此处只作举例说明。
下面就进入 raster 阶段,中文名为栅格化。栅格化的操作将上一步 paint 阶段每个 LayoutObject 存储的绘制指令列表中的每一项转换为颜色值的位图。位图中的每一项存储着 RGBA 值,对应着一个像素。位图存在于 GPU 内存中,还没有显示到屏幕上。GPU 除了用来存位图信息,还能执行生成位图的命令,也就是说栅格化过程可通过 GPU 进行,Chrome 默认开启 GPU 栅格化。GPU 栅格化的过程如下:浏览器调用 Skia 库,Skia 库对绘制指令建立单独的缓冲区以进行指令的转译处理,这一过程结束后缓冲区内容被释放输出并生成 OpenGL 调用。至此,这些 OpenGL 调用还存在于渲染沙箱进程,需要通过命令缓冲区机制代理传输到 GPU 进程执行。使用 GPU 进程的原因一是需要绕过渲染器沙箱的限制,二是将 OpenGL 程序如果不稳定或有安全漏洞,隔离开使其不至于影响浏览器的稳定性。在未来演进上,栅格化处理将转移至 GPU 进程中进行,以提升性能。同时 Vulkan 也会被支持。(注:Skia 是一个独立的图形处理函数库,其对硬件做了一层抽象,可以执行一系列相对底层 OpenGL 更复杂的指令。OpenGL 是跨语言跨平台的系统级绘图API。Vulkan 是下一代的绘图 API,旨在替代 OpenGL。)
以上过程揭示了静态渲染,也就是从 web content 到内存中的像素的整个流程。但是实际过程中页面是不断更新的,包括滚动、动画、js 等都会改变页面内容。一个完整的渲染过程是很昂贵的,如何高效更新也是讨论的重点。
动态更新过程
首先明确一个概念,帧。涉及到时间时,每一帧是当前 Web 内容的完整呈现,通常,如果每秒低于 60 帧,滚动和动画就会显得有些卡顿。
第一个优化方向最容易想到,即跟踪改变的部分,复用没有改变的部分。因此针对第一部分提到的 style、layout、paint、raster,浏览器都做了精细化跟踪失效的处理,每一帧都会复用前一帧没有变化的部分,只有被标记了需要变更的部分才会进行重新处理。
由于 JS 和渲染都存在于主线程中,因此如果 JS 占据主线程做了耗时的操作,即使渲染很快,页面看起来仍然是比较卡顿的。所以这又引出了下一个优化点,compositing,中文名合成。
合成包含两个概念,一是将页面分解成多个 layer,二是将这些 layer 在另一个线程中合成。layer 类似 PS 中图层的概念,可以独立于其他 layer 进行变换和栅格化。开发者工具中对其也有直观的展示。合成线程需要能够处理用户可能导致页面发生变化的输入事件比如(变换、剪切、滚动、特效),因为这些操作涉及了复合图层的改变。这样可以和主线程执行 js 互不干扰。但是当合成线程无法处理某个输入事件时,还是会由主线程来处理。layer 的存储依然是通过树形结构实现。合成更新是新出现的生命周期,出现在 layout 之后 paint 之前。每个 layer 都被单独绘制,因此其也有属于自己的绘制指令列表。未来,Chrome 可能会将合成图层生命周期放到 paint 后面。
主线程的绘制阶段完成后,主线程上的 layer tree 将会被复制到合成线程上,合成完毕后再返回主线程。整个过程类似 git 中分支代码的合并。
合成线程中,在对图层进行栅格化之前,还会有一步 tiling 的操作,也就是将 layer 拆分为多个小图块(tile),目的是为了防止出现某些情况下,某个滚动 layer 很长,但实际只需要展示当前容器内的一小块,如果整个 layer 进行栅格化将会比较浪费资源。复杂管理分块的模块叫 tile manager,它会随着滚动区域的变化,优先创建相邻的图块。所有图块栅格化完成后,合成线程将绘制 quads(四边形绘制)。一个 quad 类似于在屏幕上绘制一个图块的指令,其引用在内存中生成的栅格图块,然后被封装,由渲染进程提交到浏览器进程,这些就是每个动画帧。
这里为了实现可以一边可以执行前一个提交的图块绘制任务,一边继续等待新的任务,合成线程还做了一些优化,实现了一个 pending layer tree。其接收 commit,当其准备好绘制后,会被激活(activation)从而复制到 active layer tree 上进行绘制任务。
浏览器拿到渲染进程发来的动画帧之后,结合非内容区的其他渲染进程(比如浏览器 UI),调用 OpenGL 指令绘制最终的画面。
总结
最后还是这张图,快速过一下每个步骤,web 内容、生成 DOM 树、解决样式问题、更新布局、生成合成图层、把图层绘制到待显示项列表中、把图层树提交给合成线程、把图层切分为小图块、对图块进行栅格化操作、把 pending layer tree 复制到 active layer tree、把树绘制成 quads、提交 quad 到浏览器进程、通过 GPU 进程调用 GL 指令绘制像素至屏幕上。
以上,就是一个像素的一生奇妙之旅。