css3 动画深入分析和优化方案
1. 前言
CSS3 对于前端开发者是再熟悉不过了,经过在无数复杂业务、环境中的实践,css3 这项看似简单的语言已经被开发的非常完善。但随着业务的升级、技术的进化,也总留有一丝的探索空间。
如今页面交互、视觉效果越来越重,有部分内容转向 canvas、webgl 实现,借助其在复杂视觉内容上的优势,有很好的效果表现。
这是提到“优势”两字,更贴切的说应该是“更合适”,每项技术没有绝对的优势,深入理解其原理特性、不同场景环境下的能力,扬长避短,灵活应用,才是最合适的。
CSS3 就是一个非常讲究如何“合适”应用的技术,良好的使用 CSS 不仅能给页面添砖加瓦,更能锦上添花。
2. 先来一个总结
先说一下这个文章标题是有点尴尬,已经被写了无数遍,结论也是大同小异。
不管是初级、还是老鸟级的开发者都知道在动画中使用 transform
要比 left
、top
等的 css properties 性能好。再进一步,用 transform: translateZ(0)
开启个硬件加速,据说这样动画性能会更好。
但是,经常会有这种情况,随文章给出的 demo 跑的异常流畅,在实际的项目中遇上复杂的场景和环境,却总是那么的不尽人意,卡顿、丢帧,甚至有卡死和 crash。怎样去优化它?
本文旨在更深入了解一点浏览器对动画的实践机制和优化方向,也包含笔者自己的实验总结,希望在以后的动画实践中为优化页面提供思路,而不只是遵循一些来自网络上的随机建议。
3. 页面渲染的过程
首页我们必须清楚页面的渲染过程,这里有一个页面:
<style>
#a, #b {
position: absolute;
width: 100px;
height: 100px;
}
#a {
left: 30px;
top: 30px;
z-index: 2;
}
#b {
z-index: 1;
}
</style>
<div id="a">A</div>
<div id="b">B</div>
加载这个页面,浏览器都干了这些工作:
- Parse HTML: 解析整个 html 文件,生成 DOM 树和 CSSOM 树;从根结点开始遍历每个可见节点,需要强调一下,这里的不可见仅限于:
display: none;
; - Recalculate style,对每个可见的 dom 进行样式计算,如大小、位置等,这就生成了一个渲染树;
- Layout: 就是我们常说的重排,结合渲染树对每个结点在 viewport 内的确切位置和大小等进行精确计算,转换成在针对当前页面上的实际像素单位,在此过程中会每个 dom 都会相互影响,即通常所说的文档流布局;
- Update Layer Tree: 更新渲染层,浏览器除了正常的文档流,还有层叠上下文的布局模式,这些元素会脱离正常的文档流,形成自己的 layer,每个 layer 有独立的一套文档流和绘制层;
- Paint: 绘制过程,将所有 layer 进行光栅化处理,填充成位图象素点;
- Composite Layers: 将生成的各 layer image 信息合成为最终的图像,显示在屏幕上,这一步通常是由 GPU 完成的。
下面我们给 A 元素加上动画,正式开始。
4. 改变 css properties 的动画问题出在哪
#a {
...
animation: move 1s linear;
}
@keyframes move {
from { left: 30px; }
to { left: 100px; }
}
动画是动起来了,不过在动画的每一帧中,因为改变了元素的 css,浏览器不得不从 Recalculate style
到 Composite Layers
,把完整的渲染流程都走一遍:
大家都知道 paint 是非常耗性能的,即使现在的浏览器已经“聪明”到只对变更的元素进行快速重绘,但在实际业务中动画可不是如此简单。
更糟糕的是,改变 left
会影响所在 layer 文档流的 layout 过程,layer 内所有的元素都会被重新 layout 和 paint,性能的损耗可能要 *n 了。
在动画的播放过程中,如果按 60帧/秒 算,页面会每秒把这个 layer 重排和绘制 60 次。
5. GPU 渲染的动画
将上面的动画用 transform
代替:
#a {
...
animation: move 1s linear;
}
@keyframes move {
from { transform: translateX(0); }
to { transform: translateX(70px); }
}
动画过程中实际没有改变任何 css propreties,通过 devtools 查看 A 会自己独立出一个 layer,而且动画过程中也没有任何移动,只在最终的 Composite Layers
时 GPU 会自己算出这一帧元素的偏移量,往屏幕上画的时候“画偏”一点。
GPU 很善于处理此类情况,CPU 更是没有做任何工作,我们仅是看起来动了,性能随即提升。
6. 所谓的硬件加速是什么
大多数开发者还会对上述的动画做进一步的“优化”:给 A 元素开启硬件加速
#a {
...
transform: translateZ(0); // 用的最多的
will-change: transform; // 另外一种部分浏览器支持的更精确的属性
animation: move 1s linear;
}
我们来从 CPU 和 GPU 的视角看看“硬件加速”到底是什么?
先回顾一下上面列出的页面渲染的几个过程,前几步都是由 CPU 搞定,CPU 将符合以下规则的 DOM 生成一个个的渲染层:
- 整个页面 root 结点(就是默认的文档流)是一个渲染层;
- 一些有特殊的 CSS
position
属性的 dom:如relative
、absolute
、fixed
; - 带透明度 opacity 的 dom;
- 特殊的
overflow
:如hidden
、scroll
,(经笔者试验,这个非常受页面上下文的影响); - 有 CSS filter 的 dom;
<canvas>
<video>
CPU 生成的渲染层 (以下也称 RenderLayers) 在一个稍复杂点的页面都是非常非常多的。如果这样就进行光栅化处理,会占用大量的内存,也给 GPU 的绘制和缓存增加负担。为了优化内存和适应 GPU 工作,产生一个新的概念 GraphicsLayers。
优化过程中会合并掉那些“静态的、不包含 GPU 特效或动效”的 RenderLayers,按以下规则生成 GraphicsLayers:
- 含有 3D 类 transform 或 perspective 属性的;
<canvas>
webgl 或 开启硬件加速的 2d;<video>
且视频内容支持硬解播放;- 正在执行 CSS animation 动画且动画涉及 transfrom 或 opacity;
- 含有 GPU 支持的 CSS filter;
- [注意]某一个子结点是 compositing layer;
- [注意]比自己
z-index
低的同级中有一个 compositing layer,换句话说,自己层与同级的合成层重叠,而且在其上呈现。
生成的 GraphicsLayers 交给 GPU,完成最后一步 Composite Layers
(合成层)。
上面 demo,在 transition 动画运行时,A 会产生一个新的 layer:
transform: translateZ(0)
正是利用了上面的第 1 条规则,对生成 GraphicsLayers 进行了一次 hack。
因为在页面初始化的那一刻(但还没有动起来),不符合生成 GraphicsLayers 规则里的任何一条,元素A被合并在 root layer 中。在动画开始时,经过 Recalculate style
、Update Layer Tree
过程,被安排到了新的 GraphicsLayers 中,接下来被 paint
和 Composite Layers
,root layer 因少了元素A也会被重新 paint
。
动画前 | 动画中 | 动画后 | |
---|---|---|---|
预置了transform: translateZ(0) |
root layer 和 A layer | GPU 执行 transform | - |
无transform: translateZ(0) |
仅 root layer | 更新 root layer;产生 A layer;GPU 执行 transform | 更新 root layer;A layer 消失 |
通过表格比较可以看出,开启了常说的“硬件加速”:transform: translateZ(0)
,相当是预先将页面要“动”的内容分离出去,做足准备,保持“不动”的部分在整个过程中的稳定,减少了 CPU 和 GPU 计算和渲染的工作量。
但是!笔者不建议使用。
7. 不建议使用这种“硬件加速”
这也是为了回答了文章一开关提到的疑惑:“为什么那些文章中的 demo,使用了这些‘技巧’后都性能变好了,但是在实际业务中,使用了后也不变好,甚至变的更差了”。
再回顾一下,上文中有提到,GraphicsLayers 的是浏览器开发者大佬们在长期页面渲染的场景中得出的一个“优化方案”,面这种的“加速”实际是一种 hack,强行让某个 RenderLayers 不被合并。 虽然有一定效果,但是在实际业务中的页面要比上面的 demo 复杂万千倍,hack 方法阻碍了浏览器的默认优化行为,页面越复杂,负作用越会被成倍放大。
7.1 implicit compositing
注意看生成 GraphicsLayers 规则中的最后两条(标注[注意]),这是让这种“加速”负作用突显主要原因之一。
举个例子,还是用上面的 demo,我们让元素 A、B 的 index 值换一下:
#a {
...
z-index: 1;
}
#b {
z-index: 2;
}
再看一下现在的 GraphicsLayers 情况,发现元素 B 也生成了独立的 GraphicsLayers,因为它的同级元素中存在比它低 index
的元素 A 是 GraphicsLayers,导致 B 不得不自己“独立”出来,以便让合成器得到正确的的层级关系,这正是第7条规则的情况。再加上第6条规则,就更严重了,尤其在业务复杂、动效繁多的页面,一些看似单纯的“加速”优化会让页面 layer 变得凌乱,不仅增加了内存占用量,也加大了 CPU、GPU 的渲染负担,得不偿失。
官方称这种情况为 implicit compositing 隐式合成(层)。
而且,情况要比想象的复杂些,笔者在实践中发现,一些属性如position
、opacity
、overflow
,在平常本不该生成 GraphicsLayer 的情况下,受其它 layer 的影响,也会莫名的产生新的 GraphicsLayer,增加新的负担。
这就好比是你要出去旅行,只是为了在路途上方便吃饭,就早早把所有吃的东西单独放一袋里一直拎着,又突然想到吃完还需要用纸巾,就又把纸巾单独拎一袋。然后还会渴、需要洗漱… 最终索性不用行李箱了,一手拎着十多个袋子就上路了。除了你吃饭的时候方便,其它的时候都很累。
所以,笔者不建议做这种刻意的、效果有限的优化。 CSS3 动画过程本身就是 GPU 渲染的,不再用去“强调”一次。合理使用各种属性,“简洁、高效”才是 css 样式编写和动画实践的要点。
8. 内存
移动端应用切换和启动频繁,内存对任何应用都是紧俏资源。 内存不足轻者渲染时闪屏、页面卡死,重者直接白/黑屏,甚至整个应用 crash。 页面开发时,要尽量避免不发必要的内存占用。
页面渲染过程中生渲染树、渲染层、合成层都需要不少的内存量,实际占用的大小因平台、机型、系统分配机制不同而各不相同。但是对于最后像素化的合成层内存占用量(显存),我们可以大致计算出来。
8.1 显存计算方法
我们在页面中画一个 300 * 200 的矩形,背景设为红色 background: #ff0000;
。
在渲染过程的最后,生成象素化的位图图层,它占用的内存就是 300 * 300 * 4 = 360,000 bytes
大约是 350+KB,乘以的这个 4
就是记录每个像素需要的字节数,R、G、B、a
每个是 8bit。
在这里,如果使用一张 300 * 200 红色的图片,占用也是这么多。
到实际 h5,如 iphone 6/7/8 单纯一个只占满一屏的页面,占用显存大小就是 750 * 1334 * 4 = 4,002,000 bytes
。当然你的页面不可能这么“简朴”,合成层的数量和页面整体大小会让显存占用翻好几(十)倍。
8.2 图片占用内存
图片在页面中算是占用内存较大的元素,虽然在栅格化过程中,所有元素都被像素化成统一的合成层 。但图片在解析、生成渲染层的时候还要占用额外的内存。
页面最终被显示出来,都是一个像素一个像素的计算并绘制,不管jpg
、png
还是gif
,压缩比再大的图片都要解析成像素化的位图,再根据位置和大小绘制在 layer 上。所以,页面渲染时内存使用量主要看图片尺寸,占用量的计算的方式和上一节一样。
所以,单纯想在图片文件上进行内存优化是不太容易,除非你不用图片。
8.3 善于利用放缩
这是一个小技巧,笔者一直在用:图像放缩。
上面说到的内存消耗主要用于存放各种 layer 的像素信息,因此在大屏手机下,整体内存还会上涨不少。
可以充分利用 transform
在显示绘制时最终由 GPU 处理的特性,将一些图像用固定的尺寸实现后,用 transform: scale(x)
在大屏设备上放大显示。这样,在整个渲染计算过程中,图像都是以编写的“较小”尺寸计算,只是在最终的显示上由 GPU 做物理放大,内存自然占用少了。
不过使用这种方法要注意物理放大的一个缺点:图像清晰度上会有损失。
还是举个例子:
<style>
.img {
width: 100px;
height: 100px;
background-image: url(400w_400h.png); // 4倍图
background-size: 100% 100%;
}
</style>
<div class="img"></div>
一个实际尺寸 400 * 400
的图片,以 100px * 100px
大小显示。
放在一个 200 * 200
2 倍屏,这个 .img
是足够清晰的。(这里的清晰是指实际看到的每一个像素都能在渲染层找到对应的像素点)
放在一个 200 * 200
3 倍屏,这个 .img
也是足够清晰的。
放在一个 300 * 300
2 倍屏,我们来放大 1.5 倍 transform: scale(1.5)
,即显示尺寸是 150 * 150
,那么这个图还显示足够清晰吗?答案是‘否’,即使它是一张足够大的 4 倍图。
因为这张“足够大”的4倍图在渲染层是以 (100 * 2) * (100 * 2)
的尺寸来计算的,实际尺寸再大也是多余的,但 GPU 拿到后又物理放大了一次,这样就会造成像素的损失。
损失多少像素?我们来算一下,img
在在渲染层由 CPU 计算出的实际像素点是 (100 * 2) * (100 * 2)
40000 个,实际上 GPU 在屏幕上绘制的点是 (100 * 2 * 1.5) * (100 * 2 * 1.5)
90000 个,损失多少就很明显了。
放在一个 300 * 300
3 倍屏,会清晰吗?缺少多少像素?图片使用平常的2倍图呢?
适用场景
所以,综合其利弊,笔者一般使用的场景就两个:
- 纯色的元素,GPU 放大补充的像素也是纯色,不会失真;
- 整屏式的、有大量图片动效的页面,虽然有一定的图像显示失真,但权衡开发的便利程度和放大的失真的程度,还是可以用此方法(毕竟按目前的机型尺寸,最多就放大1.2倍)。
9. CSS3 还是 canvas 动画
这又是一个老生常谈的问题,本着只选合适的原则,在这里只是简单说一下两者的优势。
9.1 动画计算
- CSS3 动画最大的优势就是完全由 GPU 搞定,CPU 不参与任何动画的计算和渲染。
- canvas/webgl 以目前使用情况来看,动画帧的计算还是通过 js 完成,一部分 canvas 的渲染也还是由 CPU 完成,webgl 虽然有 GPU 渲染的相关 API,但是目前在实际项目中为了兼容 canvas2d,大多都没有使用。
因为 GPU 渲染不会占用 JS 的主线程,所以会感觉非常流畅。 JS 计算的动画就不一样了,计算一个动画的每一帧是一个连续密集的执行过程,如果在某一帧时,JS 正在执行其它比较重的任务,没有顾的上动画的运算,这就会导致这一帧没有被正确渲染出来,出现丢帧、卡顿的问题。
当然 CSS3 transform
有很大一个局限性:只支持简单变换,这个就不再多说了。
9.2 内存占用
- CSS3 动画涉及的 dom 至少会产生一个新的 layer,尤其是在动画元素中图片较多、较大时会消耗很多内存;
- canvas 因为自己会将图片元素在渲染前就合并,所以除了解析图片占用的内存外,占用内存理论上和渲染 canvas 同尺寸的一张图片占用的内存是一样的。
一般我们业务中 CSS 能满足大部分的需求,稍加优化后就能有良好的表现。所以在够用的情况下不用刻意使用 canvas,因实际情况理性选择是最好的。
10. 一些优化建议
结合上文内容,这里给出一些在平常中比较实用的建议:
- GPU 渲染的 CSS3 动画只能包含
transform
、opacity
; - 将不用的元素及时从渲染树中去掉,
display: none
很有效,从“根本”上对不需要的元素进行优化; - 合理分隔 layer 尽量只让持久动画单独占据一个 layer;
- 对于只运行很少次数的动画,尽量让元素在不动的时候不会产生 layer;
- 适当使用相对像素,如百分比、vh、vw、em、rem 等;
- 注意隐藏式合成的情况,opacity、position、overflow 等属性在某些上下文环境中也会产生这种情况,要准确使用,复杂页面中很有可能一个属性就引起整个页面 layer 的很大变化;
- 个人建议不要盲目使用
transform: translateZ(0)
、will-change
属性,除非你很明确用的目的; - sprites image 是个好东西,但也要尽量减小产出的单张图的尺寸。
本篇文章不会保证,也无法保证你在看过之后写出的 CSS3 动画就是优秀的。CSS 非常灵活,使用时要注意具体页面场景和浏览器环境。在具体的业务问题面前没有什么通用解,最终还是需要针对性的思路解决问题。
10.1 页面优化示例:
笔者实际项目中的一个页面优化前后对比:
引用: