制作60fps的高性能动画

5,055 阅读11分钟

写在前面

说到web的高性能动画,这部分内容其实已经是老生常谈的了,不过其中还是有不少比较新的而且非常实用的内容可以和大家分享一下。 读完这篇文章后相信大家都会对动画渲染的机制以及制作60fps动画的关键要素有足够的理解,以后遇上了动画相关的问题也可以很好的从源头上解决。

正文

什么是高性能动画呢?

动画帧率可以作为衡量标准,一般来说画面在 60fps 的帧率下效果比较好。

fps对比:对我们的眼睛来说30fps感觉流畅,60fps更舒服完美
60fps

换算一下就是,每一帧要在 16.7ms (16.7 = 1000/60) 内完成渲染。因此,我们的首要任务是减少不必要的性能消耗。 越多的帧需要渲染的,意味着有越多的任务需要浏览器处理,所以掉帧就出现了,这是达到 60fps 的一个绊脚石。

如果所有动画都无法在 16.7ms 渲染完毕,不如考虑用略低的 30fps 帧率来渲染。

如何实现丝般顺滑

这里主要决定因素有二:

时机(Frame Timing): 新的一帧准备好的时机

成本(Frame Budget): 渲染新的一帧需要多长的时间

开始绘制的时机

一般来说我们使用setTimeout(callback, 1/60)来实现16.7ms后执行动画一帧的渲染。 然而setTimeout实际上并不准确。 首先,setTimeout依靠浏览器内置时钟的更新频率 例如:IE8及以前更新间隔为15.6ms,setTimeout(callback, 1/60)为16.7ms,那么它就需要两个15.6ms才会触发,这也意味着无故延迟了 15.6 x 2 - 16.7 = 14.5毫秒。

时钟频率
t01457cd049f86cc7ef

其次,假使能够达到16.7ms,它还要面临一个异步队列的问题。 因为异步的关系setTimeout中的回调函数并非立即执行,而是需要加入等待队列中。但问题是,如果在等待延迟触发的过程中,有新的同步脚本需要执行,那么同步脚本不会排在timer的回调之后,而是立即执行。

function runForSeconds(s) {
    var start = +new Date();
    while (start + s * 1000 > (+new Date())) {}
}

document.body.addEventListener("click", function () {
    runForSeconds(10);
}, false);

setTimeout(function () {
    console.log("Done!");
}, 1000 * 3);

以上的例子是,如果在等待触发延迟的3秒过程中,有人点击了body,那么回调还是准时在3s完成时触发吗? 实践执行的时候,它会等待10s,同步函数总是优先于异步函数。

基于这些问题我们提出了另一个解决方案:requestAnimationFrame(callback)

window.requestAnimationFrame() 方法告诉浏览器您希望执行动画并请求浏览器在下一次重绘之前调用指定的函数来更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。-- MDN

当我们调用这个函数的时候,我们告诉它需要做两件事:

  1. 我们需要新的一帧;
  2. 当你渲染新的一帧时需要执行我传给你的回调函数

与 setTimeout 相比,rAF(requestAnimationFrame) 最大的优势是由系统来决定回调函数的执行时机

具体一点讲就是,系统每次绘制之前会主动调用 rAF 中的回调函数,如果系统绘制率是 60Hz,那么回调函数就每16.7ms 被执行一次,如果绘制频率是75Hz,那么这个间隔时间就变成了 1000/75=13.3ms。

换句话说就是,rAF 的执行步伐跟着系统的绘制频率走。它能保证回调函数在屏幕每一次的绘制间隔中只被执行一次(函数节流,这篇文章就不细说了,感兴趣的可以查一下),这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题。

另外它可以自动调节频率。如果callback工作太多无法在一帧内完成会自动降低为30fps。虽然降低了,但总比掉帧好。

同时对比使用 setTimeout 实现的动画,当页面被隐藏或最小化时,setTimeout 仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,而且还浪费 CPU 资源。而 rAF 则完全不同,当页面处理未激活的状态下,该页面的屏幕绘制任务也会被系统暂停,因此跟着系统步伐走的 rAF 也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了 CPU 开销。


