浏览器层合成与页面渲染优化

26,192 阅读18分钟

作者:黄浩群

一个 CSS 属性引发的血案

Web 页面性能是前端开发特别需要关注的重点,评判前端 Web 页面性能的指标有很多,页面的流畅度是其中的一种,如何让页面变得 “柔顺丝滑”,要讨论起来可就是个相当有料的话题了。之前开发移动端 H5 页面的时候,就遇到过一个有趣的性能问题 —— 某个卖场页面在 IOS 手机上出现了严重的卡顿,但在安卓机型下却表现得十分流畅。归纳一下在 iPhoneX 上测试的具体表现:

  • 页面加载时存在明显的延迟,但通过代理抓到的网络请求耗时并不比 Android 的高;

  • 页面滚动时会出现短暂的局部白屏,即丢帧。

根据这些表征情况不难推断出,应该是有什么东西在疯狂占用 CPU,卡住了渲染进程。

然而具体是什么东西,要问我我也并不知道。对于这种没法通过断点定位到的问题,恐怕只有用上祖师爷亲传的 “代码二分法” 才能制服得了了。一番艰苦排查之后,问题的根源终于聚焦到了下边这行 CSS 代码上:

  filter: blur(100px);

这行 CSS 代码用于实现一个高斯模糊,来构造一个优惠券模块的底部阴影。由于活动配置了多个优惠券,导致页面里存在多个设置了这个属性的 div 元素,而 IOS 手机的浏览器似乎对这个属性的渲染十分吃力(然而为何吃力的原因不得而知),进而导致渲染进程的 CPU 占用率过高,最终造成卡顿。

哦?CPU 忙不过来了?好办嘛!我给优惠券模块又加了这样一行代码,然后问题迎刃而解 ......

  will-change: transform;

你没看错,我也没写少,确实就是靠一行代码解决的。

认识它的人可能已经看出来了,大致原理其实很简单,这行代码能够开启 GPU 加速页面渲染,从而大大降低了 CPU 的负载压力,达到优化页面渲染性能的目的,不了解 CSS 硬件加速的可以看看这篇文章 Increase Your Site’s Performance with Hardware-Accelerated CSS

问题解决了,但是真的就这么完事了吗?本着 “拔树寻根” 的伟大原则,我把这个东西好好地研究了一番,才发现 GPU 加速其实没那么简单。

浏览器渲染流程

在具体讨论原理之前,我们需要了解一下浏览器渲染流程的一些基本概念。浏览器渲染流程是个老生常谈的话题了,对于 “浏览器如何呈现一个页面的内容” 的这类问题,不少人都可以讲出一个相对完整的过程,从网络请求到浏览器解析,可以具体到很多的细节。除去网络资源获取的步骤,我们理解的 Web 页面的展示,一般可以分为 构建 DOM 树构建渲染树布局绘制渲染层合成 几个步骤。

  • 构建 DOM 树:浏览器将 HTML 解析成树形结构的 DOM 树,一般来说,这个过程发生在页面初次加载,或页面 JavaScript 修改了节点结构的时候。

  • 构建渲染树:浏览器将 CSS 解析成树形结构的 CSSOM 树,再和 DOM 树合并成渲染树。

  • 布局(Layout):浏览器根据渲染树所体现的节点、各个节点的CSS定义以及它们的从属关系,计算出每个节点在屏幕中的位置。Web 页面中元素的布局是相对的,在页面元素位置、大小发生变化,往往会导致其他节点联动,需要重新计算布局,这时候的布局过程一般被称为回流(Reflow)。

  • 绘制(Paint):遍历渲染树,调用渲染器的 paint() 方法在屏幕上绘制出节点内容,本质上是一个像素填充的过程。这个过程也出现于回流或一些不影响布局的 CSS 修改引起的屏幕局部重画,这时候它被称为重绘(Repaint)。实际上,绘制过程是在多个层上完成的,这些层我们称为渲染层(RenderLayer)。

  • 渲染层合成(Composite):多个绘制后的渲染层按照恰当的重叠顺序进行合并,而后生成位图,最终通过显卡展示到屏幕上。

这是一个基本的浏览器从解析到绘制一个 Web 页面的过程,跟上边页面卡顿问题的解决方法相关的,主要是最后一个环节 —— 渲染层合成。

渲染层合成

一、什么是渲染层合成

在 DOM 树中每个节点都会对应一个渲染对象(RenderObject),当它们的渲染对象处于相同的坐标空间(z 轴空间)时,就会形成一个 RenderLayers,也就是渲染层。渲染层将保证页面元素以正确的顺序堆叠,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示。

