[关闭]
@avenwu 2017-09-13T10:45:19.000000Z 字数 7142 阅读 2753

帧动画调优实践

1. 背景

在做动画的时候我们有很多选择方案。

目前最优方案是使用Lottie动画库,在无法使用Lottie的情况下,我们就需要手动来具体帧动画的调优了。

本文主要谈谈做帧动画的一些优化策略以避免OutOfMemoryError问题。

2. 帧动画有什么问题?

我们都知道原生的Android帧动画在加载序列帧时,是一次性将所有序列帧的图片编码到内存当中的,所以执行帧数较多的动画时很容易发生内存不足,抛出 OutOfMemoryError

所以呢,你就需要解决内存问题,基本上可以从以下几方面入手;

3. 手工实现帧动画

3.1 帧动画分析

下面主要针对 重写帧动画的编码逻辑 这个维度来聊聊具体的实现策略。
既然要重写帧动画,首先就要知道Android原生帧动画的实现逻辑;通过阅读相关源码,笔者绘制了如下简化示意图,基本涵盖了帧动画的构造和执行过程;

Android原生帧动画

可以看到帧动画在首次inflate的时候会解析xml,并将每一个item节点解析为drawable对象实例,然后加入到数组当中;后续动画过程就是轮询绘制,在Drawable容器中绘制当前drawable即可,整体代码简洁漂亮。

但是这个逻辑我们并不能直接复用到手工实现的帧动画中,为什么?这里暂不解答,读者可以自己思考一下:)

现在我们把帧动画的实现策略抽象一下,它就是一个生产者和消费者的关系,如下图所示:

帧动画模型

总的来说我们的优化策略集中在编码和缓存上面,渲染不需要改变。根据编码的的策略可能代码差异会很大。在实际开发中,我们也遇到过一些坑,比如编码速度跟不上,导致渲染的时候出现跳帧,推测主要是CPU时间切片问题。
所以如果做成单线程编码,UI线程渲染是要非常谨慎的,否则很有可能你的编码业务全部被阻塞了,导致UI层面频繁丢帧。

3.2 Bitmap 复用

在图片编码之后,我们需要考虑Bitmap的编码复用,这样可以避免每次decode一张图片都要全新申请一块内存,通过复用已有的bitmap实例对象我们可以做到编码的更低开销。

其核心在于 Options#inBitmap,这个API是Android引入的一个新API,新API本身没什么问题,问题在于这个属性是API 11引入的,但并不可以直接使用,这导致做版本判断的时候比较坑(你无法确定这个接口是否可用,除非你穷举一下相关SDK版本)。根据源代码,在API 14的时候使用该属性会抛异常,具体大家可以去develop了解相关细节,。

bitmap复用

在引入了Options#inBitmap的时候一定要注意姿势。否则的话极有可能出现大量日志警告:

  1. Called reconfigure on a bitmap that is in use! This may cause graphical corruption!

当然如果出现了这个警告也不要紧张,肯定是可以解决的,这个警告是在core/jni/android/graphics/Bitmap.cpp中抛出来的,含义其大致就是说如果你在修改一个正在被使用的bitmap那么这会导致graphical corruption。这个单词没有想到合适的中文翻译,我猜就是会破坏图形的意思吧。

如果你一定要忽略这个异常的话,请忍受控制台的大量警告输出,反正我是忍不了。要解决整个警告也很简单,只要保证复用bitmap作为inBitmap时不要使用当前正在被ImageView展示的bitmap实例就可以了。

3.3 RAM和CPU的平衡处理

前面提到了,编码图片可能会跟不上渲染,这是因为编码一张图片需要CPU去处理图片,包括IO之类的,这需要编码线程申请CPU时间切片,如果UI线程或者其他线程池有高优的任务,那么编码这一块就会很尴尬。所以要解决这个问题一方面可以提供bitmap内存缓存,减少需要重新编码的次数,比如我们通过调试把图片复用的命中率提高到40%,50%之类。另外一个就是不能给编码线程设置过低的优先级,不要觉得这是后台任务应该放一个BACKGROUND之类的低优先级,根据andorid的官方说明(具体文档我忘了),后台线程和前台(其他默认优先级线程)之间获取CPU的能力差异是非常大的,好像接近二八开的样子。

