消除卡顿,实现更好的渲染性能

Tom Wiltzius
Tom Wiltzius

简介

您希望 Web 应用在进行动画、转场和其他小的界面效果时能有响应和流畅的感觉。确保这些特效没有卡顿,是区分“原生”感觉与笨拙、粗糙的感觉。

本文是一系列介绍浏览器渲染性能优化的文章中的第一篇。首先,我们将介绍流畅动画为何难以实现、需要采取什么措施才能实现流畅,以及一些简单的最佳实践。其中很多创意最初发表在“Jank Busters”中,这是 Nat Duca 和我今年在 Google I/O 大会上的演讲(视频)上的演讲。

垂直同步简介

PC 游戏玩家可能熟悉这个术语,但这个词在网络上并不常见:什么是 V-sync?

以手机的显示屏为例:它会定期刷新,通常(但并非总是!)每秒约 60 次。V-sync(或垂直同步)是指仅在屏幕刷新之间生成新帧的做法。您可以将这种情况看作是:将数据写入屏幕缓冲区的进程与读取这些数据以将其显示在屏幕上的操作系统之间的竞态条件。我们希望已缓冲的帧内容在这些刷新操作期间(而不是在刷新期间)发生变化;否则,显示器会显示一半帧,再显示一半帧,从而导致“撕裂”。

为了实现流畅的动画,您需要在每次屏幕刷新时准备好新帧。这会产生两个重大影响:帧时间(即帧需要准备就绪的时间)和帧预算(即浏览器必须在多长时间内生成帧)。您只有在两次屏幕刷新之间间隔时间才能完成一帧(在 60Hz 屏幕上,刷新时间约为 16 毫秒),并且希望在最后一帧显示在屏幕上后立即生成下一帧。

时间就是一切:requestAnimationFrame

许多 Web 开发者每 16 毫秒会使用一次 setIntervalsetTimeout 来创建动画。造成这个问题的原因有很多(稍后我们将进行详细介绍),但需要特别关注的是:

  • JavaScript 中的计时器解析仅约几毫秒
  • 不同设备的刷新频率

回想一下上文提到的帧时间问题:在下一次屏幕刷新之前,您需要一个完整的动画帧,并对其进行任何 JavaScript、DOM 操作、布局和绘制等操作。如果计时器分辨率较低,则可能很难在下次屏幕刷新之前完成动画帧,但屏幕刷新率的变化会使计时器无法实现。无论计时器的间隔时间是多久,您都会慢慢离开帧的计时窗口,最终掉落一帧。即使计时器以毫秒级的精确度触发(这一点是不会发现的,因为开发者已经发现),计时器的分辨率取决于计算机是否使用电池、是否接通了电源、是否受后台标签页占用资源等因素影响。即使这种情况很罕见(例如,每 16 帧一次,因为你差一毫秒),你仍会发现:您还将负责生成从不显示的帧,这样会浪费电量和 CPU 时间,而您可能要在应用中执行其他操作。

不同的显示屏具有不同的刷新频率:60Hz 很常见,但有些手机的刷新频率是 59Hz,有些笔记本电脑在低功耗模式下会降到 50Hz,有些台式机显示器是 70Hz。

在讨论渲染性能时,我们倾向于关注每秒帧数 (FPS),但方差可能是一个更大的问题。我们的眼睛会注意到动画中会出现不规则的微小问题,这是不合时宜的动画产生的。

如需获得正确计时的动画帧,可以使用 requestAnimationFrame。使用此 API 时,您会向浏览器请求一个动画帧。当浏览器即将生成新帧时,系统会调用您的回调。无论刷新频率如何,都会发生这种情况。

requestAnimationFrame 还有其他一些不错的属性:

  • 后台标签页中的动画会暂停,从而节省系统资源和电池续航时间。
  • 如果系统无法处理以屏幕刷新频率进行的渲染,则会限制动画并降低生成回调的频率(例如,在 60Hz 屏幕上每秒 30 次)。虽然这会将帧速率减半,但可以保持动画的一致性;而且如上所述,我们的眼睛对变化的适应比帧速率要高得多。稳定的 30Hz 看起来比 60Hz 好,每秒会漏掉几帧。

前面已经到处讨论了 requestAnimationFrame,请参阅广告素材 JS 的这则等文章了解详情。不过,这是让动画顺畅呈现的重要第一步。

帧预算

由于我们希望每次屏幕刷新时都有新帧,因此只有两次刷新之间的间隔时间来完成创建新帧的所有工作。在 60Hz 的显示屏上,这意味着我们有大约 16 毫秒的时间来运行所有 JavaScript、执行布局、绘制以及浏览器需要执行的任何操作才能弹出框架。这意味着,如果 requestAnimationFrame 回调中的 JavaScript 的运行时间超过 16 毫秒,您就无法及时为 V-sync 生成帧!

16 毫秒并不是很多时间。幸运的是,Chrome 的开发者工具可以帮助您找出在 requestAnimationFrame 回调期间是否用尽了帧预算。

打开开发者工具时间轴,快速录制此动画的实际效果,即可看出我们在进行动画设置时超出了预算。在时间轴中,切换到“帧”并查看以下内容:

布局过多的演示
演示布局过多

