动画生万物

一、动画生万物

动画需求可能是很多Android开发者挥之不去的梦魇,是否还记得那些千奇百怪的动画需求,记得那些与视觉埋头苦干的日日夜夜,记得独自一人照着小视频撸动画的心酸历程。不过非常抱歉,熟读本文并非但不能解决上述问题,反而会让上述过程更加痛苦,请意志不坚定者不要轻易尝试。

动画除了可以实现App里的5毛钱特效,实际跟UI性能息息相关。单纯站在UI的角度看,App的本质就是响应用户输入产生UI变化的过程,而UI变化的过程就是广义上的动画过程。

比如说,我们可以把Scroller,computeScroll + invalidate的过程理解成一个逐帧动画;可以把列表滑动,onTouchEvent + offsetTopAndBottom的过程也理解成一个持续性的动画过程;同样我们也可以利用在onLayout来不断改变View的布局状态,形成一个持续性的动画;我们使用的Webp、Gif等动画素材,Lottie、SVGA等动画库,本质不也是一个draw + invalidate的动画过程;同时系统也默默为我们完成了很多动画过程,比如随处可见的水波纹动画、比如Window发生切换时的窗口动画。

有人可能会说,我们的App里不用动画,我们就追求那种一闪而过的快感。那么那些一闪而过的UI变化,严格来说是否也可以理解成只有1帧的动画呢?

总而言之,无论有意无意,大部分UI变化的过程都需要依赖动画来完成,可以说动画过程就是UI变化的过程,优化UI性能本质就是优化动画性能。

真可谓是动画生万物,万物皆动画。


二、动画优化方向

前文说到了,广义上的动画性能优化就是UI性能优化,但本文会更聚焦于狭义上的动画过程,只会涉及一些相关的内容(所以前面的内容大家白看了,我也白写了)。不过,相信掌握动画优化的原理和方法会让理解UI优化事半功倍。

另外,说到Android中的动画实现,首先绕不开是就是Choreographer中现行的这套垂直同步的绘制机制,但关于绘制渲染、垂直同步的介绍,网络上已经有很多讲解了,本文就不再过多赘述,还不熟悉的小伙伴可以自行百度。

进入正题,在垂直同步的机制下,保持UI刷新流畅就只有一条,

每隔16ms把要显示的内容准备好,绘制到屏幕上

这样就引出两个子因素:

a. 绘制的过程要短于16ms;

b. UI线程要保持空闲,及时响应Vsync信号。

对于动画来说,控制动画每一帧的绘制时间,往往就是要求我们选择正确的动画实现,控制最小的动画区域;

而关于第二点,也是动画和普通UI变化最大的不同点,动画过程是一个相对连续的绘制过程,对UI线程空闲更加敏感,动画过程中即使UI线程被短暂占用,也会发生比较明显的丢帧。

(不知道大家有没有体会,关于动画,实际有一个很大的痛点——很多时候,我们之所以需要显示动画,就是想利用动画来掩盖一些耗时操作,但如果这些耗时操作一定要放在UI线程实现(比如inflate新的布局、比如截图),那动画也是无法正常工作的。在这种场景下,你会发现我们根本没有办法来实现平滑的过度,卡顿是必然的。)

根据上面两点,我们可以把动画优化概括成下面二个方向:

1. 使用最合理的实现方式;

2. 排查不必要的UI线程耗时;

同时还有一个事半功倍的优化方向,但在实践中感觉容易被遗漏,所以单列出来,

3. 关注首帧和尾帧绘制。

下文,我们会通过一个优化实例,讲解下这三个方向。不过在此之前,我们先看一下动画的常见实现方式都有哪些。


三、动画天梯榜

如表1,自上而下,性能逐渐衰退。

表1 动画性能天梯榜