3.4 缓存策略

缓存策略也是需要认真考虑的地方,前面我们讲到了为了平衡编码压力,我们可以引入内存缓存,那么以什么策略来缓存图片呢?(在心里说出一个答案,看看对不对)

LRU?

如果你回答LRU的话,恭喜你,你的缓存完全失效了。

虽然LRU使用非常广泛的缓存算法,但是很遗憾他并不适合帧动画缓存,为什么呢?读者可以自己思考一下LRU的淘汰策略和帧动画的执行策略就明白了。

所以我们用的是什么装逼的算法呢?这个笔者并不是专研算法的,所以不懂得太多唬人的名字。不过回想起多年前求学时,耳(quan)熟(bu)能(wang)详(ji)的那些算法,你会想起还有一个叫LIFO的东西,也就是后进先出算法(Last in first out)。

缓存策略

我们根据需要可以稍加改造找一下,首先实现一个LifoCache,这个可以从LruCache略加改造,然后在取缓存/删缓存时,取倒数第二个即可。这样可以最早执行的动画被缓存下来,并且有机会再下一轮动画来临时被复用。当然机智的你也可以选择其他更好的算法。

3.5 优化点回顾

以上是我们在实践当中总结的一些比较关键的点。出于尊重原创的职业素养,必须声明,我们的整体优化的启示灵感来源于FasterAnimationsContainer[2]
原项目更多的像一个示意demo,存在诸多bug,需要修复才能满足基本使用,因此我们修复了这些问题,并且发起了 Pull Request[3]

不过从维护记录来看原项目已经不太活跃,我们将这些改动和新增的功能优化做了梳理。
当然除了修复这些问题,我们实际上进行了几乎95%以上的重构,所以严格来说,这两个项目除去解决内存泄漏的思想,在工程上已经没有太多相似处了。

我们主要做了如下改动:

  1. 消除bitmap编码警告;
  2. 修复前后台切换后动画僵死的问题;
  3. 取消单例模式,支持每个ImageView控制独立的动画;
  4. 新增图片内存缓存,支持缓存比配置;
  5. 动画不显示时缓存自动释放与恢复;
  6. 支持原生xml定义,兼容原生写法;

4. 调优效果

这一节,我们简单对比下,手工实现的帧动画和原生帧动画的内存数据;

4.1 原生标准帧动画

原生标准帧动画

在这幅图中,我们执行了一个完整的操作流程

4.2 懒加载帧动画

前面已经提到,我们的帧动画支持缓存设置,所以分别看一下缓存40%和100%缓存的两种情况。

懒加载帧动画

我们保持一致的操作流程,来看一下优化后的帧动画的数据;

类似的当我们把缓存比调大,比如调到100%后,可以得到一个新的图。这个图的内存高峰和CPU状态基本可以看做是上面两种情况的结合体。当缓存占比高了,那么后续需要重新编码的次数也就少了,所以CPU的占用也就少了。

懒加载帧动画-全缓存

4.3 数据对比

以我们的测试为例,选用了 16600x600 的jpg图片,每张大小约为 14~18KB 之间。
为了方便,对比我们选取几个关键点的近似内存和CPU数据。

动画类型 阶段 内存/MB CPU/%
原生帧动画 启动页面 65 1%
原生帧动画 加载动画 65 1%
懒加载帧动画 启动页面 17 1%
懒加载帧动画 加载动画 32 15%

5. 使用说明

介绍完了原理,就来看下怎么使用吧,API层面没有做太多改变和原生使用比较接近。由于项目是开源的,这里放上传送门:

http://hub.hacktons.cn/animation/

通过上述地址可以获取项目最新的变化和源代码等信息。 下面我们简单看下目前如何使用我们的优化方案来播放一个帧动画。

5.1 配置Gradle依赖库

  1. compile 'com.github.avenwu:animation:0.2.0'

5.2 通过MockFrameImageView使用动画

