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

Tom Wiltzius
Tom Wiltzius

简介

您希望 Web 应用在执行动画、转场和其他小型界面效果时能够快速响应且流畅。确保这些效果不会出现卡顿,这可能会决定应用是呈现“原生”感觉,还是笨拙、粗糙的感觉。

这是介绍浏览器渲染性能优化的系列文章中的第一篇。首先,我们将介绍实现流畅动画效果的难度以及实现此效果需要满足什么条件,并介绍一些简单的最佳实践。其中许多想法最初是在今年 Google I/O 大会上 Nat Duca 和我共同发表的“Jank Busters”(卡顿终结者)演讲(视频)中提出的。

推出 V-sync

PC 游戏玩家可能对这个术语很熟悉,但在 Web 上却不常见:什么是垂直同步

想想手机的显示屏:它会按固定的时间间隔刷新,通常(但不一定!)每秒大约 60 次。V 同步(或垂直同步)是指仅在屏幕刷新之间生成新帧的做法。您可以将其视为将数据写入屏幕缓冲区的进程与操作系统读取该数据以将其显示在屏幕上之间的竞态条件。我们希望缓冲的帧内容在刷新之间更改,而不是在刷新期间更改;否则,显示器将显示一帧的一半和另一帧的一半,从而导致“画面撕裂”。

为了获得流畅的动画效果,您需要在每次屏幕刷新时准备好新帧。这有两个重要影响:帧时间(即帧需要在什么时候准备就绪)和帧预算(即浏览器生成帧所需的时间)。您只有在屏幕刷新之间的时间来完成一帧(在 60Hz 屏幕上约为 16 毫秒),并且希望在上一帧显示在屏幕上后立即开始生成下一帧。

时机就是一切:requestAnimationFrame

许多 Web 开发者每 16 毫秒使用一次 setIntervalsetTimeout 来创建动画。这会导致各种问题(我们稍后会详细讨论),但特别值得注意的是:

  • JavaScript 中的计时器分辨率仅为几毫秒级
  • 不同设备的刷新率不同

回想一下上面提到的帧时间问题:您需要在下次屏幕刷新之前完成动画帧,并完成所有 JavaScript、DOM 操作、布局、绘制等工作。计时器分辨率较低可能会导致在下次屏幕刷新之前无法完成动画帧,但由于屏幕刷新率存在差异,因此固定计时器无法实现这一点。无论计时器间隔时间如何,您都会慢慢偏离帧的计时范围,最终丢失一个帧。即使计时器以毫秒级精度触发,这种情况也会发生(正如开发者发现的那样),因为计时器分辨率因机器是使用电池还是插接电源而异,还会受到后台标签页占用资源等因素的影响。即使这种情况很少见(例如,每 16 帧出现一次,因为您误差了 1 毫秒),您也会注意到:每秒会丢失几帧。您还需要生成永远不会显示的帧,这会浪费电量和 CPU 时间,而这些时间本可以用于在应用中执行其他操作。

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

在讨论渲染性能时,我们往往会关注每秒帧数 (FPS),但方差可能是一个更大的问题。我们的眼睛会注意到动画中不规则的小卡顿,而动画时间不当可能会导致这种情况。

如需获取时间正确的动画帧,请使用 requestAnimationFrame。使用此 API 时,您会请求浏览器提供动画帧。当浏览器即将生成新帧时,系统会调用您的回调。无论刷新率如何,都会发生这种情况。

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

  • 后台标签页中的动画会暂停,从而节省系统资源和延长电池续航时间。
  • 如果系统无法以屏幕的刷新率处理渲染,则可以节流动画,并降低回调的生成频率(例如,在 60Hz 屏幕上每秒 30 次)。虽然这会将帧速率减半,但可以保持动画的一致性。正如前面所述,我们的眼睛对变化的敏感度远高于对帧速率的敏感度。稳定的 30Hz 效果要比每秒丢失几帧的 60Hz 好。