从动画实现的原理就可以看出它们性能和能力的差异:

  • 窗口动画和Render Thread动画,都是可以在非UI线程实现的动画,由于前文提到的动画跟UI线程耦合这个痛点,能跟UI线程解耦的动画肯定是性能最好的,后面我们会再简单讨论一下这两种动画;
  • 属性动画,可以广义的理解成可以直接操作View属性——也就是RenderNode对应的Api——的动画,这种动画方式通过直接修改渲染线程的RenderProperty实现,可以避免触发View Tree重绘,也就避免了UI线程绘制的过程,只需要Render线程重新渲染。但这种动画的局限是只能使用RenderNode的Api,只有一些相对基本的矩阵变换,不能实现太过复杂的绘制需求;
  • 补间动画,这是一种比较古老的动画实现,是在硬件加速出现之前,用来优化绘制效率的一种动画形式,它的性能高于直接重绘;但由于代码向下兼容的原因,在硬件加速时代,它的性能会略低于属性动画;
  • Draw动画和帧动画,都是需要利用invalidate触发View Tree重绘的动画方式,这种动画需要触发UI线程重绘,自然也需要Render线程重新渲染,根据View Tree的具体结构,性能上的差异可能会比较大。但draw动画的原理决定了它可以实现所有绘制指令的更新,所以这也是应用最普遍的一种动画形式;
  • Layout动画,是利用requestLayout触发View Tree重新布局的动画,这种动画除了需要UI线程重绘,还需要对View Tree重新measure和layout,尤其布局结构不合理时,耗时是灾难性的。

从榜单也可以看出,往往能力越强的动画性能就越差,这也说明我们还是要根据实际需求来选择最合适的动画。

1、属性动画和补间动画

前文已经提到,补间动画是早期的动画实现,所以设计的初衷是优化非硬件加速下的动画效率。因此在非硬件加速的状态下,实际它的效率才是最高的(高于属性动画和逐帧绘制);但在硬件加速的状态下,它的功能已经被属性动画完美覆盖。所以两者到底怎么选择,还得看具体的情况,不过考虑到目前绝大部分情况下,都是硬件加速绘制,属性动画显然更胜一筹。

补间动画的种种实际跟View Tree的绘制流程息息相关,是了解绘制流程的一个很好的切入点。但在这里就不多讨论了,后续可能会专门写一篇文章来对比两者的差别。另外这两种动画最直观的差异就是它们的基类不同,补间是Animation,属性是Animator。

2、转场动画

在榜单中,有3种转场相关的动画实现,分别是Window切换动画、ActivityOptions转场动画和Fragment切换动画。

Window切换动画不是运行在本进程,是在SystemServer中由WindowManager实现,所以Window的切换动画可以完全不受App的UI线程影响,不过Window切换动画的功能比较有限,使用场景也很固定。

ActivityOptions转场动画是普通Window切换动画的升级版本,由于窗口动画是跨进程实现,所以可支持的功能非常有限;而ActivityOptions转场动画是在本进程实现的,可以支持非常多的动画效果,而且从源码可以看出,ActivityOptions的内部基本都利用属性动画实现,而且用到了部分不太常用的API,大家有兴趣可以阅读一下相关源码,应该会对动画优化很有帮助。

Fragment切换动画主要有两个小问题,一个是它默认的动画实现是补间动画,除非自己去重载Fragment的onCreateAnimator接口,这一点往往不容易被关注,而且很多时候我们也懒于去实现这个接口;另一点是动画start时间偏早,容易发生卡顿。

总之,虽然ActivityOptions转场动画和Fragment切换动画虽然都在不同维度上对Window切换动画进行了补充,但它们的性能表现还是不可同日而语的。很多时候,我们要评估打开一个新的页面是用一个新的Window实现(Activity、Dialog、Pop Window等)还是Add一个Fragment,过度动画的性能可能也是一个值得考虑的因素。

3、Render Thread动画

关于Render Thread动画,在老罗的博客中有比较详细的分析了,大家感兴趣的话可以自行阅读下(Android应用程序UI硬件加速渲染的动画执行过程分析)。我想Render Thread动画的出现,主要是解决前文提到的动画跟UI线程耦合的这一痛点的。

在L版本上,RippleCompount和RevealAnimator这两种动画就率先使用了Render动画;而在N版本上,AnimatedVectorDrawable也改用Render动画实现。

另外不知道大家有没有注意到,原生ProgressBar的默认样式就换成了用AnimatedVectorDrawable实现,可以说基本上解决了前文提到的——UI线程有耗时操作,无法进行Loading动画——这个问题。大家日后使用ProgressBar时,也可以多尝试用AnimatedVectorDrawable来实现。

(为什么水波纹动画也要用Render动画实现,想来也是类似的原因,因为水波纹一般都对应click操作,点击后大概率会触发UI变化,为了让水波纹动画在UI变化(比如inflate)的同时,可以继续扩散,用Render动画也是必然的选择)

总体来说,目前开放的一些API,局限性都比较强,期待Google在后续版本中能开放更多相关API。


四、优化实例

进行了很多原理性的分析,让我们看一个动画优化的实例。我们在实际项目中往往会遇到很多不同的场景,所以这个优化实例主要是抛砖引玉的探讨一下,为什么动画优化一般都可以概括成前文提到的那三个角度。

图1 动画过程截图

这次讨论的问题是之前云音乐在开发视频流功能时遇到的。有一个无缝切换需求,是将正在播放中的视频由图2-1展开成图2-3的样式。动画由两部分组成,顶部的视频区向上平移;底部的评论区向上平移的同时高度展开。

完成之后,QA反馈展开动画卡顿比较严重,遂进行分析,先看一下systrace。

图2 优化前的systrace

从systrace看,这个动画的帧率有些惨不忍睹——300ms时长的动画,只能绘制3帧,平均1帧的耗时在100ms,远高于16ms的刷新频率。

1、关注首帧和尾帧绘制

首帧和尾帧的耗时是最容易被忽视的一点,首帧就是动画的第一帧,尾帧是动画的最后一帧,这两帧发生卡顿,往往最容易被用户注意到,却也最容易被开发忽视。

1)AnimatorListener回调

图3 动画尾帧的systrace

先看这个动画的尾帧,可以明显看到尾帧结束前UI线程中有耗时,从systrace可以看到耗时来自Choreographer doFrame中animation这个回调,这里有个小知识点:

AnimatorListener的回调都是发生的doFrame过程中的。对于onAnimationEnd来说,回调触发时,最后一帧还没有绘制。

查看代码发现,onAnimationEnd确实有间接初始化UI的操作,对应systrace中inflate的耗时。最简单的改法就是把onAnimationEnd中的耗时操作改为异步执行,先让动画绘制完成,再执行需要的耗时操作,简单修改后的systrace如下,尾帧终于顺利绘制出来了。

图4 优化后尾帧的systrace

可见AnimatorListener的回调中,不宜进行耗时操作,最好只进行跟动画相关的控制逻辑。

尾帧的丢帧往往都会是这种场景,因为我们在动画结束后,一般都需要处理一些页面转换或者其他善后操作,一旦没有注意调用时机,同时逻辑中有一些隐性的耗时,就会导致尾帧绘制不流畅。

2)先绘制再动画

首帧的优化与尾帧相比,有一个相反的原则——如果对尾帧来说,我们要保证先动画再切换,那对于首帧来说,就是先绘制再动画。

先看下修改前首帧的systrace,

图5 动画首帧的systrace

这里先简单描述下这里页面展开的逻辑,在展开时,会在视频View的下方add一个fragment,这个fragment中主要维护展开后页面下方的视频信息。在每次展开之前,我们会先add这个fragment,同时start展开动画的animator。

从systrace我们可以看到,动画第一帧performTraversal的耗时比之后几帧都要长很多,这是因为我们在add了fragment之后,View Tree发生了较大变化,View Tree上刚添加的和其他受到影响的控件都需要重新进行measure、layout和draw,也就是说几乎整个View Tree都需要重建,这就导致第一帧的绘制比其他帧要慢很多。

我们在进行转场动画时,往往都需要重建UI,所以必然会遇到类似问题,前文提到的Fragment切换动画容易发生卡顿,也是同样原因。

想要优化这类问题,就需要在动画开始前,保证界面先绘制完一帧,然后再开始动画,这样总体的耗时虽没有差异,但是动画的流畅度得到了保障,在体验上有很大提升。

实际上先绘制再动画的思想在很多地方都有体现,不管是在Android源码中,还是第三方开源源码中,都经常可以看到类似如下的逻辑,

图6.1 ProgressBar中相关的逻辑
图6.2 ViewPropertyAnimator中相关的逻辑

核心思路都是保证界面先draw一次,然后在下一帧开始动画,强烈建议大家写一个utils类来封装这种先绘制后动画的逻辑。

图7 优化后首帧的systrace

按上述逻辑调整代码之后,得到优化后的systrace如下,将第一帧的耗时从动画过程中移除了。

总结一下,首帧和尾帧的优化虽然是一个比较细小的优化点,但不注意的话,往往实现的每一个动画都会有同样的问题;反过来说,只要养成良好的编码习惯,简单的几行代码,就可以大幅提升动画性能。

同时,仔细想的话,首尾帧优化其实跟排除UI线程耗时是互相重复的,这里之所以单独讨论这个场景,是因为UI线程耗时往往跟业务场景是强相关的,会有千奇百怪的原因,我们没有办法也没有必要一一去进行讨论,不过整体的优化思路跟首尾帧优化是类似的——就是针对动画的特殊性,时刻保持动画优先,把动画过程中的耗时移动到动画之外去完成(当然能减少的耗时还是要优化的)。

2、排查不必要的UI线程耗时

从图3我们还可以看出,在动画开始的过程中,主线程上有一段较长时间的耗时,这种明显的耗时,还是比较好定位的。无论是利用Trace View,还是升级版的Android Profiler的CPU火焰图,都能较快的定位到耗时的函数。

另外,如果对systrace比较熟悉的话,从图3中圈出的线程state可以看出,动画发生卡顿时,主线程进入了Uninterruptible Sleep状态,一般就是在进行IO操作,结合业务场景,这里很大概率是在主线程中操作播放器。

最终根据排查定位到,这里会对使用过的播放器进行reset操作,对这部分逻辑进行调整并加入重入保护后,解决了这个问题。

UI线程中的耗时,是性能优化中会遇到的比较通用的问题,所以这里我们不展开讨论太多。

3、使用最合理的实现方式

继续观察systrace来看一下动画过程中的实现,可以看到动画过程中帧耗时偏高,很明显每一帧都有measure和layout操作。

图8 动画过程中的systrace

查看代码可以发现,这里的逻辑是通过setTranslationY来实现视频区的平移;修改height来实现评论区的垂直展开,所以触发了requestLayout。

图9 修改前的动画逻辑

这就回到了前面讨论过的问题,为什么我们要根据不同应用场景来选择相对最优的动画实现?是因为工作量还不够饱和么?(误)

我想主要还是因为垂直同步下,留给每一帧绘制、渲染的时间太少了,理论上每一帧绘制、渲染的时间最多也只有16ms。尽管在异步渲染机制的帮助下可以让这个时间增加一些,但整体也就是在20ms上下。在20ms这个尺度下,每1ms的优化都变得有意义了;同时越少的逻辑就意味着越稳定的性能表现。

以这里的动画实现为例,触发requestLayout,这个布局measure的时间大约是5~6ms,layout的时间大约2ms,而单纯draw的时间只在2ms左右,requestLayout相当于增加了400%的绘制时间。

图10 每一帧具体的耗时

同时,requestLayout也增加了动画的不可控性,在后续维护时,布局的复杂程度进一步增加,这个动画必然变得越来越卡顿;而且不同的布局实现,实际measure、layout的耗时差异也是非常巨大的,可能其他同事在这个布局上不经意的一点修改,就会对这个动画产生影响。

其次,如果一个动画平均帧在10ms左右,目测也没有任何卡顿,此时我们有没有必要再优化一些逻辑,把这个时间进一步压缩到8ms、6ms呢?我想肯定也是有意义的,用户场景千差万别,尤其当我们的app用户量级越来越大,不同的设备、不同的运行状态、不同的操作习惯,都很可能把动画耗时的差异从10ms vs 6ms放大到30ms vs 18ms,到时就真的是“大潮退去,才知谁在裸泳”了。

最后,绘制效率对功耗的影响也不可忽视,特别对于P0级的页面,如果一个经常被触发的动画,绘制效率非常低,那么它带来的功耗损失也是不容小觑的(有没有有电流计的大佬,来一起测试一些数据)。

进行了一些形而上的讨论,让我们具体来看一下这个动画该怎么优化,首先视频区平移没什么难度,用translate属性或者offsetTopAndBottom都可以实现;评论区的展开也有不少方式,如果是纯色背景可以用scale属性来模拟,如果是非纯色背景setClipBounds或者setBottom都可以实现。根据前面讨论的天梯榜,可以控制在属性动画这个级别。

不过在优化之后,又发现了一些额外的问题,可以在这里继续讨论一下。

1)谨防动画降级

这一点在实际开发过程中很容易遇到,很多时候我们费了半天时间,把动画优化成用View属性来实现,正准备去吃个火锅庆祝一下,临走前打开systrace看了一眼,发现systrace中每一帧还是有measure和layout的操作。这时候只能默默拿出泡面,自行“消费降级”了。

我们知道,对于View System来说,requestLayout、invalidate和View属性这些逻辑,实际都是被View Tree耦合在一起的,所以在动画过程中,整个动画性能取决于性能最差的那种动画操作。

还是以前面讨论的这个视频展开动画为例,

图11 被动触发的requestLayout

之前我们在优化首帧时,发现把动画延时启动之后,首帧还是有measure和layout的操作,但实际此时的动画实现已经全部修改成了属性动画。继续排查代码可以发现,之前有逻辑,在动画开始时进行了设置视频标题等操作,这些操作间接的触发了requestLayout,所以让首帧又多余layout了一次。

图12 首帧相关逻辑

这里想讨论的是,控件的api有很多都会隐式的触发requestLayout和invalidate,往往一不注意就会发生类似问题。总体的原则是,如果相关的api会改变View的大小或内容,那么一般内部就会调用requestLayout或invalidate。 如果需求决定,一定要在动画过程中调用类似api,比如TextView的setText,那只能调整实现方式,比如把TextView的宽度固定来减少requestLayout的影响或者换用自定义View通过控制draw来实现等等。

这里也有一个小技巧,一般我们在根布局的requestLayout和invalidate函数下断点(最好自备一个实现了requestLayout和invalidate的根布局),就能够快速的排查、定位这类问题。

2)控制动画区域

处理完上述逻辑之后,发现还有另一个需求点需要考虑:在播放竖版视频时,进行展开动画的同时,需要把视频区拉伸成竖版的长宽比。在展开过程中,帧率偏低。

图13 视频区展开需求

这里拉伸视频区是利用requestLayout实现的,实际利用CoordinatorLayout或者NestedScroll会有更好、更高效的实现方式,但由于这里耦合了太多逻辑,而且设计之初就没有考虑滑动头的问题,所以如果直接改造实现方式工作量巨大,短时间内进行优化比较困难。

这可能也是我们在实际项目中会经常遇到的情况——由于布局、需求的复杂度,历史原因,或者开发成本,使我们不得不使用低效率的动画实现。这种情况下,控制动画影响范围就显得非常重要了。

这里竖版视频展开过程之所以效率偏低,主要是因为这里视频区和评论区是一个上下布局,如果用常见的实现方式(LinearLayout或RelativeLayout),两者在布局上都是互相有依赖的——当视频区高度发生变化,会同时触发评论区重新measure、layout,所以这里间接触发了下方评论区列表重新布局。不过好在评论区Fragment内部是用ListView实现的,所以风险还算可控。(关于ListView、RecyclerView重新布局的问题,又是一个很大的话题了,有机会后续可以再仔细研究一下)

所以,主要的优化是如下解除视频区和评论区在布局上的耦合,

图14 优化后视频页的根布局

然后在视频区展开时,让底部的评论区随之translateY即可。

修改到这里,基本涉及到的优化点都讨论的差不多了,还有一些业务相关的或者比较通用的UI优化,这里就不再展开讨论了。虽然文章写了很长,但在实操过程中,代码的修改量很小,整体的工作量也并不大。更多时候还是在编码的过程中,有意识的调整一下逻辑,更耐心的多写几行代码,就可以写出性能高效的动画实现了。


五、总结

最后我们再默默看一眼优化之后的systrace,是不是可以开心的去吃火锅了。

图15 优化前后的systrace

一拉到底的结论,动画优化的三个主要方向,

1. 使用最合理的实现方式 —— 熟记动画实现排行,谨防动画降级,控制最小的动画区域;

2. 排查不必要的UI线程耗时 —— 通用的UI优化方法,日后再说;

3. 关注首帧和尾帧绘制 —— 举手之劳,生活更多彩。

怎么样,读到这里的小伙伴,是否觉得实现动画简单了许多呢?[狗头]

编辑于 2018-09-28