2

Webkit 渲染基础与硬件加速

当浏览器加载一个 html 文件并对它进行解析完毕后,内核就会生成一个极为重要的数据结构即 DOM 树,树上每一个节点都对应着网页里面的某一个元素,并且开发人员也可以通过 JavaScript 操作这棵 DOM 树动态改变它的结构,但是 DOM 树本身并不能直接用于排版和渲染。

浏览器中页面的渲染过程可以简化为以下五个步骤:

image_1c82b7ng61nkqhsktmm19t5hsnm.png-59.6kB

从 DOM 到 RenderObject

在 DOM 树构建完成之后,Webkit 所要做的事情就是为 DOM 树节点构建 RenderObject 树,一个 RenderObject 对象保存为绘制 DOM 节点所需要的各种信息,从以下这些规则出发会为 DOM 树节点创建 RenderObject 对象:

  • DOM 树的 document 节点
  • DOM 树中的可视节点
  • 某些情况下 Webkit 需要建立匿名的 RenderObject 节点,这样的节点不对应于 DOM 树中的任何节点,而是 Webkit 处理上的需要,典型的例子就是匿名用于表示块元素的 RenderBlock 节点
  • 虽然 Javascript 无法访问影子节点,但是需要为其创建并渲染 RenderObject
Tip:由此可见,网上有人说浏览器渲染的步骤中包含“将 DOM 树和 CSSOM 树合并为 Render 树”的说法是有些问题的。CSSOM(CSS 对象模型)是用于提供方法可以让开发者自定义一些脚本来操作其样式状态的,它的思想是在 DOM 中的一些节点接口中加入获取和操作 CSS 属性或者接口的 Javascript 接口,便于 Javascript 可以动态操作 CSS 样式。

由此我们可以知道 RenderObject 树和 DOM 树不是一一对应的!我们可以简单的认为,RenderObject 是衔接浏览器排版引擎和渲染引擎之间的桥梁,它是排版引擎的输出和渲染引擎的输入。当 Webkit 创建 RenderObject 对象之后,每个对象是不知道自己的位置、大小等信息的,Webkit 根据框模型来计算它们的位置、大小等信息的过程称为布局计算

Tip:从整个网页的加载和渲染过程来看,CSS 解释器和规则匹配处于 DOM 树建立之后,RenderObject 树建立之前,CSS 解释器解释后的结果会保存起来,然后 RenderObject 树基于该结果来进行规范匹配和布局计算。

既然已经实现绘制每个 DOM 节点的方法,那是不是可以开辟一段位图空间,然后 DFS 遍历这个 RenderObject 树执行绘制方法,就像“盖章”一样把每个 RenderObject 的内容一个个的盖到“画布上”,是不是就足够完成绘制?

如果没有层叠上下文,到这儿就可以结束了!

实际上是如果没有 Positioning,Clipping,Overflow Scroll,CSS Transform/Opacity/Animation/Filter,Mask or Reflection,Z Indexing etc. 到这儿就可以结束了……

从 RenderObject 到 RenderLayer

Webkit 会为网页的层次创建相应的 RenderLayer 对象,当某些类型的 RenderObject 的节点或者具有某些 CSS 样式的 RenderObject 节点出现的时候,Webkit 就会为这些节点创建 RenderLayer 对象,一般来说某个 RenderObject 节点的后代都属于该节点的 RenderLayer,除非 Webkit 根据规则为某个后代 RenderObject 节点创建一个新的 RenderLayer 对象,以下是 RenderObject 节点需要建立新的 RenderLayer 节点的规则:

  • DOM 树的 document 节点对应的 RenderView 节点
  • DOM 树中 document 的子女节点,即 html 节点对应的 RenderBlock 节点
  • 显示指定 CSS 位置的 RendrObject 节点
  • 有透明效果的 RenderObject 节点
  • 节点有溢出(overflow)、alpha 或者反射等效果的 RenderObject 节点
  • 适用 canvas 2d 或者 3d(WebGL)技术的 RenderObject 节点
  • video 节点对应的 RenderObject 节点
由此我们可以知道,RenderLayer 节点和 RenderObject 节点不是一一对应的,而是一对多的关系。

具体来说,根据创建 RenderLayer 的原因不同可以将其分为常见的 3 类:

NormalPaintLayer
  • 根元素(html)
  • 有明确的定位属性(relative、fixed、sticky、absolute)
  • 透明的(opacity 小于 1)
  • 有 CSS 滤镜(fliter)
  • 有 CSS mask 属性
  • 有 CSS mix-blend-mode 属性(不为 normal)
  • 有 CSS transform 属性(不为 none)
  • backface-visibility 属性为 hidden
  • 有 CSS reflection 属性
  • 有 CSS column-count 属性(不为 auto)
  • 有 CSS column-width 属性(不为 auto)
  • 当前有对于 opacity、transform、fliter、backdrop-filter 的应用动画
