总结之优化动画卡顿:卡顿原因分析及优化方案

13,147 阅读16分钟

目录

一、动画卡顿分析

动画卡顿的原因

大多数设备的刷新频率是60次/秒,也就是1秒钟的动画是由60个画面连在一起生成的,所以要求浏览器对每一帧画面的渲染工作要在16ms内完成。当渲染时间超出16ms时,1秒钟内少于60个画面生成,就会有不连贯、卡顿的感觉,影响用户体验。

页面渲染流程

一个页面帧在客户端的渲染分为以下几步:
页面渲染流程 来源:Google

  1. JavaScript:JavaScript实现动画效果,DOM操作等。
  2. Style(样式计算):确认每个DOM元素应用的CSS样式规则。
  3. Layout(布局):计算每个DOM元素最终在屏幕上的大小和位置。由于DOM元素的布局是相对的,所以当某个元素发生变化影响了布局时,其他元素也会随之变化,则需要回退重新渲染,这个过程称之为reflow。
  4. Paint(绘制):在多个层上绘制DOM元素的文字、颜色、图像、边框和阴影等。
  5. Composite(Render Layer合并):按照合理的顺序合并图层并显示到屏幕上。 浏览器在实际渲染页面的时候需要经过一系列的映射,由HTML页面构建出来的DOM树到最终的图层,映射过程如下图(来源:参考[3])所示(注意下图类名在后续有所更改,RenderObject->LayoutObject,RenderLayer->PaintLayer):
    The Compositing Tree
  • Node->RenderObject:DOM树的每个Node都有一个对应的RenderObject(一对一关系,RenderObject包含了Node的内容);
  • RenderObject -> RenderLayer:一个或多个RenderObject对应一个RenderLayer(多对一),RenderLayer用于保证元素之间的层级关系,一般来说位于同一位置的且层级相同的元素位于同一个Render Layer,只有某些特殊的RenderObject会专门创建一个新的渲染层,其他的RenderObject与第一个拥有RenderLayer的祖先元素共用一个。常见的生成RenderLayer的RenderObject拥有以下的一种特征参考[3]
    • 页面根元素
    • 有CSS定位属性(relative, absolute, fixed, sticky)
    • transparent不为1
    • overflow不为visible
    • 有CSS mask属性
    • 有CSS box-reflect属性
    • 有CSS filter属性
    • 3D或硬件加速的2D canvas元素
    • video元素
  • RenderLayer -> GraphicsLayer:一个或多个RenderLayer对应一个GraphicsLayer(多对一),某些被认为是Compositing Layer的RenderLayer单独对应一个GraphicsLayer,其他RenderLayer与第一个拥有GraphicsLayer的祖先元素共用一个GraphicsLayer。每个GraphicsLayer有一个GraphicsContext用于绘制其对应的RenderLayers,合成器将GraphicsContexts的位图合成,最终显示到屏幕上。渲染层提升为合成层的原因如下:
    • 有3D transform属性
    • 有perspective属性
    • 3D canvas或硬件加速的2D canvas
    • 硬件加速的iframe元素(如iframe嵌入的页面有合成层,合成层需要硬件加速)
    • 使用了硬件加速的插件,如flash
    • 对opacity/transform属性应用了animation/transition(当animation/transition为active)
    • 子元素是compositing layer
    • 兄弟元素是compositing layer,与当前的非composting layer有重叠,层级低于当前层
    • 有will-change属性

二、优化方法

在网上可以看到很多的优化方案总结,大佬们都写的很好。

Talk is cheap. Show me the code.

结合页面渲染流程,这里将结合一些测试代码,分析动画的各种优化方案和效果:

  • JavaScript:优化JavaScript的执行效率
    • requestAnimationFrame代替setTimeoutsetInterval
    • 可并行的DOM元素更新划分为多个小任务
    • DOM无关的耗时操作放到Web Workers
  • Style:降低样式计算复杂度和范围
    • 降低样式选择器的复杂度
    • 减少需要执行样式计算的元素个数
  • Layout:避免大规模、复杂的布局
    • 避免频繁改变布局
    • 用flexbox布局替代老的布局模型
    • 避免强制同步布局事件
  • Paint/Composite:GPU加速
    • 将移动或渐变元素由渲染层(RenderLayer)提升为合成层(Compositing Layer)
    • 避免提升合成层的陷阱

JavaScript:优化JavaScript的执行效率

1. requestAnimationFrame代替setTimeoutsetInterval

