【YMFE】How To Reach 60FPS (写的很是棒,网上转的)

王钰css

2016 年加入 Qunar,目前在去哪儿网平台事业部前端架构组(YMFE)任前端工程师一职。欢迎访问团队博客YMFE(http://ymfe.tech)查看更多技术...前端

不久前我在 YMFE Conf 上分享了关于构建流畅动画相关的内容,这篇文章是我分享内容的文字版,你能够在这里()看到对应的 PPT。浏览器

什么是 fps,60fps 意味着什么?前端工程师

fps(frames per second),指一秒内屏幕刷新的次数或者动画在一秒内更新的帧数。现代浏览器大多每秒刷新 60 次,为了和设备的刷新频率保持一致,动画也要保证每秒 60 更新帧。若是低于 60 fps,称动画发生了掉帧,若是掉帧严重,用户则可以明显地感受到卡顿。高的帧率,意味着更连贯的动画,更流畅的滚动,这些老是能带来极好的用户体验。架构

本文首先谈了谈现代浏览器的渲染流程,并结合各个流程谈了一下构建流程动画的技巧和注意事项。函数

本文的结构以下:工具

  • 从 HTML/CSS 到 Web 页面
  • 浏览器在每一帧中要作的工做
  • 构建流程动画的技巧和注意事项

从 HTML/CSS 到 Web 页面布局

要想高效地操做DOM, 完成流畅的动画,须要了解浏览器是如何将 HTML/CSS/Java 等资源渲染为 Web 页面的。下面就此过程进行描述:性能

浏览器接收到 HTML 文档后就会开始解析文档,并创建 DOM 树 (Document Object Model Tree),DOM 树中记录了当前文档的全部节点。同时浏览器使用内联的 style 标签或者外部加载的 CSS 文档来构建 CSSOM 树(CSS Object Model Tree),CSSOM 树中记录了各个节点的样式规则。随后联合 DOM 树和 CSSOM 树构建出渲染树(Render Tree),渲染树中记录了当前页面中全部可见节点的实际样式。之因此说实际样式,是由于 CSS 中可能出现width: 50%或color: inherit这样的写法,浏览器须要自顶向下地去根据父节点来计算出某个节点的实际样式。测试

整个步骤,以下图所示:

△渲染树的构建过程(图片来自 Chrome developer)

  • DOM 树:记录了文档的结构与内容
  • CSSOM 树:记录了 DOM 节点的样式规则
  • Render 树:表示 DOM 中每一个节点真实的样式

获得了渲染树,浏览器还不能开始进行绘制,由于页面上存在太多元素,若是页面中有一个元素被改变,这个时候若是重绘整个页面就显得很浪费,毕竟不少时候只是很小的一部分被改变了。浏览器为了高效地绘制,提出了图层(layer)的概念,按照某些规则将 DOM 节点划分在不一样的图层中,这样一个节点的改变,浏览器会智能地去重绘那些受到影响的图层,而非全部图层,浏览器绘制的时候是以图层为单位的。

细分后的过程,大体是这样:

△Web 页面渲染流程

绘制过程就是浏览器调用绘图 API 来完成图层的绘制,绘制过程就是填充像素的过程,浏览器会调用一些相似于moveTo, lineTo 这样的绘图 API,将 各图层绘制出来,获得一些像素点的集合,相似于一张位图(bitmap),这些位图随后被上传至 GPU,GPU 帮助浏览器将这些位图合并起来,获得最终显示在屏幕上的图片。

综上,浏览器渲染出 Web 页面的过程,大致可分为如下几个步骤:

  1. 解析 HTML/CSS 生成 DOM 树 CSSOM 树
  2. 联合 DOM 树和 CSSOM 树获得渲染树
  3. 将 Render 树划分为多个图层,并绘制图层
  4. 将各图层的数据上传至 GPU
  5. GPU 合并图层获得最终展现在屏幕上的图片

能够想象浏览器内部实现本来以上论述复杂千万倍,以上也只是从很是宏观的角度去描述了浏览器渲染页面的过程。其中还没牵扯到 Java,不过知道以上这些内容,起码对浏览器的渲染流程有了一个大致的认识。

浏览器在每一帧中要作的工做

Java 经过 API 来修改 DOM 树和 CSSOM 树,CSS 中的 animation 或 transition 都会改变渲染树,每当渲染树被改变后,浏览器都须要从新计算样式,样式计算会涉及多个 DOM 节点,由于有些样式存在继承关系,还有则是相对父节点的。

每一帧中浏览器都可能要进行下列部分或所有步骤:

△每一帧浏览器可能要进行的工做

对上图中的各个步骤进行一个简要的解释说明:

  • Java:运行 Java 代码,期间可能会添加 DOM 节点,修改节点的样式等,这会影响 DOM 树和 CSSOM 树,最终影响渲染树。另外 CSS 动画和 CSS 过渡都会修改渲染树。
  • Recalculate Style:这个节点会根据 CSS 选择器来计算节点的最终样式。
  • Layout:一旦知道了各个节点关联的样式,这儿时候就能计算节点的实际尺寸以及其在屏幕上的位置,由于可能牵扯继承和相对单位,所以一个节点的改变可能会影响多个节点,好比修改了<body>的宽度,下面不少元素都会受到影响。
  • Update Layer Tree:Layer Tree 中记录了各个图层之间的层叠关系,这会影响最终谁那些元素在上那些元素在下。
  • Paint:填充像素,将图层上的文字、边框、阴影等绘制出来,绘制是基于图层的,绘制须要绘制的图层,最终获得一张位图,其中记录了当前图层的视觉表现。
  • Composite Layer:获得图层之后须要将其按照正确的层叠关系合并起来,最终获得一整块须要显示在屏幕上图片。

在 Chrome DevTools 能够清楚地看到这几个步骤:

部分步骤能够被跳过

若是修改了一个会影响元素的尺寸或位置的属性,好比 width 和 height 或者 top 等,须要从新进行 Layout 操做,随后会进行重绘,随后将图层合并获得新一帧。这就会执行以上的全部步骤。

但若是只是修改了 color 这样的不涉及节点尺寸或定位的属性,则不须要执行 Layout 这一步骤。由于 color 的修改,并不会影响元素的尺寸和位置,只须要进行一次重绘就行了,此时以上步骤中的 Layout 就被跳过了。

△不须要重排

一样的,若是修改了一个都不须要进行重绘的属性,那么能够跳过 Layout 和 Paint 这两个步骤,此时只须要要进行图层的合并操做就能获得新一帧的图片。

△不须要重排和重绘

不须要进行重排(Layout)和重绘(Paint)操做,天然会耗时更短,每一帧中浏览器须要进行的工做也就越少,必定程度上也就可以提高性能。由此看来对 DOM 树的修改、对 DOM 节点属性或样式的修改,须要付出的代价是不一样的,某些操做可能会触发重排和重绘操做,而有些操做则能够彻底跳过以上步骤。

规律

不过也能够得出以下的一个规律:

  • Layout:涉及到 DOM 操做,DOM 节点的尺寸、位置的属性的修改会触发 layout 进而会致使 repaint(重绘)和图层合并。好比修改 width,margin,border 等样式,或者修改 clientWidth 等属性。
  • Paint:涉及 DOM 节点的颜色的属性会致使重绘,好比 color,background,box-shadow 等
  • Composite:目前经常使用的 CSS 属性中,对opacity,transform,filter这三个属性的修改只须要进行 Composite 操做。这几个属性的改变,GPU 只须要在合并图层以前对图层进行一些变换,好比 opacity 属性的改变,GPU 只须要在合并以前改变图层的 alpha 通道。其余两个属性的修改,GPU 也能够直接进行一些矩阵运算获得变换后的图层。

参考资料

paul irish 罗列了那些操做会触发重排,你能够在这里看到:What forces layout / reflow()

另外在https://csstriggers.com/这个...,Chrome 团队的一伙人列出了对 CSS 各属性的修改会引起以上那些操做。

在实践中能够时刻参考这两个列表,并结合调试工具,来避免没有不要的重排和重绘。

构建流程动画的技巧和注意事项

前面介绍了很多关于浏览器渲染过程的基础知识,旨在帮助对此不清楚的朋友从宏观上理清楚 Web 页面的渲染过程。

实现连贯的动画,流畅的滚动,了解以上基础知识对后续编码、优化有着巨大的好处。下面根据浏览器渲染原理,结合每一帧的浏览器须要作的各个步骤,给出了一些切实可行的优化方案,并提出一些注意事项。

后面的内容我想分 5 个点来介绍,分别是:

  1. 避免没有必要的重排
  2. 避免没有必要的重绘
  3. 利用 GPU 加速渲染
  4. 构建更为流畅的动画
  5. 正确地处理滚动事件

1 避免没有必要的重排

每一个前端工程师在入门的时候,都被告知 DOM 很慢,使用脚本对 DOM 进行操做的代价很昂贵,要批量修改 DOM 等等,关于 DOM 操做的话题已经有很多著做进行过论述了。强烈推荐《高性能 Java》()这本书,我以为这本书应该是前端工程师必读。

虽然说已经有不少关于 DOM 操做的内容了,这里我仍是想提一个注意事项:避免强制性同步布局,由于我常常看到这个字眼,不妨提出来谈谈。

避免强制性同步布局

强制性同步布局(forced synchonous layout),发生在使用 Java 改变了 DOM 元素的属性,然后又读取 DOM 元素的属性的时候,一般也说读取了脏 DOM 的时候。好比改变了 DOM 元素的宽度,然后又使用clientWidth 读取 DOM 元素的宽度。这个时候为了获取到 DOM 元素真实的宽度,须要从新计算样式。也就是会从新进行计算样式(Recalculate Style)和计算布局( Layout)操做。

设想如下案例,有一组 DOM 元素,须要将其其高度设为与宽度一致,新手很快就能写出如下代码:

解决方案 1 - 简单粗暴:

执行这段代码的时候,每次迭代开始的时候,DOM 都是脏的(被改动过),为了得到真实的 DOM 尺寸,都会从新计算布局。该循环就会引起屡次强制性同步布局,这是很低效的作法,千万要避免。

△引起了强制性同步布局

从 Chrome DevTools 中很容易地发现该低效操做,能够看到浏览器进行了不少次的从新计算样式(Recalculate Style)和布局(Layout),也叫作 reflow(重排)的操做,且这一帧用时很长。

解决方案 2 - 分离读和写:

能够很轻松地解决这个问题,使用两次循环,在第一次循环中读取 DOM 元素宽度并将结果保存起来,在第二个循环中修改 DOM 元素的高度。

△分离读写后

分离读写,一个时刻只读取,另外一个时刻只改写,这样就能颇有效地避免强制性同步布局。

在实际项目中每每没有上面提到的那样简单,有时尽管已经分离了读和写,但在写操做后面仍是不可避免地存在读取操做,这个时候不妨将写操做放在requestAnimationFrame中,浏览器会在下一帧执行这个对 DOM 的改写操做。关于requestAnimationFrame后文有详细的讲解。

补充资料

  • 《高性能 Java》- Nicholas C.Zakas ()中讲解了更多关于 DOM 操做的内容,包括如何最小化重绘与重排,如何高效实用 CSS 选择器等。
  • What forces layout/reflow(),这个 Gist 中列出了那些操做会致使强制性同步布局。

2 避免没有必要的重绘

在开始以前须要回顾一下何时须要重绘:

  1. 当 DOM 节点的会触发重绘的属性(color,background 等)被修改后,会进行重绘
  2. 当 DOM 节点所在的图层中其余元素的会触发重绘的属性被修改后,整个层会被重绘
  3. 图片加载完成后会发生重绘,GIF 图片的每一帧都会发生重绘

在 Chrome DevTools 的 Rendering 选择卡中勾选 Painting Flashing 选项后,能够观察到页面上正在进行重绘的区域。

避免 fixed 定位元素在滚动时重绘

一个常见的场景是,网页有一个 fixed 定位的头部导航栏或者侧边栏。问题存在于每次滚动后,这些 fixed 定位的元素相对于整个内容区域的位置改变了。这就至关于一个图层中的某个元素的位置改变了,为了得到滚动后的图层,须要进行重绘,所以每次滚动都会进行重绘操做。

举个例子,在腾讯网首页上有以下 fixed 定位的元素:

不幸的是这几个 fixed 定位的元素和整个网页位于同一个图层:

滚动后,由于定位元素相对于整个文档的位置发生了改变,所以整个文档都须要被重绘。解决此类问题的方法就是将 fixed 定位的元素提高至单独的图层。使用transform:translateZ(0);这样的写法,能够强制将元素提高至单独图层,关于此后文中还有详细说明。

注:Chrome 在高 dpi 的屏幕上会自动将 fixed 定位的元素提高至单独的图层,在低 dpi 的屏幕上不会提高,所以不少开发者在 MacBook Pro 上测试的时候,不会发现问题,但用户在低 dpi 的屏幕上访问的时候就出问题了。

将部分元素提高至单独图层,避免大面积重绘

使用 transform:translateZ(0); 这样的 CSS hark 写法会将元素提高至单独的图层。在这么作以前要考虑为何要这样作,建立新的图层的目的应该是,避免某个元素的改变致使大面积重绘,好比某个小标签的颜色的改变,致使大面积重绘,所以将其提高至单独的图层中。

这是一个面板,其中内容区域的文字会不断地闪烁(文本的颜色会改变),若是将该文本使用transform:translateZ(0); 提高至单独的图层,那么文本的颜色改变,就只会致使它所在的图层重绘,而不须要整个面板重绘。这是正确地利用 transform:translateZ(0); 的方式。所以,若是页面中存在小面积的 DOM 节点须要频繁地重绘,能够考虑将其提高至单独的图层中。你能够在这里看到 demo —— 避免大面积重绘()。

正确地处理动图

页面加载的时候为了更好的用户体验经常会使用一个 loading,但在页面加载完成后如何处理 loading 呢?一个错误的方法是将其 z-index 设置一个更小的值,将其隐藏起来,不幸的是就算 loading 不可见,浏览器依然会在每一帧对它进行重绘。所以对于像 loading 这样的动态图,在不须要显示的时候最好使用display:none或者visibility: hidden;来完全隐藏,或者干脆移除 DOM。

3 利用 GPU 加速网页渲染

前端工程师应该都据说过硬件加速,一般是指利用 GPU 来加速页面的渲染。早期浏览器彻底依赖 CPU 来进行页面渲染。如今随着 GPU 的能力加强和普及,且目前绝大多数运行浏览器的设备上都集成了 GPU。浏览器能够利用 GPU 来加速网页渲染。

GPU 包含几百上千个核心,但每一个核心的结构都相对简单, GPU 的结构也决定了它适合用来进行大规模并行计算。进行图层合并须要操做大量的像素,这方面 GPU 能比 CPU 更高效的完成。这里有个视频(),很清楚地说明 CPU 与 GPU 的差异。

经常看到有文章指出使用transform:translateZ(0);这样的 hark 能够强制开启硬件加速来提升性能,这是错误的说法。下面就来讲说硬件加速的实质。

何为硬件加速

GPU 可以存储必定数量的纹理(texture),也就是一个矩形的像素点集合。一般这个集合会对应到 Web 页面上的某个图层,GPU 可以高效地对这些像素点进行多种变换(位移、旋转、拉伸)操做。在实现动画的时候,利用 GPU 的这一特性,若是只须要对原像素集合在 GPU 内进行一次变换,就能获得新一帧的图层,那么动画的全部操做都在 GPU 内高效地完成了,没有重绘操做。

获得了变换后的图层,只须要再进行一次图层的合并,将该变换后的图层和其余图层合并起来,最终获得在屏幕上显示的整幅图片。GPU 的这一特性就经常被称为硬件加速。

要利用硬件加速也是有条件的,盲目地使用transform:translateZ(0);而不知原理,只会让事情变得更糟糕。硬件加速的本质是说让下一帧的图层在 GPU 内通过变换得来,可是若是某些操做 GPU 没法完成,必须动画修改了 DOM 节点的宽度,颜色等,这依然是须要在 CPU 端进行软件的重绘的,这种状况就没法利用硬件加速的机制。

使用transform:translateZ(0);会强制浏览器建立一个新的层,每建立一个层都须要消耗额外的内存,有太多的层就会消耗大量内存,这会致使设备内存不够用,有可能致使应用奔溃。另外这些图层最后须要上传至 GPU 进行图层合并,太多的层,会致使 GPU 和 CPU 之间的带宽不够用,反而影响性能。

目前常见的 CSS 属性中只有 filter, transform, opacity 这几个属性的改变能够在 GPU 端进行处理,这在前面已经提到过了,所以应该尽量使用这些属性来完成动画。

后面会有更多关于利用 GPU 的这一特性的例子,下面先看一个须要注意的点:

避免无谓地新建图层

一个真实案例:

△每一个列表项都是一个图层

这是一个城市选择页,这个页面中的每一项都使用了 transform:translateZ(0); 强制提高至了单独的图层,滚动列表,并录制了一段 Timeline。

△优化前

从上图中能够看到,性能是至关糟糕的,大量时间都花费在了图层的合并上,每一帧都须要合并上千个列表子项,这不是一件很轻松的事情。

为了体现,错误使用 transform:translateZ(0); 的严重性,下面来看看去掉后的效果,去掉该属性后,一片绿,没有任何性能问题。

△优化后

所以在谈起硬件加速的时候,必定知道,什么是硬件加速,硬件加速是如何工做的,它能作什么,不能作什么。合理的利用 GPU 才能利用它帮咱们构建出 60fps 的体验。

4 构建更加流畅的动画

上面讲了,使用 transform 和 opacity 来建立动画(filter 的支持度还不够好)最为高效。所以每当须要用到动画的时候,首先要考虑使用这两个属性来完成。

避免使用会触发 Layout 的属性来进行动画

有时候看起来不太可能使用这两个属性来完成,不过仔细想一想每每可以想到解决方案。考虑下面动画:

demo 地址:expand cord()

通常的想法多是修改每一个卡片的 top, left, width, height 来实现这个功能,这样作固然能够实现效果,只是改变这些属性都会触发 Layout 进而触发 Paint 操做,在复杂应用上势必形成卡顿。下面介绍一种使用 transform 来完成此动画的方法。

以上思路是使用getBoundingClientRect将动画的始态和终态的尺寸和位置计算出来,而后利用 transform 来进行过渡,思路在代码注释中已经进行了说明。

通过这样的处理,本来须要使用top,left,width,height来进行的动画使用transfrom就搞定了,这会大大地提示动画的性能。

使用 transform, filter 和 opacity 来完成动画

使用以上 3 个属性来完成动画,能够避免在动画的每一帧进行重绘。但若是在动画中改变了其余属性,那也不能避免从新绘制。要尽量地利用这几个属性来完成动画。涉及位移的考虑使用 translate,涉及大小的考虑 scale,涉及颜色的考虑 opacity,为了实现流畅的动画要想尽一切办法。

这里给出一个案例,Instagram 的安卓 APP 在登陆的时候,有一个颜色渐变的效果,这种效果经常见到。

△Instagram 登陆页的背景色渐变效果

经过地不断地改变背景颜色能很快地实现,测试后会发如今低端设备上会感到卡顿,CPU 使用率飙升,这是由于修改背景颜色会致使页面重绘。为了避免重绘也能达到一样的效果,咱们可使用两个 div,给它们设置两个不一样的背景色,在动画中改变两个 div 的透明度,这样两个不一样透明度的 div 叠加在一块儿就能获得一个颜色演变的效果,而整个动画只使用了 opacity 来完成,彻底避免了重绘操做。

关于示例,你能够在此处看到: 使用 background 完成渐变 vs 使用 opacity 完成渐变()

不要混用 transform, filter, opacity 和其余可能触发重排或重绘的属性,虽然使用 transform, filter, opacity 来完成动画可以有很好的性能,可是若是在动画中混合使用了其余的会触发重排或重绘的属性,那么依然不能达到高性能。

使用 requestAnimationFrame 来驱动动画

前面提到的动画大可能是使用 CSS 动画 和 CSS 过渡 CSS 动画一般是事先定义好的,没法很灵活地控制,某些时候可能须要使用 Java 来驱动动画。新手经常使用 setTimeout 来完成动画,问题在于使用 setTimeout 设置的回调会在主线程空闲的时候才会调用,想象下面场景:

setTimeout在一帧的中间位置被触发,随后致使从新计算样式进而致使一个长帧。setTimeout/setInterval 主要存在如下局限性:

  1. 在页面不可见的时候依然会调用(耗电)
  2. 执行频率并不固定(一帧内可能屡次触发,形成没必要要的重排/重绘)

setTimeout/setInterval 会周期性的调用,及时当前网页并无在活动。另外由于调用时机不肯定可能引起的在同一帧内屡次调用同一个回调,若是回调中触发了屡次重绘,那么会出如今一帧中重绘屡次的状况,这是没有必要的,且会致使掉帧。

而requestAnimationFrame,一个专门用来驱动动画的 API,它有如下好处:

  1. 保证回调在下一帧调用
  2. 根据机器的刷新频率调整执行频率
  3. 当前网页不可见的时候不执行回调

虽然requestAnimationFrame是一个已经存在不少年的 API 了,可是仍是存在诸多误读,其中最严重的是认为使用requestAnimationFrame可以避免从新布局和重绘,浏览器可以启动优化措施,让动画更流畅,这是错误的,浏览器能保证的仅仅是以上 3 条,在requestAnimationFrame的回调中进行强制同步布局依然会触发重排。

在编写使用 Java 驱动的动画时,使用requestAnimationFrame能够将对 DOM 的写操做放在下一帧进行,这样该帧后面对 DOM 的读取操做就不会引起强制性同步布局,浏览器只须要在下一帧开始的时候进行一次重排。

5 正确地处理滚动事件

现代浏览器都使用一个单独的线程来处理滚动和输入,这个线程叫作合成线程,它可以和 GPU 进行通讯来告诉 GPU 如何移动图层,进行页面的滚动。若是页面上绑定了 touchmove,mousemove 这类事件,合成线程须要等待主线程执行相应的事件监听函数,由于这些函数里面可能会调用 preventDefault 来阻止滚动。

对于优化 scroll,touchmove,mousemove 等事件,其中一个最为重要的建议就是,要控制此类高频事件的回调的执行频率。说到控制频率,天然会想到 debounce 和 throttle 这两个函数。曾一度为止迷惑,不妨简要对这两个函数进行科普:

使用 debounce 或 throttle 控制高频事件触发频率

debounce 和 throttle 是两个类似(但不相同)的用于控制函数在某段事件内的执行频率的技术。

debounce

屡次连续的调用,最后只调用一次

想象本身在电梯里面,门将要关上,这个时候另一我的来了,取消了关门的操做,过了一下子门又要关上,又来了一我的,再次取消了关门的操做。电梯会一直延迟关门的操做,直到某段时间里没人再来。

throttle

将频繁调用的函数限定在一个给定的调用频率内。它保证某个函数频率再高,也只能在给定的事件内调用一次。好比在滚动的时候要检查当前滚动的位置,来显示或隐藏回到顶部按钮,这个时候可使用 throttle 来将滚动回调函数限定在每 300ms 执行一次。

须要注意的是这两个函数的使用方法,它们接受一个函数,而后返回一个节流/去抖后的函数,所以下面第二种用法才是正确的

使用 requestAnimationFrame 来触发滚动事件的回调

若是在事件监听函数中进行了 DOM 操做,这可能会消耗很多时间,事件监听函数执行的时间变长,与 GPU 进行通讯的合成线程也就迟迟接收不到通知,浏览器也就迟迟不知道如何滚动页面,由此引起的就是卡顿。对于这类同步的事件(浏览器等待事件执行完成),能够在事件触发的时候先读取须要获取的 DOM 元素的尺寸位置等信息,而后将其余改写 DOM 的操做安排在 requestAnimationFrame 中完成,浏览器可以更快地执行完事件回调,还能避免后续的读取 DOM 的时候发生重排。

另外,有时候但愿事件在每一帧执行一次,此时是使用 throttle 是没法知足需求的,使用requestAnimationFrame 能够保证每一帧都会调用,须要注意的是有的事件触发的频率多是一帧好几回。所以在使用 requestAnimationFrame 的时候要注意判断是否在一帧内屡次触发了回调。

总结

这两篇文章对浏览器的渲染过程进行了简要描述,而后根据浏览器渲染原理,分析实现流畅的动画须要注意的方方面面,并给出多个实现流畅动画的实用技巧。

不过规则最是不停在改变的,浏览器也不断在更新,一年前是性能瓶颈的点,如今可能已经不是瓶颈了。在开发过程当中应该结合调试工具,去分析每一次重排和重绘,分析各个阶段的耗时,找出真正的问题所在。而不是仅仅记住一些条条框框。

欢迎留言交流或投稿,和咱们一块儿分享知识。

相关文章
相关标签/搜索