浏览器渲染详细过程:重绘、重排和 composite 只是冰山一角
https://juejin.im/entry/590801780ce46300617c89b8
渲染
这张很经典的图许多人都看过,其中的概念大家应该都很熟悉,也就是这么几个步骤:
js修改dom结构或样式 -> 计算style -> layout(重排) -> paint(重绘) -> composite(合成)
但是其中有更复杂的内容,我们从更底层来详细说明这个过程,主要是下面这两幅图:
上图出自GPU Accelerated Compositing in Chrome
上图出自The Anatomy of a Frame
这部分内容基于blink、webkit内核,但是其中涉及到的重排、重绘、composite和合成层提升等环节对于各大浏览器都是一致的。
先说一些概念纹理
-
纹理其实就是GPU中的位图,存储在GPU video RAM中。
-
Rasterize(光栅化)
绘制的具体过程
我们先把计算样式、重排等步骤抽离,单独讲解浏览器是怎么绘制的。
先来看这幅经典的图:
图中一些名词的称呼发生了变化,详见taobaofed的文章:无线性能优化:Composite
Render Object
首先我们有DOM树,但是DOM树里面的DOM是供给JS/HTML/CSS用的,并不能直接拿过来在页面或者位图里绘制。因此浏览器内部实现了Render Object:
每个Render Object和DOM节点一一对应。Render Object上实现了将其对应的DOM节点绘制进位图的方法,负责绘制这个DOM节点的可见内容如背景、边框、文字内容等等。同时Render Object也是存放在一个树形结构中的。
既然实现了绘制每个DOM节点的方法,那是不是可以开辟一段位图空间,然后DFS遍历这个新的Render Object树然后执行每个Render Object的绘制方法就可以将DOM绘制进位图了?就像“盖章”一样,把每个Render Object的内容一个个的盖到纸上(类比于此时的位图)是不是就完成了绘制。
不,浏览器还有个层叠上下文。就是决定元素间相互覆盖关系(比如z-index)的东西。这使得文档流中位置靠前位置的元素有可能覆盖靠后的元素。上述DFS过程只能无脑让文档流靠后的元素覆盖前面元素。
因此,有了Render Layer。
Render Layer
当然Render Layer的出现并不是简单因为层叠上下文等,比如opacity小于1、比如存在mask等等需要先绘制好内容再对绘制出来的内容做一些统一处理的css效果。
总之就是有层叠、半透明等等情况的元素(具体哪些情况请参考无线性能优化:Composite)就会从Render Object提升为Render Layer。不提升为Render Layer的Render Object从属于其父级元素中最近的那个Render Layer。当然根元素HTML自己要提升为Render Layer。
因此现在Render Object树就变成了Render Layer树,每个Render Layer又包含了属于自己layer的Render Object。
另外:
The children of each RenderLayer are kept into two sorted lists both sorted in ascending order, the negZOrderList containing child layers with negative z-indices (and hence layers that go below the current layer) and the posZOrderList contain child layers with positive z-indices (layers that go above the current layer).
每个Render Layer的子Render Layer都是按照升序排列存储在两个有序列表当中的:negZOrderList存储了负z-indicices的子layers,posZOrderList存储了正z-indicies的子layers。
— 出自GPU加速的compositing一文
现在浏览器渲染引擎遍历 Layer 树,访问每一个 RenderLayer,然后递归遍历negZOrderList里的layer、自己的RenderObject、再递归遍历posZOrderList里的layer。就可以将一颗 Layer树绘制出来。
Layer 树决定了网页绘制的层次顺序,而从属于 RenderLayer 的 RenderObject 决定了这个 Layer 的内容,所有的 RenderLayer 和 RenderObject 一起就决定了网页在屏幕上最终呈现出来的内容。
层叠上下文、半透明、mask等等问题通过Render Layer解决了。那么现在:
开辟一个位图空间->不断的绘制Render Layer、覆盖掉较低的Layer->拿给GPU显示出来 是不是就完全ok了?
不。还有GraphicsLayers和Graphics Context
Graphics Layer(又称Compositing Layer)和Graphics Context
上面的过程可以搞定绘制过程。但是浏览器里面经常有动画、video、canvas、3d的css等东西。这意味着页面在有这些元素时,页面显示会经常变动,也就意味着位图会经常变动。每秒60帧的动效里,每次变动都重绘整个位图是很恐怖的性能开销。
因此浏览器为了优化这一过程。引出了Graphics Layers和Graphics Context,前者就是我们常说的合成层(Compositing Layer):
某些具有CSS3的3D transform的元素、在opacity、transform属性上具有动画的元素、硬件加速的canvas和video等等,这些元素在上一步会提升为Render Layer,而现在他们会提升为合成层Graphics Layer(你如果查看了前文我给的链接,你当时可能会疑惑为什么这些情况也能提升为Render Layer,现在你应该明白了,他们是为提升为Graphics Layer准备的)。每个Render Layer都属于他祖先中最近的那个Graphics Layer。当然根元素HTML自己要提升为Graphics Layer。
Render Layer提升为Graphics Layer的情况:
- 3D 或透视变换(perspective、transform) CSS 属性
- 使用加速视频解码的 元素
- 拥有 3D (WebGL) 上下文或加速的 2D 上下文的 元素
- 混合插件(如 Flash)
- 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition(需要是 active 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提升合成层也会失效)
- will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性,如 relative 等)
- 拥有加速 CSS 过滤器的元素
- 元素有一个 z-index 较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)
- ….. 所有情况的详细列表参见淘宝fed文章:无线性能优化:Composite
3D transform、will-change设置为 opacity、transform等 以及 包含opacity、transform的CSS过渡和动画 这3个经常遇到的提升合成层的情况请重点记住。
另外除了上述直接导致Render Layer提升为Graphics Layer,还有下面这种因为B被提升,导致A也被隐式提升的情况,详见此文: GPU Animation: Doing It Right
每个合成层Graphics Layer 都拥有一个 Graphics Context,Graphics Context 会为该Layer开辟一段位图,也就意味着每个Graphics Layer都拥有一个位图。Graphics Layer负责将自己的Render Layer及其子代所包含的Render Object绘制到位图里。然后将位图作为纹理交给GPU。所以现在GPU收到了HTML元素的Graphics Layer的纹理,也可能还收到某些因为有3d transform之类属性而提升为Graphics Layer的元素的纹理。
现在GPU需要对多层纹理进行合成(composite),同时GPU在纹理合成时对于每一层纹理都可以指定不同的合成参数,从而实现对纹理进行transform、mask、opacity等等操作之后再合成,而且GPU对于这个过程是底层硬件加速的,性能很好。最终,纹理合成为一幅内容最终draw到屏幕上。
所以在元素存在transform、opacity等属性的css animation或者css transition时,动画处理会很高效,这些属性在动画中不需要重绘,只需要重新合成即可。
上述分层后合并的过程可以用一张图来描述:
绘制的具体实现
系统结构
进程
blink和webkit引擎内部都是使用了两个进程来搞定JS执行、页面渲染之类的核心任务。
- Renderer进程
主要的那个进程,每个tab一个。负责执行JS和页面渲染。包含3个线程:Compositor Thread、Tile Worker、Main thread,后文会介绍这三个线程。 - GPU进程
整个浏览器共用一个。主要是负责把Renderer进程中绘制好的tile位图作为纹理上传至GPU,并调用GPU的相关方法把纹理draw到屏幕上(一般的介绍浏览器渲染引擎的文章里都用paint这个词表述把内容光栅化和绘制到位图里,而用draw这个词表示GPU最终把纹理显示到屏幕上),所以这个CPU里的进程更应该称为“负责跟GPU打交道的进程”,不要像我之前一样因为不懂GPU以为是GPU里的一个进程, mdzz。GPU进程里只有一个线程:GPU Thread。
Renderer进程的三个线程
- Compositor Thread
这个线程既负责接收浏览器传来的垂直同步信号(Vsync,水平同步表示画出一行屏幕线,垂直同步就表示从屏幕顶部到底部的绘制已经完成,指示着前一帧的结束,和新一帧的开始), 也负责接收OS传来的用户交互,比如滚动、输入、点击、鼠标移动等等。
如果可能,Compositor Thread会直接负责处理这些输入,然后转换为对layer的位移和处理,并将新的帧直接commit到GPU Thread,从而直接输出新的页面。否则,比如你在滚动、输入事件等等上注册了回调,又或者当前页面中有动画等情况,那么这个时候Compositor Thread便会唤醒Main Thread,让后者去执行JS、完成重绘、重排等过程,产出新的纹理,然后Compositor Thread再进行相关纹理的commit至GPU Thread,完成输出。 - Main Thread
这里大家就很熟悉了,chrome devtools的Timeline里Main那一栏显示的内容就是Main Thread完成的相关任务:某段JS的执行、Recalculate Style、Update Layer Tree、Paint、Composite Layers等等。 - Compositor Tile Worker(s)
可能有一个或多个线程,比如PC端的chrome是2个或4个,安卓和safari为1个或2个不等。是由Compositor Thread创建的,专门用来处理tile的Rasterization(前文说过的光栅化)。
可以看到Compositor Thread是一个很核心的东西,后面的俩线程都是由他主要进行控制的。
同时,用户输入是直接进入Compositor Thread的,一方面在那些不需要执行JS或者没有CSS动画、不重绘等的场景时,可以直接对用户输入进行处理和响应,而Main Thread是有很复杂的任务流程的。这使得浏览器可以快速响应用户的滚动、打字等等输入,完全不用进主线程。这里也有一个非常重要的点,后文会说。
再者,即使你注册了UI交互的回调,进了主线程,或者主线程很卡,但是因为Compositor Thread在他外面拦着,所以Compositor Thread依然可以直接负责将下一帧输出到页面上,因此即使你的主线程可能执行着高耗任务,超过16ms,但是你在滚动页面时浏览器还是能做出响应的(同步AJAX等特殊任务除外),所以比如你有一个比较卡的动画(动画的预先计算过程或者重绘过程超过16ms每帧),但是你滚动页面是非常流畅的,也就是动画卡而滚动不卡(随便给你个demo自己试试看)。
具体流程
一般我们在devtools的Timeline里大概会看到如下过程:
也就是JS执行后触发重绘重排等操作。这里着重分析背后的运行过程,即下面这副图:
图里后半部分有两处commit,分别是主线程通知Main Thread可以执行光栅化了,以及光栅化完成、纹理生成完毕,Compositor Thread通知GPU Thread可以将纹理按照指定的参数draw到屏幕上。
整体流程:
- Vsync
接收到Vsync信号,这一帧开始 -
Input event handlers
之前Compositor Thread接收到的用户UI交互输入在这一刻会被传入给主线程,触发相关event的回调。All input event handlers (touchmove, scroll, click) should fire first, once per frame, but that’s not necessarily the case; a scheduler makes best-effort attempts, the success of which varies between Operating Systems.
这意味着,尽管Compositor Thread能在16ms内接收到OS传来的多次输入,但是触发相应事件、传入到主线程被JS感知却是每帧一次,甚至可能低于每帧一次。也就是说touchmove、mousemove等事件最快也就每帧执行一次,所以自带了相对于动画的节流效果!如果你的主线程有动画之类的卡了一点,事件触发频率非常可能低于16ms。我在最开始关于渲染时机的内容中说了scroll和resize因为和渲染处于同一轮次,所以最快也就每帧执行一次,现在来看,不仅仅是scroll和resize!连touchmove、mousemove等事件,由于Compositor Thread的机制原因,也依然如此!
详见这个jsfiddle,大家可以试试,你可以发现mousemove回调和requestAnimationFrame回调的调用频率是完全一致的,mousemove的执行次数跟raf执行次数一模一样,永远没有任何一次出现mousemove执行两次而rAF还没有执行一次的情况发生。另外两次执行间隔在14到20毫秒之间,主要是因为帧的间隔不会精确到16.666毫秒哈,基本是14ms~20ms之间大致波动的,大家可以打开timeline观察。另外有个挺奇怪的现象是每次鼠标从devtool移回页面区域里的时候,会非常快的触发两次mousemove(间隔有时小于5ms),虽然依然每次mousemove后依然紧跟raf,这意味着非常快速的触发了两帧。 - requestAnimationFrame
图中的红线的意思是你可能会在JS里Force Layout,也就是我们说的访问了scrollWidth、clientHeight、ComputedStyle等触发了强制重排,导致Recalc Styles和Layout前移到代码执行过程当中。 - parse HTML
如果有DOM变动,那么会有解析DOM的这一过程。 - Recalc Styles
如果你在JS执行过程中修改了样式或者改动了DOM,那么便会执行这一步,重新计算指定元素及其子元素的样式。 - Layout
我们常说的重排reflow。如果有涉及元素位置信息的DOM改动或者样式改动,那么浏览器会重新计算所有元素的位置、尺寸信息。而单纯修改color、background等等则不会触发重排。详见css-triggers。 - update layer tree
这一步实际是更新Render Layer的层叠排序关系,也就是我们之前说的为了搞定层叠上下文搞出的那个东西,因为之前更新了相关样式信息和重排,所以层叠情况也可能变动。 -
Paint
其实Paint有两步,第一步是记录要执行哪些绘画调用,第二步才是执行这些绘画调用。第一步只是把所需要进行的操作记录序列化进一个叫做SkPicture的数据结构里:The SkPicture is a serializable data structure that can capture and then later replay commands, similar to a display list.
这个SkPicture其实就一个列表,记录了你的commands。接下来的第二步里会将SkPicture中的操作replay出来,这里才是将这些操作真正执行:光栅化和填充进位图。主线程中和我们在Timeline中看到的这个Paint其实是Paint的第一步操作。第二步是后续的Rasterize步骤(见后文)。
- Composite
主线程里的这一步会计算出每个Graphics Layers的合成时所需要的data,包括位移(Translation)、缩放(Scale)、旋转(Rotation)、Alpha 混合等操作的参数,并把这些内容传给Compositor Thread,然后就是图中我们看到的第一个commit:Main Thread告诉Compositor Thread,我搞定了,你接手吧。然后主线程此时会去执行requestIdleCallback。这一步并没有真正对Graphics Layers完成位图的composite。 -
Raster Scheduled and Rasterize
第8步生成的SkPicture records在这个阶段被执行。SkPicture records on the compositor thread get turned into bitmaps on the GPU in one of two ways: either painted by Skia’s software rasterizer into a bitmap and uploaded to the GPU as a texture, or painted by Skia’s OpenGL backend (Ganesh) directly into textures on the GPU.
可以看出Rasterization其实有两种形式:
- 一种是基于CPU、使用Skia库的Software Rasterization,首先绘制进位图里,然后再作为纹理上传至GPU。这一方式中,Compositor Thread会spawn出一个或多个Compositor Tile Worker Thread,然后多线程并行执行SkPicture records中的绘画操作,以之前介绍的Graphics Layer为单位,绘制Graphics Layer里的Render Object。同时这一过程是将Layer拆分为多个小tile进行光栅化后写入进tile对应的位图中的。
- 另一种则是基于GPU的Hardware Rasterization,也是基于Compositor Tile Worker Thread,也是分tile进行,但是这个过程不是像Software Rasterization那样在CPU里绘制到位图里,然后再上传到GPU中作为纹理。而是借助Skia’s OpenGL backend (Ganesh) 直接在GPU中的纹理中进行绘画和光栅化,填充像素。也就是我们常说的GPU Raster。
现在基本最新版的几大浏览器都是硬件Rasterization了,但是对于一些移动端基本还是Software Rasterization较多。打开你的chrome浏览器输入chrome://gpu/ 可以看看你的chrome的GPU加速情况。下图是我的:
使用Hardware Rasterization的好处在于:以往Software Rasterization的方式,受限于CPU和GPU之前的上传带宽,把位图从RAM里上传到GPU的VRAM里的过程是有不可忽视的性能开销的。若Rasterization的区域较大,那么使用Software Rasterization很可能在这里出现卡顿。下面这个例子是Chrome32和Chrome41的对比,后者的版本实现了Hardware Rasterization。
不过,对于图片、canvas等情况,我没有查到到底是怎么处理的,但是我觉得绝对是有一个从CPU上传到GPU的过程的,所以应该有一些情况不是纯Hardware Rasterization的,两者应该是结合使用的。另外就是硬件还是软件Rasterization主要还是由设备决定的,在这个地方并没有我们手动优化的空间,但是这里涉及到一些后面的内容,所以简单介绍了一下。 -
commit
如果是Software Rasterization,所有tile的光栅化完成后Compositor Thread会commit通知GPU Thread,于是所有的tile的位图都会作为纹理都会被GPU Thread上传到GPU里。如果是使用GPU 的Hardware Rasterization,那么此时纹理都已经在GPU中。接下来,GPU Thread会调用平台对应的3D API(windows下是D3D,其他平台都是GL),把所有纹理绘制到最终的一个位图里,从而完成纹理的合并。
同时,非常关键的一点:在纹理的合并时,借助于3D API的相关合成参数,可以在合并前对纹理transformations(也就是之前提到的位移、旋转、缩放、alpha通道改变等等操作),先变形再合并。合并完成之后就可以将内容呈现到屏幕上了。
并不是每次渲染都会执行上述11步的所有步骤,比如Layout、Paint、Rasterize、commit可能一次都没有,但是Layout又可能会不止一次。另外还有利用合成层提升来获得GPU加速的动画等相关技术的原理。接下里就是对上述步骤更加详细的分析。
重排 Layout、强制重排 Force Layout
重排和强制重排是老生常谈的东西了,大家也应该非常熟悉了,但在这里可以结合浏览器机制顺带讲一遍。
首先,如果你改了一个影响元素布局信息的CSS样式,比如width、height、left、top等(transform除外),那么浏览器会将当前的Layout标记为dirty,这会使得浏览器在下一帧执行上述11个步骤的时候执行Layout。因为元素的位置信息变了,将可能会导致整个网页其他元素的位置情况都发生改变,所以需要执行Layout全局重新计算每个元素的位置。
需要注意到,浏览器是在下一帧、下一次渲染的时候才重排。并不是JS执行完这一行改变样式的语句之后立即重排,所以你可以在JS语句里写100行改CSS的语句,但是只会在下一帧的时候重排一次。
如果你在当前Layout被标记为dirty的情况下,访问了offsetTop、scrollHeight等属性,那么,浏览器会立即重新Layout,计算出此时元素正确的位置信息,以保证你在JS里获取到的offsetTop、scrollHeight等是正确的。
会触发重排的属性和方法:
这一过程被称为强制重排 Force Layout,这一过程强制浏览器将本来在上述渲染流程中才执行的Layout过程前提至JS执行过程中。前提不是问题,问题在于每次你在Layout为dirty时访问会触发重排的属性,都会Force Layout,这极大的延缓了JS的执行效率。
这三行代码的后两行都导致了Force Layout,Layout一次的时间视DOM数量级从几十微秒到十几毫秒不等,相比于一行JS 1微秒不到的执行时间,这个开销是难以接受的。所以也就有了读写分离、纯用变量存储等避免Force Layout的方法。否则你就会在你Timeline里看到这种10多次Recalculate Style 和 Layout的画面了。
另外,每次重排或者强制重排后,当前Layout就不再dirty。所以你再访问offsetWidth之类的属性,并不会再触发重排。
重绘 Paint
重绘也是相似的,一旦你更改了某个元素的会触发重绘的样式,那么浏览器就会在下一帧的渲染步骤中进行重绘。也即一些介绍重绘机制中说的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.
两个demo几乎完全一样,除了第二demo的.ab-right的样式里多了一行,will-change:transform;
。我们在前文介绍合成层的时候强调过will-change: transform
会让元素强制提升为合成层。
于是在第二个demo中出现了两个合成层:HTML根元素的合成层和.ab-right所在的合成层。
然后我们在js中修改了#target元素的样式,于是#target元素在的合成层(即HTML根元素的合成层)被重绘。在demo1中,.ab-right元素没有被提升为合成层,于是.ab-right也被重绘了。而在demo2中,.ab-right元素并没有重绘。先看demo1:
明显的看到.ab-right被重绘了。
显然,demo2只重绘了HTML根元素的合成层的内容。
对了,你还可以顺便点到Raster一栏去看看Rasterization的具体过程。前面已经介绍过了,这里真正完成Paint里的操作,将内容绘制进位图或纹理中,且是分tile进行的。
重排和重绘和Compositing
先说点题外的,怎么查看合成层:
修改一些CSS属性如width、float、border、position、font-size、text-align、overflow-y等等会触发重排、重绘和合成,修改另一些属性如color、background-color、visibility、text-decoration等等则不会触发重排,只会重绘和合成,具体属性列表请自行google。
接下来很多文章里就会说,修改opacity、transform这两个属性仅仅会触发合成,不会触发重绘和合成。所以一定要用这两个属性来实现动画,没有重绘重排,效率很高。
然而事实并不是这样。
只有一个元素在被提升为合成层之后,上述情况才成立。
回到我们之前说的渲染过程的第11步:
同时,非常关键的一点:在纹理的合并时,借助于3D API的相关合成参数,可以在合并前对纹理transformations(也就是之前提到的位移、旋转、缩放、alpha通道改变等等操作),先变形再合并。合并完成之后就可以将内容呈现到屏幕上了。
在合成多个合成层时,确实可以借助3D API的相关参数,从而直接实现合成层的transform、opacity效果。所以如果你将一个元素提升为合成层,然后用JS修改其transform或opacity 或者在 transform或opacity 上施加CSS过渡或动画,确实会避免CPU的Paint过程,因为transform和opacity可以直接基于GPU的合成参数来完成。
但是,这是在合成层整体有transform或opacity才会这么做。对于没有提升为合成层的元素,仅仅是他自己具有transform和opacity,他是作为合成层的内容。而生成合成层的内容和写进位图或纹理是在Paint和Rasterize阶段完成的,因此这个元素的transform和opacity的实现也是在Paint和Rasterize中完成的。所以还是会重排,也就没有启用我们常说的GPU加速的动画。
比如这个demo,一个提升为合成层的div#father和一个未提升合成层的div#child,3秒钟后JS更改child和father的transform属性。 接下来渲染的时候流程是怎样的?
- Recalc Styles(重新计算样式)
- Paint 绘制变动的合成层 即 div#father
- Paint 绘制父元素的背景和textNode(即”父元素 提升为合成层”)
- Paint 绘制child元素 即div#child
- Paint 先translate,完成移动
- Paint 再在移动后的区域里绘制子元素的背景和textNode(即”子元素 未提升为合成层”)
- Rasterize
- Composite 合并合成层,在合成时借助于3D API的相关合成参数完成合成层的位移、旋转等变换,所以div#father的translate在这里实现
所以我们看到了,对于未提升合成层的元素,他的transform、opacity等是在主线程里Paint和配合Rasterize来实现的(其他的需要重绘的属性更是如此),依然会触发重绘,直接用JS改动这俩属性并不会获得性能提升。而如果元素已提升为合成层,那么他的transform、opacity等样式的实现就是直接由GPU Thread控制在GPU中Compositing来完成的,主线程的Composite步骤只是计算出合成的参数,耗时极小,速度极快,所以因此就有了尽量使用transform和opacity来完成动画的经验之谈。
借用这篇文章中的例子:
这段transition的实现过程是这样的:
而如果代码变成了这样
也就是Main Thread不用重排,不用重绘,Draw也不是他完成的,他的Composite步骤只是计算出具体的Compositing参数而已(示例中其实右边应该是Compositor和GPU Thread,但是作者为了简化概念、便于阐述,直接就没有提GPU Thread,大家不要在此处扣细节)。
另外,第二个例子中div为什么提升为合成层,其实就是前文介绍合成层的时候说的:
对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition(需要是 active 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提升合成层也会失效)
括号中的内容也很关键,元素在opacity等属性具有动画时,并不是直接就提升为合成层,而是动画或者transition开始时才提升为合成层,并且结束后提升合成层也失效。
同时,元素在提升为合成层或者提升合成层失效时,会触发重绘。这也是上图一开始在动画开始前有Layout the element first time
和Paint the element into the bitmap
两步的原因:transition开始前,div并未被提升为合成层,transition开始,div立马提升合成层,立马导致其本来所在的合成层重绘(因为要剔除掉提升为合成层的div),并且div因为提升为合成层,也立马重绘,两个重绘好的合成层Rasterize后上传至GPU中。
demo在此,所以在动画开始前看到:
在动画结束后的那一帧则是这样:
这个上述demo中,只有2个dom,所以Paint开销几乎可以忽略,但是如果是dom数量多一些,那么就很可能是下面这样了。
实时上这个情况不止是在动画和过渡时,只要一个元素被提升为合成层,在提升前和合成层失效时都会有这个过程,所以一方面是重绘带来了绘制开销,另外则是纹理上传过程因为CPU到GPU的带宽带来的上传开销(虽然现在已经有Hardware Raster不用上传,但是仍然有不能用Hardware Raster的情况,而且Hardware Raster绘制进纹理的绘制过程本身也是有开销的)。 因此处理不好就可能导致动画开始前和开始后出现一帧卡顿/延迟。
最后,重要的一点,也是一般谈到性能优化的文章中都会介绍的一点,即:
合成层提升并非银弹。
合成层提升一方面可能会引入纹理生成、上传和重绘的开销,而且合成层提升后会占用GPU VRAM,VRAM可并不会很大。对于移动端,上述两个问题尤甚。而且在介绍合成层时,我还介绍了合成层存在隐式提升的情况。因此请合理使用。
本文主要介绍原理,所以怎么去实现16ms的动画、怎么去提升渲染性能、怎么去优化合成层数量和避免层爆炸等等、以及到底哪些情况会提升合成层、触发重绘等详细内容还是见文末附录吧。
总结
正文算是比较详细的介绍浏览器的渲染过程,可能需要你事先理解重绘、重排和合成,结合了一些demo,深入了一些我之前理解错的点。
这里再次强调一下一些颠覆了我认知的内容:
- 按照HTML5标准,scroll事件是每帧触发一次的,自带requestAnimationFrame节流效果
- 按照Blink和Webkit引擎实现,touchmove、mousemove等UI input由Compositor线程接收,但传入到主线程是每帧一次,也自带requestAnimationFrame节流效果
- 重绘是以合成层为单位的
- 合成层提升前后的Paint步骤
三周前就第一次发布的文章终于在五一节的假期里搞定。呼….
参考资料
chromium官方资料
- GPU Accelerated Compositing in Chrome
- Compositor Thread Architecture
- Multithreaded Rasterization
- How to get GPU Rasterization
渲染机制
- youtube视频 Jing Jin & Matthew Delaney: The Web’s Black Magic
- 中文 How Rendering Work (in WebKit and Blink)
- 中文 提高页面的渲染性能
- GPU Animation: Doing It Right
- Accelerated Rendering in Chrome
- google developers的文章,简单易懂,这个系列都值得一读 : 渲染性能
- Chrome Developer Relations team的员工写的文章 The Anatomy of a Frame