OverflowClipPaintLayer
  • overflow 不为 visible
NoPaintLayer
  • 不需要 paint 的 RenderLayer:比如一个没有视觉属性(背景、颜色、阴影等)的空 div

上文中讲解的从 DOM 到 RenderObject 以及从 RenderObject 到 RenderLayer 可以归纳如下图:

image_1c82ab03815321e7p1ugc7m419.png-89.1kB

软件渲染和硬件加速渲染

在 Webkit 中绘图操作被定义为一个抽象层即绘图上下文,所有绘图操作都是在该上下文中进行,可以分为两种类型:2d 图形上下文和 3d 图形上下文。其中 2d 图形上下文的具体作用就是提供基本绘图单元的绘制接口以及设置绘图的样式,绘图接口包括画点、画线、画图片、画多边形、画文字 etc.,绘图样式包括颜色、线宽、字号大小、渐变 etc.,而RenderObject 对象知道自己需要画什么样的点,什么样的图片。3d 绘图上下文的主要用处是支持 CSS3D、WebGL etc.。

网页的渲染方式主要有两种:软件渲染和硬件加速渲染。每个 RenderLyaer 对象都可以被想象成一个层,各个层一同构成一个图像,在渲染过程中,每个层对应网页中的一个或者一些可视元素,这些元素都绘制内容到该层上,如果这些绘图操作由 CPU 莱完成则称之为软件绘图,如果这些绘图操作由 GPU 来完成则称之为硬件加速绘图。理想情况下,每个层都有绘制的存储区域来保存绘图的结果,最后需要将这些层的内容合并到同一个图像中的过程称为合成(compositing),使用合成技术的渲染叫做合成化渲染

对于软件渲染机制,Webkit 需要使用 CPU 来绘制每层的内容,然而该机制是没有合成阶段的:在软件渲染中通常其结果就是一个位图(Bitmap),绘制每一层时都使用同一个位图,区别在于绘制的位置看你不一样,每一层都按照从后到前的顺序。而使用合成化的渲染技术,以使用软件绘图的合成化渲染为例,对于使用 CPU 绘制的层,其结果保存在 CPU 内存中,之后传输到 GPU 中进行合成。

对于常见的 2d 绘图操作,使用 GPU 来绘图不一定比使用 CPU 绘图在性能上有优势,原因是 CPU 使用缓存机制有效减少重复绘制得开销,而且不需要 GPU 的并行,并且 GPU 的内存资源相对 CPU 的内存资源更加紧张。
什么是位图

image_1c84k71cv1uo61vhafc9b6b1kh7m.png-37.1kB

在绘制出一个图片我们应该怎么做,显然首先是把这个图片表示为一种计算机能理解的数据结构:用一个二维数组,数组的每个元素记录这个图片中的每一个像素的具体颜色。所以浏览器可以用位图来记录它想在某个区域绘制的内容,绘制的过程也就是往数组中具体的下标里填写像素而已。

什么是纹理

纹理其实就是 GPU 中的位图,存储在 GPU video RAM 中。前面说的位图里的元素存什么我们自己定义好就行(是用3字节存256位rgb还是1个bit存黑白自己定义即可),但纹理是 GPU 专用的,需要有固定格式便于兼容与处理,所以一方面纹理的格式比较固定,如 R5G6B5、A4R4G4B4 等像素格式, 另外一方面 GPU 对纹理的大小有限制,比如长/宽必须是2的幂次方,最大不能超过2048或者4096等。

什么是光栅化

image_1c84kb5l6vk81jmhr8a5d0a6013.png-115.4kB

在纹理里填充像素不是那么简单的自己去遍历位图里的每个元素然后填写这个像素的颜色即可,光栅化的本质是坐标变换、几何离散化,然后再填充

同时,光栅化从早期的 Full-screen Rasterization 基本都进化到现在的 Tile-Based Rasterization,也就是不对整个图像做光栅化,而是把图像分块后再对每个 tile 单独光栅化。光栅化完成后将像素填充进纹理,再将纹理上传至 GPU,
原因一方面如上文所说,纹理大小有限制,即使整屏光栅化也是要填进小块小块的纹理中,不如事先根据纹理大小分块光栅化后再填充进纹理里;另一方面是为了减少内存占用(整屏光栅化意味着需要准备更大的buffer空间)和降低总体延迟(分块栅格化意味着可以多线程并行处理)。