对于rAF的兼容性问题其实已经有了很好的处理方案了,以下是一种比较简单的:

window.requestAnimFrame = (function(){
 return  window.requestAnimationFrame   || 
   window.webkitRequestAnimationFrame || 
   window.mozRequestAnimationFrame    || 
   window.oRequestAnimationFrame      || 
   window.msRequestAnimationFrame     || 
   function( callback ){
        window.setTimeout(callback, 1000 / 60);
   };
})();

这种写法没有考虑 cancelAnimationFrame 的兼容性,并且不是所有的设备绘制时间间隔都是1000/60。

这个是比较不错的polyfil

绘制一帧的时间

总的来说,rAF解决了前面的第一个问题(绘制时机),至于第二个问题(绘制成本),rAF是无能为力的,最多也就是采取自动降低频率的方式处理。

这里就需要从浏览器渲染方面来优化了,首先看下这个图:

渲染过程
t01018eff532098fece

Rendering

页面首次加载时,浏览器会下载并解析 HTML,将 HTML 元素转变为一个 DOM 节点的「内容树」(content tree)。除此之外,样式同样会被解析生成「渲染树」 (render tree)。为了提升性能,渲染引擎会分开完成这些工作,甚至会出现渲染树比 DOM 树更快生成出来。

在这个阶段里最影响绘制时间的自然就是Layout了

// animation loop
function update(timestamp) {
    for(var m = 0; m < movers.length; m++) {
        // DEMO 版本
        //movers[m].style.left = ((Math.sin(movers[m].offsetTop + timestamp/1000)+1) * 500) + 'px';

        // FIXED 版本
        movers[m].style.left = ((Math.sin(m + timestamp/1000)+1) * 500) + 'px';
        }
    rAF(update);
};
rAF(update);

上面例子里DEMO版本是非常慢的,之所以慢的原因是,在修改每一个物体的left值时,会请求这个物体的offsetTop值,触发了重排,这是一个非常耗时的reflow操作。

通常我们会不知不觉中写了很多的频繁layout的代码,例如:

var h1 = element1.clientHeight;
element1.style.height = (h1 * 2) + 'px';

var h2 = element2.clientHeight; 
element2.style.height = (h2 * 2) + 'px';

var h3 = element3.clientHeight;
element3.style.height = (h3 * 2) + 'px';

不断地读写 DOM 会导致「强制同步布局」(forced synchronous layouts),不过在技术发展过程中它演变成了更形象的词 — 「布局抖动」(layout thrashing)(详情可以看一下这篇文章 layout thrashing)。 浏览器会追踪「脏元素」,在合适的时候将变换过程储存起来,然后在读取了特定属性以后,开发者可以强制浏览器提前计算,这样反复的读写会导致重排。 所以这里我们需要进行优化,先读后写就是一个解决方案,上面的代码可以改写为:

// Read
var h1 = element1.clientHeight;
var h2 = element2.clientHeight;
var h3 = element3.clientHeight;

// Write
element1.style.height = (h1 * 2) + 'px';
element2.style.height = (h2 * 2) + 'px';
element3.style.height = (h3 * 2) + 'px';

当然这种只能应对一些普通的情况,如果代码是解耦的或者更复杂的读写后嵌套读写操作的这些情况可以使用一些比较成熟的解决方案,例如fastdom.js。另外一个小技巧是使用rAF来延迟全部的写操作到下一帧执行也是很不错的解决方案。

Paint

生成布局后,浏览器将页面绘制到屏幕上。这个环节和前一个步骤类似,浏览器会追踪脏元素,将它们合并到一个超大的矩形区域中。每一帧内只会发生一次重绘,用于绘制这个被污染区域。

这个阶段对性能的影响主要在于重绘。

减少不必的绘制

例如,gif图即使不可见,也可能导致paint,不需要时应将gif图的display属性设为none 在经常paint的区域,要避免代价太高的style 代价比较高的样式:

color,border-style,visibility,background,
text-decoration,background-image,
background-position,background-repeat
outline-color,outline,outline-style
border-radius,outline-width,box-shadow
background-size

参考网站:csstriggers.com/

减少绘制的区域

为引起大范围Paint的元素生成独立的Layer以减小Paint的范围

可以参考一下这个demo网站,绿色部分为重绘区域:

demo网站截图

Composite

将所有绘制好的元素进行复合。 默认情况下,所有元素将会被绘制到同一个层中,如果将元素分开到不同的复合层中,更新元素对性能友好,不在同一层的元素不容易受到影响。

这一阶段里CPU 绘制层,GPU 生成层。GPU 复合层上的改变代价最小性能消耗最少。所以这里的优化主要就是把代价高的改动都放到GPU上,也就是一般说的开启硬件加速技术,可以说有益无害,如果设备的性能足够开启就对了。

这里的限制主要有:GPC和CPU之间带宽,GPU的限度。


这里需要区分一下CPU,GPU的工作:

enter description here
t01918abbc87b4f13ba
CPU工作比较多,还分主线程和合成线程。 主线程主要负责:

  1. Javascript 的计算与执行
  2. CSS 样式计算
  3. Layout 计算
  4. 将页面元素绘制成位图(paint),也就是光栅化(Raster)
  5. 将位图给合成线程

合成线程则主要负责:

  1. 将位图(GraphicsLayer 层)以纹理(texture) 的形式上传给 GPU(GPC和CPU之间带宽)
  2. 计算页面的可见部分和即将可见部分(滚动)
  3. CSS 动画处理(CSS 动画而言,由于其流程不受主线程的影响,所以性能更好。)
  4. 通知 GPU 绘制位图到屏幕上

而GPU就只需要绘制图层了,所以硬件加速的性能无疑更好。


开启硬件加速的方式主要有:

  1. 通过改变 opacitytransform 的值触发
  2. 通过transform的3D属性强制开启GPU加速
  3. will-change显式地通知浏览器对某一个元素的某个或某些元素做渲染优化

硬件加速之后,浏览器会为此元素单独创建一个“层”。当有单独的层之后,此元素的repaint操作将只需要更新自己,不用影响到别人。你可以将其理解为局部更新。所以开启了硬件加速的动画会变得流畅很多

默认情况下,transformopacity这类css属性CPU是直接通知GPU来做处理的,因为GPU能快速对texture(纹理:CPU传输到GPU的一个Bitmap)进行偏移、缩放、旋转、修改透明度等操作,不经过主线程的layout、paint过程。也就是开启了硬件加速。

will-change是个新事物,它能够显式地通知浏览器对某一个元素的某个或某些元素做渲染优化。 will-change 接收各种各样的属性值,比如一个或多个 CSS 属性 (transform, opacity)、contents 或者 scroll-position。不过最常用值可能就是 auto,这个值表示的是浏览器将进行默认的优化:


GPU虽然擅长处理图像,但是它也有瓶颈。

连接CPU和GPU之间的带宽是有限的,如果一次更新的层太多,则很容易就达到GPU的瓶颈,影响到动画的流畅度。所以我们需要控制层的数量和层paint的次数。

控制层的数量可以理解,因为层的创建和更新都会消耗内存。而控制层paint的次数,是为了减少位图更新的次数。每次位图更新,合成线程就需要提交新的位图给GPU。频繁地更新位图也会拖慢GPU的效率。

优化有度,我们总能听到关于「复合层过多反而阻碍渲染」的讨论。因为浏览器已经为优化做了能做的一切, will-change 的性能优化方案本身对资源要求很高。如果浏览器持续在执行某个元素的 will-change,就意味着浏览器要持续对这个元素的进行优化,性能消耗造成页面卡顿。

过多的复合层降低页面性能的现象在移动端很常见。


避免意外生成的layer

z-index高于Layer的元素,也会生成单独的Layer

demo以及说明页面

小结

实现丝般顺滑主要决定因素有二:

时机(Frame Timing):

  • rAF

成本(Frame Budget):

  • 避免layout:先读后写

  • 尽量少paint:注意样式的使用

  • 适当的硬件加速