为什么setTimeoutsetInterval不好?
由于js是单线程执行,所以为了防止某个任务执行时间过长而导致进程阻塞,js中存在异步队列的概念,对于如setTimeoutajax请求都是把进程放到了异步队列中,当主进程为空时才执行异步队列中的任务。所以 setTimeoutsetInterval无法保证回调函数的执行时机,可能会在一帧之内执行多次导致多次页面渲染,浪费CPU资源甚至产生卡顿,或者是在一帧即将结束时执行导致重新渲染,出现掉帧的情况。
requestAnimationFrame是怎么优化的?

  • CPU节能,当页面被隐藏或最小化时,暂停渲染。
  • 函数节流,其循环间隔是由屏幕刷新频率决定的,保证回调函数在屏幕的每一次刷新间隔中只执行一次。

优化效果具体如何?DEMO
通过chrome的performance面板查看具体表现的差别。
通过setTimeout进行了3次渲染,而且有长时间帧出现:
setTimeout
使用requestAnimationFrameDOM操作部分合并,只进行了2次渲染,长时间帧也被优化:
requestAnimationFrame

2. DOM无关的耗时操作放到Web Worker

Web Worker的好处是什么?
JavaScript是单线程的,如果频繁的进行耗时操作(如实时更新数据),就会造成拥堵,影响用户交互体验。Web Worker的作用在于为JavaScript创建了多线程环境,worker线程在后台运行,受主线程控制,两者互不干扰。worker线程负担高延迟且UI无关的任务,主线程负责UI交互就会相对流畅。
需要注意

  • Web Worker无法操作DOM,本质上只是将数据刷新和页面渲染拆开执行。
  • Web Worker遵循同源策略且限制本地访问。
  • 用一次多余的网络请求和浏览器线程资源来换取高效执行。

优化效果具体如何?DEMO
可以通过chrome的performance面板查看具体表现的差别: 不使用web worker,减少了一次网络请求,但是出现了长时间帧,有卡帧的风险。
不使用worker
使用了web worker之后,耗时操作无关的任务不再被阻塞,但是增加了网络延迟。如果在项目中使用worker,初始化时间需要好好斟酌。
使用worker

可考虑的应用场景

  • 轮询服务器获取数据
  • 频繁的数据上报
  • 耗时的数据处理

Style:降低样式计算复杂度和范围

1. 降低样式选择器的复杂度?

降低样式选择器的复杂度是常常被提出的一个优化方法,实际上这个方法的效果比较微弱,根据Ivan Curic的文章[5]的测试方法(DEMO),在一个拥有50000个节点的页面中,不同选择器复杂度对于性能的影响不会超过20ms,而一般情况下,页面的节点数都不会达到这个数量。
优化效果微弱的原因在于浏览器引擎对选择器速度进行了优化,不同引擎的性能优化方案不同,所以开发者的优化是否有效是难以预测的,至少对于静态元素的优化性价比是极低的。
通过测试可以确认的一点是,应当减少伪类选择器和过长的选择器的使用。推荐按照如OOCSS、BEM等命名规范来组织CSS,优点是在微弱优化性能的同时也提高了代码可维护性。

2. 减少需要执行样式计算的元素个数

这一点是针对较早的浏览器而言,较早的浏览器如改变了body元素上的一个类,则其子元素都需要重新计算样式。
现代浏览器都进行了优化,所以优化效果要视具体应用场景而言。目前尚未挖掘到应用例子,后期如有发现回来填坑。

Layout:避免大规模、复杂的布局

1. 避免频繁触发布局

不同的属性导致的渲染成本不尽相同,这一点在css动画时对比尤其明显。触发layout或者paint的动画属性尤其消耗性能,所以应当尽量使用transformopacity作为动画属性,如果无法实现则考虑采用JavaScript实现动画。
性能差别有多大? 以width和transform为例,分别实现动画的性能差别:DEMO
通过width实现动画,帧率较低且曲线抖动明显,右下角也给出了一帧的渲染过程,触发了样式计算,布局,绘制和渲染层合并:
width实现动画
通过transform实现动画,可以发现帧率虽然也低但是平稳,渲染过程只触发了样式计算和、绘制和渲染层合并(仅当元素为合成层时,不会触发绘制。后面将详细讲述):
transform实现动画

2. 用flexbox布局替代老的布局模型

常用的经典布局方案有基于浮动的布局、基于绝对定位的布局,flexbox布局相较而言更加高效。在能用flexbox布局的项目中,尽量用flexbox布局。以下DEMO尝试用三种布局方式渲染一样的界面效果来测试性能:
绝对布局:对于每一个元素都需要唯一的定位坐标,当元素较多时,CSS文件偏大,导致在样式计算上花费了较多的时间。
绝对布局
浮动布局:浮动元素之间定位会互相影响,部分浮动元素也受到文档流影响,导致布局所需时间较长。
浮动布局
弹性布局:对比前两种布局方案而言,性能有较显著的提升。
弹性布局

3. 避免强制同步布局事件

什么是强制同步布局?
前面提到了页面渲染流程是JavaScript->Style->Layout->Paint->Composite,强制同步布局就是强制浏览器在执行JavaScript脚本前先执行布局。
什么情况会导致强制同步布局?
JavaScript运行时,获取到的元素属性样式都是上一帧的数值,所以如果在当前帧的渲染流程中,获取当前帧的某个元素属性之前对该元素进行了修改,浏览器就必须先应用属性再执行JavaScript逻辑,简而言之就是DOM先写后读操作,尤其是连续的读写操作,对浏览器的性能影响更大。 对性能影响有多大?DEMO
DEMO通过改变1000个节点的属性,测试强制同步布局事件对性能的影响,具体参照下图。可以发现性能的损耗是极大的,连续的读写操作导致连续的强制同步事件触发,JavaScript执行时间变得很长:
强制同步布局

Paint/Composite:GPU加速

1. 将移动或渐变元素由渲染层(RenderLayer)提升为合成层(Compositing Layer)

注:可在Chrome的开发者工具的layers面板查看合成层,layers面板打开方法command+shift+p(mac)/ctrl+shift+p(windows) -> show layers 将复杂/频繁变化的元素提升到合成层,这样的好处是该元素绘制的时候不会触发其他元素的绘制。渲染层提升为合成层的原因如下(注意以下原因是在渲染层的基础之上):

  • 有3D transform属性
  • 有perspective属性
  • 3D canvas或硬件加速的2D canvas
  • 硬件加速的iframe元素(如iframe嵌入的页面有合成层,合成层需要硬件加速)
  • 使用了硬件加速的插件,如flash/iframe
  • 对opacity/transform属性应用了animation/transition(当animation/transition为active)
  • will-change属性为opacity、transform、top、left、bottom、right
  • 子元素是compositing layer
  • 兄弟元素是compositing layer,与当前的非composting layer有重叠,composting layer的层级低于非composting layer层

为什么会有性能提升?

  • 只重绘需要重绘的部分
  • GPU加速:合成层的位图直接由GPU合成,比CPU处理速度更快

性能提升有多少? DEMO 通过demo可以看到,提升为合成层之后,paint所需的时间大大减少。
render layer -> compositing layer

提升合成层是不是越多越好?
可以看到提升合成层后,paint时间大大下降。但是合成层的创建需要消耗额外的内存和管理资源,过多的合成层给页面带来的内存开销很大,DEMO创建了5000个元素,全部元素都提升为合成层与不提升时的内存消耗进行对比。这一点在移动端尤其需要注意,相比较于PC,移动设备的内存资源更加紧张。 过多合成层

只提升动画元素的渲染层
基于提升为合成层来提升性能的原理,当页面其他部分绘制比较复杂且相对静态时,我们可以考虑将动画元素单独提升为合成层,减少动画元素对页面其他元素的影响。

2. 避免提升合成层的陷阱

回顾一下提升为合成层的最后一个原因:兄弟元素是compositing layer,与当前的非composting layer有重叠,composting layer的层级低于非composting layer层。
这种情况下导致的提升合成层一般都是预期外的。其原因与屏幕的渲染流程有关,我们回忆一下页面映射的最后一步,每一个Compositing Layer对应一张位图,合成器最后将这些位图根据层级关系合并起来最终输出到屏幕。此时我们假设A是已知的合成层,而B理想中应当是普通渲染层,其层级关系如图所示:
层级陷阱
B作为普通渲染层与父级元素位于同一张位图,A单独在一张位图,此时合并的时候层级就会出现问题,如果直接将B置于A之上,有可能导致层级低于A的B的父元素反而显示在了A之上,反之A,B的层级关系就不对了。浏览器此时的解决方案,就是将B也单独出来作为compositing layer进行渲染,导致了意料外的compositing layer生成。 这种时候第一直觉就是避免重叠的发生不就好了嘛?然而事情并不简单。在查找资料的时候发现了一个神奇宝贝——assumedOverlap。字面意思是假设重叠,对于无法/难以判断是否会与compositing layer重合的某些元素,浏览器假设会发生重叠,提升为compositing layer。
对此浏览器也进行了优化的,通过层压缩(Layer Squashing)处理,将与合成层有重叠且连续多个的渲染层合并为一个合成层。防止由于重叠导致的提升合成层过多,导致的层爆炸(Layer Explosion),可参考DEMO
然而层压缩还是有解决不了的情况,查看源码可以列出以下原因(注意一下都是在重叠/假设重叠的前提下):

  • scrollsWithRespectToSquashingLayer:渲染层相对于压缩层滚动,当滚动的渲染层与合成层重叠时,会有新的合成层生成且无法压缩。DEMO(这个例子不是很好,codepen用iframe嵌入,整个iframe都变成了合成层,如果想看效果可以在本地看)
  • squashingSparsityExceeded:渲染层压缩后会导致压缩层过于稀疏。DEMO
  • squashingClippingContainerMismatch:渲染层和压缩层的裁剪容器(clip container)不同,简单理解就是重叠的渲染层的容器overflow类型不同。DEMO
  • squashingOpacityAncestorMismatch:渲染层与压缩层的继承自祖先的opacity属性不同。DEMO
  • squashingTransformAncestorMismatch:渲染层与压缩层的继承自祖先的transform不同。DEMO
  • squashingFilterAncestorMismatch:渲染层与压缩层的继承自祖先的filter属性不同,或者是渲染层本身有filter属性。DEMO
  • squashingWouldBreakPaintOrder:无法在不打乱渲染顺序的前提下压缩(e.g. 父元素有mask/filter属性,子元素与压缩层overlap,则假如合并了,父元素的mask/filter属性无法局部应用在压缩层,导致渲染结果有误)。DEMO
  • squashingVideoIsDisallowed:video元素无法被压缩。DEMO
  • squashedLayerClipsCompositingDescendants:当合成层是被剪切的子元素时,与之重叠的渲染层无法被压缩。DEMO
  • squashingLayoutPartIsDisallowed:无法压缩frame/iframe/plugin。
  • squashingReflectionDisallowed:无法压缩有reflection属性的渲染层。 DEMO
  • squashingBlendingDisallowed:无法压缩有blend mode属性的渲染层。DEMO
  • squashingNearestFixedPositionMismatch:渲染层的最近fixed元素与压缩层不同,无法被压缩。DEMO

当发现页面明明没有什么内容却比较卡的时候可以检查一下是不是这个原因,以下给出常见的层压缩解决不了的情况:

  1. transform动画的元素,其后的元素为relative/absolute定位
    原因:relative元素和relative下的absolute元素由于assumedOverlap原因都被被提升为合成层,又由于设置了overflow:hidden,基于前面提到的squashingClippingContainerMismatch,渲染层与合成层的裁剪容器不同,导致无法层压缩,出现过多的合成层。 解决方法:为动画的元素设置z-index扰乱compositing layer的排序。DEMO

三、参考

本文结构主要参照文章[1],对其中的一些优化点进行了实际测试和扩展,也算是一篇读后感吧~
关于层压缩部分情况过于复杂,没找到什么资料,感觉还没有完全吃透,后面有机会再重新整理一下。感恩以下大佬!

  1. 深度剖析浏览器渲染性能原理,你到底知道多少? www.jianshu.com/p/a32b890c2…
  2. Optimizing CSS: ID Selectors and Other Myths www.sitepoint.com/optimizing-…
  3. GPU Accelerated Compositing in Chrome www.chromium.org/developers/…
  4. GPU加速是什么 aotu.io/notes/2017/…
  5. Blink Compositing Update: Recap and Squashing docs.google.com/presentatio…
  6. 无线性能优化:Composite taobaofed.org/blog/2016/0…

撒花完结欢迎指教

广告时间

飞书是字节跳动旗下办公套件产品,其将即时沟通、在线协作、音视频会议、日历、云盘、工作台等功能进行了深度整合,为用户提供一站式协作体验。目前,飞书服务的客户已经覆盖了科技互联网、信息技术、制造、建筑地产、企业服务、教育、媒体等多个领域。欢迎投递字节跳动飞书团队,有海量前端后端HC~扫描二维码或者点击链接投递,认准飞书团队👍~
【校招】内推码: HZNVPHS,投递链接: job.toutiao.com/s/JaeUCoc
【社招】投递链接: job.toutiao.com/s/JaevUNo
社招 校招