requestAnimationFrame 已经被广泛讨论,因此请参阅 Creative JS 中的这篇文章等文章,详细了解 requestAnimationFrame,但它是实现流畅动画的重要第一步。

帧预算

由于我们希望在每次屏幕刷新时都准备好新帧,因此只有在刷新之间的时间可以执行创建新帧的所有工作。在 60Hz 显示屏上,这意味着我们只有大约 16 毫秒的时间来运行所有 JavaScript、执行布局、绘制以及浏览器必须执行的任何其他操作,以便输出帧。这意味着,如果 requestAnimationFrame 回调中的 JavaScript 运行时间超过 16 毫秒,您就无法及时生成帧以进行 v-sync!

16 毫秒的时间并不长。幸运的是,Chrome 的开发者工具可以帮助您跟踪在 requestAnimationFrame 回调期间是否超出了帧预算。

打开开发者工具时间轴并录制此动画的运行情况后,我们很快发现动画的帧速率远远超出了预算。在时间轴中,切换到“帧”,然后查看:

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

这些 requestAnimationFrame (rAF) 回调的用时超过 200 毫秒。这比每 16 毫秒刷新一帧的时间长了几个数量级!打开其中一个长 rAF 回调即可了解内部发生的情况:在本例中,有很多布局。

Paul 的视频详细介绍了重新布局(正在读取 scrollTop)的具体原因以及如何避免此问题。但重点是,您可以深入研究回调,调查耗时较长的原因。

布局大大简化的更新版演示
更新后的演示,布局大大简化

请注意 16 毫秒的帧时间。帧中的空白空间就是您可以用来执行更多工作(或让浏览器在后台执行其需要的工作)的余量。这个空白区域是好事。

卡顿的其他来源

尝试运行由 JavaScript 驱动的动画时出现问题的主要原因是,其他内容可能会干扰 rAF 回调,甚至阻止其运行。即使您的 rAF 回调很精简,并且只需几毫秒即可运行完毕,其他 activity(例如处理刚刚传入的 XHR、运行输入事件处理脚本或在计时器上运行定期更新)也可能会突然出现并运行一段时间,而不会让出。在移动设备上,处理这些事件有时可能需要数百毫秒的时间,在此期间您的动画将完全停止。我们将这些动画卡顿称为卡顿

虽然没有万灵丹可以避免这些情况,但有一些架构最佳实践可以帮助您取得成功:

  • 请勿在输入处理程序中执行大量处理!在 onscroll 处理脚本等过程中执行大量 JS 或尝试重新排列整个页面,是导致严重卡顿的一个非常常见的原因。
  • 尽可能将大量处理(即任何需要很长时间才能运行的操作)推送到 rAF 回调或 Web Worker
  • 如果您将工作推送到 rAF 回调,请尝试将其分块,以便每帧只处理一点点,或者延迟到重要动画结束后再处理 - 这样,您就可以继续运行短 rAF 回调并流畅地呈现动画。

如需查看介绍如何将处理推送到 requestAnimationFrame 回调(而非输入处理程序)的实用教程,请参阅 Paul Lewis 的文章 Leaner, Meaner, Faster Animations with requestAnimationFrame(使用 requestAnimationFrame 制作更精简、更强大、更快速的动画)。

CSS 动画

在事件和 rAF 回调中,哪种方式比轻量 JS 更好?不使用 JS。

我们之前曾说过,没有万灵丹可以避免中断 rAF 回调,但您可以使用 CSS 动画来完全避免使用回调。特别是在 Android 版 Chrome 上(其他浏览器也在开发类似功能),CSS 动画具有非常理想的特性,即即使 JavaScript 正在运行,浏览器通常也可以运行它们。

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

请尽可能使用 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 动画,甚至适用于静态页面上的滚动。在本系列的下一篇文章中,我们将结合这些概念深入探讨滚动性能。

祝您动画制作顺利!

参考