这些 requestAnimationFrame (rAF) 回调用时超过 200 毫秒。这么长,太长了,无法每 16 毫秒抽一个帧!打开其中一个较长的 rAF 回调就可以看到里面发生了什么:在本例中,大量的布局。

Paul 的视频更详细地介绍了重新布局的具体原因(这段代码是读取 scrollTop 的),以及如何避免重新布局。但重点在于,您可以深入研究回调并调查什么时间太长。

更新后的演示,布局大幅缩小
经过更新的演示,布局大大缩小

请注意 16 毫秒的帧时间。框架中的空白区域就是您需要做更多工作(或者让浏览器在后台执行所需要的工作)的提升空间。那片空白是一件好事。

其他导致卡顿的原因

在尝试运行由 JavaScript 提供支持的动画时,导致问题的最大原因是其他事项可能会干扰 rAF 回调,甚至会阻碍其运行。即使您的 rAF 回调比较精简并且只运行几毫秒,其他 activity(例如处理刚刚传入的 XHR、运行输入事件处理脚本或对计时器运行预定的更新)也可能会突然进入并运行一段时间,而不会让步。在移动设备上,有时处理这些事件可能需要数百毫秒的时间,在此期间,您的动画将会完全停滞。我们将这些动画卡顿称为卡顿

您无法避免这些情况,但遵循一些架构最佳实践,即可为成功做好准备:

  • 不要在输入处理程序中进行大量处理!执行大量 JS 或试图在滚动处理程序期间重新排列整个页面是导致严重卡顿的一个常见原因。
  • 将尽可能多的处理操作(读取:任何需要很长时间运行的所有内容)推送到 rAF 回调或 Web Worker 中。
  • 如果将工作推送到 rAF 回调中,请尝试对其进行分块,以便只对每帧稍作处理,或将其延迟到重要动画结束之后;这样一来,您就可以继续运行简短的 rAF 回调并顺畅地播放动画。

有关如何将处理推送到 requestAnimationFrame 回调(而不是输入处理程序)的实用教程,请参阅 Paul Lewis 的文章使用 requestAnimationFrame 实现更精巧、更流畅的动画效果

CSS 动画

在事件和 rAF 回调中,什么比轻量级 JS 更好?没有 JS。

我们之前说过,没有一劳永逸的方法可以避免中断 rAF 回调,但您可以使用 CSS 动画来完全不需要它们。特别是在 Chrome(Android 版)浏览器(且其他浏览器也采用了类似功能)中,CSS 动画具有非常理想的特性,即使 JavaScript 处于运行状态,浏览器也能经常运行这些动画。

在上文中,有一个关于卡顿的隐式语句:浏览器一次只能执行一项操作。这并不完全是正确的,但有一个很好的假设:浏览器在任何给定时间都可以运行 JS、执行布局或绘制,但一次只能运行一个。可以在开发者工具的时间轴视图中验证这一点。此规则的一个例外情况是 Chrome(Android 版)(即将支持桌面版 Chrome,但目前尚不支持)中的 CSS 动画。

在可能的情况下,使用 CSS 动画既可简化应用,又可让动画顺畅运行,即使在 JavaScript 运行时也是如此。

  // see http://paulirish.com/2011/requestanimationframe-for-smart-animating/ for info on rAF polyfills
  rAF = window.requestAnimationFrame;

  var degrees = 0;
  function update(timestamp) {
    document.querySelector('#foo').style.webkitTransform = "rotate(" + degrees + "deg)";
    console.log('updated to degrees ' + degrees);
    degrees = degrees + 1;
    rAF(update);
  }
  rAF(update);

如果您点击该按钮,JavaScript 会运行 180 毫秒,导致卡顿。但是,如果我们改用 CSS 动画来驱动该动画,就不会再出现卡顿。

(请注意,在撰写本文时,CSS 动画只会在 Android 版 Chrome 中无卡顿,在桌面版 Chrome 中则不会。)

  /* tools like Modernizr (http://modernizr.com/) can help with CSS polyfills */
  #foo {
    +animation-duration: 3s;
    +animation-timing-function: linear;
    +animation-animation-iteration-count: infinite;
    +animation-animation-name: rotate;
  }

  @+keyframes: rotate; {
    from {
      +transform: rotate(0deg);
    }
    to {
      +transform: rotate(360deg);
    }
  }

如需详细了解如何使用 CSS 动画,请参阅 MDN 上的这篇报道等文章。

小结

简短内容是:

  1. 在添加动画效果时,为每次屏幕刷新生成帧非常重要。通过 Vsync 处理的动画对应用的体验产生了巨大的积极影响。
  2. 如需在 Chrome 和其他现代浏览器中制作经过 vsync 处理的动画,最好的方法是使用 CSS 动画。如果您需要的灵活性超出了 CSS 动画提供的灵活性,最好的技术是基于 requestAnimationFrame 的动画。
  3. 为了让 rAF 动画保持健康愉悦,请确保其他事件处理脚本不会妨碍 rAF 回调的运行,并保持 rAF 回调较短(小于 15 毫秒)。

最后,经过 vsync 处理的动画不仅适用于简单的界面动画,还适用于 Canvas2D 动画、WebGL 动画,甚至静态页面上的滚动。在本系列的下一篇文章中,我们将牢记这些概念,深入探讨滚动性能。

祝您制作动画愉快!

参考编号