这个模型类似于 Photoshop 的图层模型,在 Photoshop 中,每个设计元素都是一个独立的图层,多个图层以恰当的顺序在 z 轴空间上叠加,最终构成一个完整的设计图。

对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

二、浏览器的渲染原理

从浏览器的渲染过程中我们知道,页面 HTML 会被解析成 DOM 树,每个 HTML 元素对应了树结构上的一个 node 节点。而从 DOM 树转化到一个个的渲染层,并最终执行合并、绘制的过程,中间其实还存在一些过渡的数据结构,它们记录了 DOM 树到屏幕图形的转化原理,其本质也就是树结构到层结构的演化。

1、渲染对象(RenderObject)

一个 DOM 节点对应了一个渲染对象,渲染对象依然维持着 DOM 树的树形结构。一个渲染对象知道如何绘制一个 DOM 节点的内容,它通过向一个绘图上下文(GraphicsContext)发出必要的绘制调用来绘制 DOM 节点。

2、渲染层(RenderLayer)

这是浏览器渲染期间构建的第一个层模型,处于相同坐标空间(z轴空间)的渲染对象,都将归并到同一个渲染层中,因此根据层叠上下文,不同坐标空间的的渲染对象将形成多个渲染层,以体现它们的层叠关系。所以,对于满足形成层叠上下文条件的渲染对象,浏览器会自动为其创建新的渲染层。能够导致浏览器为其创建新的渲染层的,包括以下几类常见的情况:

  • 根元素 document

  • 有明确的定位属性(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 应用动画

  • overflow 不为 visible

DOM 节点和渲染对象是一一对应的,满足以上条件的渲染对象就能拥有独立的渲染层。当然这里的独立是不完全准确的,并不代表着它们完全独享了渲染层,由于不满足上述条件的渲染对象将会与其第一个拥有渲染层的父元素共用同一个渲染层,因此实际上,这些渲染对象会与它的部分子元素共用这个渲染层。

3、图形层(GraphicsLayer)

GraphicsLayer 其实是一个负责生成最终准备呈现的内容图形的层模型,它拥有一个图形上下文(GraphicsContext),GraphicsContext 会负责输出该层的位图。存储在共享内存中的位图将作为纹理上传到 GPU,最后由 GPU 将多个位图进行合成,然后绘制到屏幕上,此时,我们的页面也就展现到了屏幕上。

所以 GraphicsLayer 是一个重要的渲染载体和工具,但它并不直接处理渲染层,而是处理合成层。

4、合成层(CompositingLayer)

满足某些特殊条件的渲染层,会被浏览器自动提升为合成层。合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 的父层共用一个。

那么一个渲染层满足哪些特殊条件时,才能被提升为合成层呢?这里列举了一些常见的情况:

  • 3D transforms:translate3d、translateZ 等

  • video、canvas、iframe 等元素

  • 通过 Element.animate() 实现的 opacity 动画转换

  • 通过 СSS 动画实现的 opacity 动画转换

  • position: fixed

  • 具有 will-change 属性

  • 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition

因此,文首例子的解决方案,其实就是利用 will-change 属性,将 CPU 消耗高的渲染元素提升为一个新的合成层,才能开启 GPU 加速的,因此你也可以使用 transform: translateZ(0) 来解决这个问题。

这里值得注意的是,不少人会将这些合成层的条件和渲染层产生的条件混淆,这两种条件发生在两个不同的层处理环节,是完全不一样的。

另外,有些文章会把 CSS Filter 也列为影响 Composite 的因素之一,然而我验证后发现并没有效果。

三、隐式合成

上边提到,满足某些显性的特殊条件时,渲染层会被浏览器提升为合成层。除此之外,在浏览器的 Composite 阶段,还存在一种隐式合成,部分渲染层在一些特定场景下,会被默认提升为合成层。

对于隐式合成,CSS GPU Animation 中是这么描述的:

This is called implicit compositing: One or more non-composited elements that should appear above a composited one in the stacking order are promoted to composite layers. (一个或多个非合成元素应出现在堆叠顺序上的合成元素之上,被提升到合成层。)

这句话可能不好理解,它其实是在描述一个交叠问题(overlap)。举个例子说明一下:

  • 两个 absolute 定位的 div 在屏幕上交叠了,根据 z-index 的关系,其中一个 div 就会”盖在“了另外一个上边。

  • 这个时候,如果处于下方的 div 被加上了 CSS 属性:transform: translateZ(0),就会被浏览器提升为合成层。提升后的合成层位于 Document 上方,假如没有隐式合成,原本应该处于上方的 div 就依然还是跟 Document 共用一个 GraphicsLayer,层级反而降了,就出现了元素交叠关系错乱的问题。

  • 所以为了纠正错误的交叠顺序,浏览器必须让原本应该”盖在“它上边的渲染层也同时提升为合成层。

四、层爆炸和层压缩

1、层爆炸

从上边的研究中我们可以发现,一些产生合成层的原因太过于隐蔽了,尤其是隐式合成。在平时的开发过程中,我们很少会去关注层合成的问题,很容易就产生一些不在预期范围内的合成层,当这些不符合预期的合成层达到一定量级时,就会变成层爆炸。

层爆炸会占用 GPU 和大量的内存资源,严重损耗页面性能,因此盲目地使用 GPU 加速,结果有可能会是适得其反。CSS3硬件加速也有坑 这篇文章提供了一个很有趣的 DEMO,这个 DEMO 页面中包含了一个 h1 标题,它对 transform 应用了 animation 动画,进而导致被放到了合成层中渲染。由于 animation transform 的特殊性(动态交叠不确定),隐式合成在不需要交叠的情况下也能发生,就导致了页面中所有 z-index 高于它的节点所对应的渲染层全部提升为合成层,最终让这个页面整整产生了几千个合成层。

消除隐式合成就是要消除元素交叠,拿这个 DEMO 来说,我们只需要给 h1 标题的 z-index 属性设置一个较高的数值,就能让它高于页面中其他元素,自然也就没有合成层提升的必要了。点击 DEMO 中的复选按钮就可以给 h1 标题加上一个较大的 z-index,前后效果对比十分明显。

2、层压缩

当然了,面对这种问题,浏览器也有相应的应对策略,如果多个渲染层同一个合成层重叠时,这些渲染层会被压缩到一个 GraphicsLayer 中,以防止由于重叠原因导致可能出现的“层爆炸”。这句话不好理解,具体可以看看这个例子:

  • 还是之前的模型,只不过这次不同的是,有四个 absolute 定位的 div 在屏幕内发生了交叠。此时处于最下方的 div 在加上了 CSS 属性 transform: translateZ(0) 后被浏览器提升为合成层,如果按照隐式合成的原理,盖在它上边的 div 会提升为一个新的合成层,第三个 div 又盖在了第二个上,自然也会被提升为合成层,第四个也同理。这样一来,岂不是就会产生四个合成层了?

  • 然而事实并不是这样的,浏览器的层压缩机制,会将隐式合成的多个渲染层压缩到同一个 GraphicsLayer 中进行渲染,也就是说,上方的三个 div 最终会处于同一个合成层中,这就是浏览器的层压缩。

当然了,浏览器的自动层压缩并不是万能的,有很多特定情况下,浏览器是无法进行层压缩的,无线性能优化:Composite 这篇文章列举了许多详细的场景。

基于层合成的页面渲染优化

一、层合成的得与失

层合成是一个相对复杂的浏览器特性,为什么我们需要关注这么底层又难理解的东西呢?那是因为渲染层提升为合成层之后,会给我们带来不少好处:

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快得多;

  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层;

  • 元素提升为合成层后,transform 和 opacity 才不会触发 repaint,如果不是合成层,则其依然会触发 repaint。

当然了,利弊是相对和共存的,层合成也存在一些缺点,这很多时候也成为了我们网页性能问题的根源所在:

  • 绘制的图层必须传输到 GPU,这些层的数量和大小达到一定量级后,可能会导致传输非常慢,进而导致一些低端和中端设备上出现闪烁;

  • 隐式合成容易产生过量的合成层,每个合成层都占用额外的内存,而内存是移动设备上的宝贵资源,过多使用内存可能会导致浏览器崩溃,让性能优化适得其反。

二、Chrome Devtools 如何查看合成层

层合成的特性给我们提供了一个利用终端硬件能力来优化页面性能的方式,对于一些重交互、重动画的页面,合理地利用层合成可以让页面的渲染效率得到极大提升,改善交互体验。而我们需要关注的是如何规避层合成对页面造成的负面影响,或者换个说法来讲,更多时候是如何权衡利害,合理组织页面的合成层,这就要求我们事先要对页面的层合成情况有一个详细的了解。Chrome Devtools 给我们提供了一些工具,可以方便的查看页面的合成层情况。

首先是看看页面的渲染情况,以一个栏目页为例,点击 More tools -> Rendering,选择 Layer borders,你就能看到页面中的合成层都带上了黄色边框。

这还不够,我们还需要更加详尽的层合成情况,点击 More tools -> Layers,你可以看到像这样的一个视图:

左侧列出了所有提升为独立合成层的元素,右侧则是一个整体合成层边界视图,以及选定合成层的详细情况,包括以下几个比较关键的信息:

  • Size:合成层的大小,其实也就是对应元素的尺寸;
  • Compositing Reasons:形成复合层原因,这是最关键的,也是我们分析问题的突破口,比如图中的合成层产生的原因就是交叠问题;
  • Memory estimate:内存占用估算;
  • Paint count:绘制次数;
  • Slow scroll regions:缓慢滚动区域。

可以看出我们在不经意间就已经制造出了很多意料之外的合成层,这些没有实际意义的合成层都是可以被优化的。

三、一些优化建议

1、动画使用 transform 实现

对于一些体验要求较高的关键动画,比如一些交互复杂的玩法页面,存在持续变化位置的 animation 元素,我们最好是使用 transform 来实现而不是通过改变 left/top 的方式。这样做的原因是,如果使用 left/top 来实现位置变化,animation 节点和 Document 将被放到了同一个 GraphicsLayer 中进行渲染,持续的动画效果将导致整个 Document 不断地执行重绘,而使用 transform 的话,能够让 animation 节点被放置到一个独立合成层中进行渲染绘制,动画发生时不会影响到其它层。并且另一方面,动画会完全运行在 GPU 上,相比起 CPU 处理图层后再发送给显卡进行显示绘制来说,这样的动画往往更加流畅。

2、减少隐式合成

虽然隐式合成从根本上来说是为了保证正确的图层重叠顺序,但具体到实际开发中,隐式合成很容易就导致一些无意义的合成层生成,归根结底其实就要求我们在开发时约束自己的布局习惯,避免踩坑。

比如上边提到的栏目页面,就因为平时开发的不注意造成页面生成了过多的合成层,我在试图查看页面合成层情况的时候,在 PC 上已经能明显感到卡顿了。利用 Chrome Devtools 分析之后不难发现,页面里边存在的一个带动画 transform 的 button 按钮,提升为了合成层,动画交叠的不确定性使得页面内其他 z-index 大于它但其实并没有交叠的节点也都全部提升为了合成层(这个原因真的好坑)。

这个时候我们只需要把这个动画节点的 z-index 属性值设置得大一些,让层叠顺序高过于页面其他无关节点就行。当然并不是盲目地设置 z-index 就能避免,有时候 z-index 也还是会导致隐式合成,这个时候可以试着调整一下文档中节点的先后顺序直接让后边的节点来覆盖前边的节点,而不用 z-index 来调整重叠关系。方法不是唯一的,具体方式还是得根据不同的页面具体分析。

改善后的页面效果如下,可以看到相比优化前,我们消除了很多无意义的合成层。

3、减小合成层的尺寸

举个简单的例子,分别画两个尺寸一样的 div,但实现方式有点差别:一个直接设置尺寸 100x100,另一个设置尺寸 10x10,然后通过 scale 放大 10 倍,并且我们让这两个 div 都提升为合成层:

<style>
  .bottom, .top {
    position: absolute;
    will-change: transform;
  }
  .bottom {
    width: 100px;
    height: 100px;
    top: 20px;
    left: 20px;
    z-index: 3;
    background: rosybrown;
  }
  .top {
    width: 10px;
    height: 10px;
    transform: scale(10);
    top: 200px;
    left: 200px;
    z-index: 5;
    background: indianred;
  }
</style>
<body>
  <div class="bottom"></div>
  <div class="top"></div>
</body>

利用 Chrome Devtools 查看这两个合成层的内存占用后发现,.bottom 内存占用是 39.1 KB,而 .top 是 400 B,差距十分明显。这是因为 .top 是合成层,transform 位于的 Composite 阶段,现在完全在 GPU 上执行。因此对于一些纯色图层来说,我们可以使用 width 和 height 属性减小合成层的物理尺寸,然后再用 transform: scale(…) 放大,这样一来可以极大地减少层合成带来的内存消耗。

参考文章

CSS3硬件加速也有坑
无线性能优化:Composite
CSS GPU Animation
详谈层合成


如果你觉得这篇内容对你有价值,请点赞,并关注我们的官网和我们的微信公众号(WecTeam),每周都有优质文章推送:

WecTeam