先进行动画定义,一般都是用一个xml搞定,方便省事。

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <animation-list
  3. xmlns:android="http://schemas.android.com/apk/res/android"
  4. android:oneshot="false">
  5. <item
  6. android:drawable="@drawable/num_0"
  7. android:duration="120"/>
  8. <item
  9. android:drawable="@drawable/num_1"
  10. android:duration="120"/>
  11. <item
  12. android:drawable="@drawable/num_2"
  13. android:duration="120"/>
  14. <item
  15. android:drawable="@drawable/num_3"
  16. android:duration="120"/>
  17. <item
  18. android:drawable="@drawable/num_4"
  19. android:duration="120"/>
  20. <item
  21. android:drawable="@drawable/num_5"
  22. android:duration="120"/>
  23. <item
  24. android:drawable="@drawable/num_6"
  25. android:duration="120"/>
  26. <item
  27. android:drawable="@drawable/num_7"
  28. android:duration="120"/>
  29. <item
  30. android:drawable="@drawable/num_8"
  31. android:duration="120"/>
  32. <item
  33. android:drawable="@drawable/num_9"
  34. android:duration="120"/>
  35. <item
  36. android:drawable="@drawable/num_a"
  37. android:duration="120"/>
  38. <item
  39. android:drawable="@drawable/num_b"
  40. android:duration="120"/>
  41. <item
  42. android:drawable="@drawable/num_c"
  43. android:duration="120"/>
  44. <item
  45. android:drawable="@drawable/num_d"
  46. android:duration="120"/>
  47. <item
  48. android:drawable="@drawable/num_e"
  49. android:duration="120"/>
  50. <item
  51. android:drawable="@drawable/num_f"
  52. android:duration="120"/>
  53. </animation-list>

在layout中进行布局定义

  1. <cn.hacktons.animation.MockFrameImageView
  2. android:id="@+id/imageview"
  3. android:layout_width="200dp"
  4. android:layout_height="200dp"
  5. android:layout_gravity="center"
  6. app:cache_percent="0.4"
  7. app:src="@drawable/loading_animation"/>

最后在Activity或者其他地方就可以启动动画了

  1. ImageView imageView = (ImageView) findViewById(R.id.imageview);
  2. animateDrawable = (Animatable) imageView.getDrawable();
  3. ((Switch) findViewById(R.id.button)).setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
  4. @Override
  5. public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
  6. if (isChecked) {
  7. animateDrawable.start();
  8. } else {
  9. animateDrawable.stop();
  10. }
  11. }
  12. });

上面的使用方法维持了和原生帧动画一致的操作和配置,除了layout里面写的控件不是ImageView,其他一模一样的。

5.3 通过代码使用动画

除此之外,我们也可以直接用代码来实现这个动画配置,这也是项目最开始所支持的方式,这个就不需要依赖任何自定义的View,直接在原生ImageView上生效:

  1. int[] FRAMES = {
  2. R.drawable.num_0,
  3. R.drawable.num_1,
  4. R.drawable.num_2,
  5. R.drawable.num_3,
  6. R.drawable.num_4,
  7. R.drawable.num_5,
  8. R.drawable.num_6,
  9. R.drawable.num_7,
  10. R.drawable.num_8,
  11. R.drawable.num_9,
  12. R.drawable.num_a,
  13. R.drawable.num_b,
  14. R.drawable.num_c,
  15. R.drawable.num_d,
  16. R.drawable.num_e,
  17. R.drawable.num_f,
  18. };
  19. animateDrawable = new AnimationBuilder()
  20. .frames(FRAMES, 120/*duration*/)
  21. .cachePercent(0.4f)
  22. .oneShot(false)
  23. .into(findViewById(R.id.imageview));
  24. ((Switch) findViewById(R.id.button)).setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
  25. @Override
  26. public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
  27. if (isChecked) {
  28. animateDrawable.start();
  29. } else {
  30. animateDrawable.stop();
  31. }
  32. }
  33. });

5.4 效果对比图

最后看一下各种帧动画的实现效果。

Animation

Download Video

6. 小结

综合来看,原生帧动画更适合体量较小,内存压力不那么大的帧动画,如此一次性加载所有帧,可以保证后续帧切换的流程性;而MockFrameAnimation则为了解决内存问题,采取动态编码序列帧。

这相当于用CPU的编码/计算能力换取了内存消耗;同时为了达到适合的平衡,我们允许开发者设置图片缓存的张数,缓存数越大那么内存消耗越多,需要重新编码的次数也就相对更少;

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注