每秒60帧的动效里,每次变动都重绘整个位图是很恐怖的性能开销!
非合成加速的渲染架构,所有的 RenderLayer 都没有自己独立的缓存,它们都按照先后顺序被绘制到同一个缓存里面,所以只要这个 RenderLayer 触发重绘,变化区域的缓存就需要重新生成,此时不但需要绘制发生变化的 RenderLayer,跟变化区域(Damage Region)相交的其它 RenderLayer 也需要被绘制。

浏览器本身并不能直接改变屏幕的像素输出,它需要通过系统本身的 GUI Toolkit,所以一般来说浏览器会将一个要显示的网页包装成一个 UI 组件,通常叫做 WebView,然后通过将 WebView 放置于应用的 UI 界面上,从而将网页显示在屏幕上。

默认的情况下 UI 组件没有自己独立的位图缓存,构成 UI 界面的所有 UI 组件都直接绘制在当前的窗口缓存上,所以 WebView 每次绘制就相当于将它在可见区域内的 RenderLayer/RenderObject 逐个绘制到窗口缓存上。上述的渲染方式有一个很严重的问题,用户拖动网页或者触发一个惯性滚动时,网页滑动的渲染性能会十分糟糕:这是因为即使网页只移动一个像素,整个 WebView 都需要重新绘制。

要提升网页滑屏的性能,一个简单的做法就是让 WebView 本身持有一块独立的缓存,而 WebView 的绘制就分成了两步: 1) 根据需要更新内部缓存,将网页内容绘制到内部缓存里面 2) 将内部缓存拷贝到窗口缓存上。第一步我们通常称为绘制(Paint)或者光栅化(Rasterization),它将一些绘图指令转换成真正的像素颜色值,而第二步我们一般称为合成(Composite),它负责缓存的拷贝,同时还可能包括位移(Translation),缩放(Scale),旋转(Rotation),Alpha 混合等操作。

从 RenderLayer 到 GraphicsLayer

在现实情况中,由于硬件能力和资源有限,为了节省 GPU 的内存资源,硬件加速机制在 RenderLayer 树建立之后需要做三件事情来完成网页的渲染:

  1. Webkit 决定将哪些 RendeLayer 对象组合在一起,形成一个由后端存储(一般指 GPU 内存)的新层,对于一个 RenderLayer 对象来说,如果它没有后端存储的新层,那么就使用其父亲所使用的合成层
  2. 将每个合成层包含的 RenderLayer 内容绘制在其后端存储中,这里的绘制可以是软件绘制,也可以是硬件加速绘制
  3. 由合成器将多个合成层合成起来,形成网页的最终可视化结果(实际就是一张图片)

一个 RenderLayer 对象如果需要后端存储,它会创建一个 RenderLayerBacking 对象,负责 RenderLayer 对象所需要的各种存储,每个 RenderLayer 对象都可以创建自己的后端存储,然而不是所有 RenderLayer 对象都有自己的 RenderLayerBacking,如果一个 RenderLayer 对象被 Webkit 按照一定的规则创建后端存储,那么该层被称为合成层,后端存储可能需要管理多个存储空间,使用 GraphicsLayer 类来表示。

每个 GraphicsLayer 都拥有一个 GraphicsContext,用于为该 GraphicsLayer 开辟一段位图,也就意味着每个 GraphicsLayer 都拥有一个独立的位图,GraphicsLayer 负责将自己的 RenderLayer 及其所包含的 RenderObject 绘制到位图里,然后将位图作为纹理交给 GPU 进行合成。如果一个 RenderLayer 对象具有以下特征之一,那么它就是合成层:

  • RenderLayer 具有 CSS3D 属性或者 CSS 透视效果
  • RenderLayer 包含 video 节点对应的 RenderObject 节点
  • RenderLayer 包含使用 canvas 2d 或者 3d(WebGL)技术的 RenderObject 节点
  • RenderLayer 使用 CSS 透明效果的动画或者 CSS 变换动画
  • RenderLayer 使用硬件加速的 CSS Filters 技术
  • RenderLayer 使用裁剪或者反射属性,并且其后代包含合成层
  • RenderLayer 有一个 Z 坐标比自己小的兄弟节点,且该兄弟节点是一个合成层
直接原因
  • 硬件加速的 iframe 元素(比如 iframe 嵌入的页面中有合成层)
  • video 元素
  • 覆盖在 video 元素上的视频控制栏
  • 3D 或者 硬件加速的 2D Canvas 元素
  • 硬件加速的插件:比如 flash etc.
  • 在 DPI 较高的屏幕上 fix 定位的元素会自动地被提升到合成层中;但在 DPI 较低的设备上却并非如此:因为这个渲染层的提升会使得字体渲染方式由子像素变为灰阶
  • 有 3D transform
  • backface-visibility 为 hidden
  • 对 opacity、transform、fliter、backdropfilter 应用 animation 或者 transition(需要是 active 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提升合成层也会失效)
  • will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性:比如 relative etc.)
后代元素原因
  • 有合成层后代同时本身有 transform、opactiy(小于 1)、mask、fliter、reflection 属性
  • 有合成层后代同时本身 overflow 不为 visible(如果本身是因为明确的定位因素产生的 SelfPaintingLayer,则需要 z-index 不为 auto)
  • 有合成层后代同时本身 fixed 定位
  • 有 3D transfrom 的合成层后代同时本身有 preserves-3d 属性
  • 有 3D transfrom 的合成层后代同时本身有 perspective 属性
重叠原因

重叠或者说部分重叠在一个合成层之上,最常见和容易理解的就是元素的 border box(content + padding + border) 和合成层的有重叠,其他的还有一些不常见的情况,也算是同合成层重叠的条件如下:

  • filter 效果同合成层重叠
  • transform 变换后同合成层重叠
  • overflow scroll 情况下同合成层重叠

假设重叠在一个合成层之上,其实也比较好理解,比如一个元素的 CSS 动画效果在运行期间,元素有可能和其他元素发生重叠的情况,需要注意的是该原因下,有一个很特殊的情况:如果合成层有内联的 transform 属性,会导致其兄弟渲染层假设重叠从而提升为合成层。

基本上常见的一些合成层的提升原因如上所说,我们会发现:由于重叠的原因可能随随便便就会产生出大量合成层来,而每个合成层都要消耗 CPU 和内存资源,岂不是严重影响页面性能?!

层压缩

这一点浏览器也考虑到,因此就有层压缩(Layer Squashing)的处理。如果多个渲染层同一个合成层重叠时,这些渲染层会被压缩到一个 GraphicsLayer 中,以防止由于重叠原因导致可能出现的“层爆炸”。

当然,浏览器的自动层压缩也不是万能的,在很多特定情况下,浏览器是无法进行层压缩的,而这些情况也是我们应该尽量避免的(以下情况都是基于重叠原因而言):

  • 无法进行会打破渲染顺序的压缩
  • video 元素的渲染层无法被压缩,同时也无法将别的渲染层压缩到 video 所在的合成层上
  • iframe、plugin 的渲染层无法被压缩,同时也无法将别的渲染层压缩到其所在的合成层上
  • 无法压缩有 reflection 属性的渲染层
  • 无法压缩有 blend mode 属性的渲染层
  • 当渲染层同合成层有不同的裁剪容器时,该渲染层无法压缩
  • 相对于合成层滚动的渲染层无法被压缩
  • 当渲染层同合成层有不同的具有 opacity 的祖先层(一个设置 opacity 且小于 1 一个没有设置 opacity 也算是不同)时,该渲染层无法压缩
  • 当渲染层同合成层有不同的具有 transform 的祖先层时,该渲染层无法压缩
  • 当渲染层同合成层有不同的具有 filter 的祖先层时,该渲染层无法压缩
  • 当覆盖的合成层正在运行动画时,该渲染层无法压缩,只有在动画未开始或者运行完毕以后,该渲染层才可以被压缩

多线程

进一步来说,浏览器还可以使用多线程的渲染架构,将网页内容绘制到缓存的操作放到另外一个独立的线程(绘制线程),而原来线程对 WebView 的绘制就只剩下缓存的拷贝(合成线程),绘制线程跟合成线程之间可以使用同步,部分同步,完全异步等作业模式,让浏览器可以在性能与效果之间根据需要进行选择。

Main thread or WebKit/Blink thread

内核线程 - 负责解析,排版,Render 树绘制,JavaScript 执行等任务,它有可能执行真正的网页内容的光栅化,也有可能只是纪录绘制指令,由独立的光栅化线程执行

Rasterize thread

光栅化线程 - 如果内核线程只负责将网页内容转换为绘图指令列表,则真正的光栅化(执行绘图指令计算出像素的颜色值)由独立的光栅化线程完成

Compositor thread

合成线程 - 负责将网页内部位图缓存/纹理输出到窗口的帧缓存,从而把网页显示在屏幕上,但是在使用 GPU 合成的情况下,也有可能只是产生 GL 绘图指令,然后将绘图指令的缓存发送给 GPU 线程执行

GPU thread

GPU 线程 - 如果使用 GPU 合成,则由 GPU 线程负责执行 GL 绘图指令,访问 GPU,可能跟合成线程是同一个线程,也有可能是独立的线程(合成线程产生GL指令 GPU 线程执行)

Browser UI thread

浏览器 UI 线程,如果跟 GPU 线程不是同一个线程,则只负责外壳的绘制,如果跟 GPU 线程是同一个线程,则同时负责绘制外壳的UI界面,和网页的合成输出,到窗口帧缓存

image_1c84d20k41v5kia34ig1kk71aro9.png-146.9kB

重排&重绘

重排和重绘是老生常谈的东西,大家也应该非常熟悉,但在这里可以结合浏览器机制顺带讲一遍。

重排

首先,如果你改变一个影响元素布局信息的 CSS 样式:比如 width、height、left、top etc.(transform除外),那么浏览器会将当前的 Layout 标记为 dirty,这会使得浏览器在下一帧执行重排,因为元素的位置信息发生改变将可能会导致整个网页其他元素的位置情况都发生改变,所以需要执行 Layout 全局重新计算每个元素的位置。

需要注意到,浏览器是在下一帧、下一次渲染的时候才重排,并不是 JS 执行完这一行改变样式的语句之后立即重排,所以你可以在 JS 语句里写 100 行修改 CSS 的语句,但是只会在下一帧的时候重排一次。

会触发重排的属性和方法如下:

Element

clientHeight, clientWidth, clientTop, clientLeft, focus(), getBoundingClientRect(), getClientRects(), innerText, offsetHeight, offsetLeft, OffsetParent, offsetTop, offsetWidth, outerText, scrollByLines(), scrollByPages(), scrollHeight, scrollIntoView(), scrollIntoViewIfNeeded(), scrollLeft, scrollTop, scrollWidth

Frame, Image

height, width

Range

getBoundingClientRect(), getClientRects()

SVGLocatable

computeCTM(), getBBox()

SVGTextContent

getCharNumAtPosition(), getComputedTextLength(), getEndPositionOfChar(), getExtentOfChar(), getNumberOfChars(), getRotationOfChar(), getStartPositionOfChar(), getSubStringLength(), selectSubString()

SVGUse

instanceRoot

window

getComputedStyle(), scrollBy(), scrollTo(), scrollX, scrollY, webkitConvertPointFromNodeToPage(), webkitConvertPointFromPageToNode()

强制重排

如果你在当前 Layout 被标记为 dirty 的情况下访问 offsetTop、scrollHeight 等属性,那么浏览器会立即重新 Layout,计算出此时元素正确的位置信息,以保证你在 JS 里获取到的 offsetTop、scrollHeight 等是正确的。

这一过程被称为强制重排 Force Layout,强制浏览器将本来在渲染流程中才执行的 Layout 过程提前至 JS 执行过程中,每次当我们在 Layout 为 dirty 时访问会触发重排的属性都会 Force Layout,这会极大延缓 JS 的执行效率

另外,每次重排或者强制重排后,当前 Layout 就不再 dirty,这时再访问 offsetWidth 之类的属性并不会再触发重排。

重绘

重绘也是相似的,一旦你更改某个元素的会触发重绘的样式,那么浏览器就会在下一帧的渲染步骤中进行重绘(也即一些介绍重绘机制中说的 invalidating),JS 更改样式导致某一片区域的样式作废,从而在一下帧中重绘 invalidating 的区域。

但是!有一个非常关键的行为就是:重绘是以合成层为单位的,也即 invalidating 的既不是整个文档也不是单个元素,而是这个元素所在的合成层。当然这也是将渲染过程拆分为 Paint 和 Compositing 的初衷之一:

Since painting of the layers is decoupled from compositing, invalidating one of these layers only results in repainting the contents of that layer alone and recompositing.

使用 transform 或者 opacity 来实现动画效果

修改一些 CSS 属性如 width、float、border、position、font-size、text-align、overflow-y etc. 会触发重排、重绘和合成,修改另一些属性如 color、background-color、visibility、text-decoration etc. 则不会触发重排,只会重绘和合成。

image_1c84lrbps1ntk1hjl10eg9371sfp1g.png-49.3kB

接下来很多文章里就会说,修改 opacity、transform 这两个属性仅仅会触发合成,不会触发重绘,所以一定要用这两个属性来实现动画,没有重绘重排,效率很高……然而事实并不是这样,只有一个元素在被提升为合成层之后,上述情况才成立
最后一句话:合成层提升并非银弹!

10081677wc
272 声望14 粉丝