@avenwu
2017-09-13T10:45:19.000000Z
字数 7142
阅读 2753
在做动画的时候我们有很多选择方案。
目前最优方案是使用Lottie动画库,在无法使用Lottie的情况下,我们就需要手动来具体帧动画的调优了。
本文主要谈谈做帧动画的一些优化策略以避免OutOfMemoryError
问题。
我们都知道原生的Android帧动画在加载序列帧时,是一次性将所有序列帧的图片编码到内存当中的,所以执行帧数较多的动画时很容易发生内存不足,抛出 OutOfMemoryError
。
所以呢,你就需要解决内存问题,基本上可以从以下几方面入手;
降帧
在保证UI效果和视觉流畅度的情况下尽可能减少帧数,比如UE输出的序列帧可能默认有有4,5十张图片,减帧后可能只有20张;这里一定要注意的是,必须经过UE来减帧,RD不允许私自调整,避免效果不达标;
压缩尺寸
根据在手机上的展示大小,缩放到一致的高宽,避免屏幕上展示100x100,你用一个500x500的资源;
压缩体积
采用无损或者可接受的有损压缩图片质量,这个也无需多说,老司机都懂得。打个广告,推荐笔者写的IntelliJ插件 http://avenwu.net/biu/;
重写帧动画的编码逻辑
这里的一个思路是动态编码图片,不再一次性加载所有图片,通过懒加载的方式,图片对内存的要求会大幅降低;
下面主要针对 重写帧动画的编码逻辑
这个维度来聊聊具体的实现策略。
既然要重写帧动画,首先就要知道Android原生帧动画的实现逻辑;通过阅读相关源码,笔者绘制了如下简化示意图,基本涵盖了帧动画的构造和执行过程;
可以看到帧动画在首次inflate的时候会解析xml,并将每一个item节点解析为drawable对象实例,然后加入到数组当中;后续动画过程就是轮询绘制,在Drawable容器中绘制当前drawable即可,整体代码简洁漂亮。
但是这个逻辑我们并不能直接复用到手工实现的帧动画中,为什么?这里暂不解答,读者可以自己思考一下:)
现在我们把帧动画的实现策略抽象一下,它就是一个生产者和消费者的关系,如下图所示:
总的来说我们的优化策略集中在编码和缓存上面,渲染不需要改变。根据编码的的策略可能代码差异会很大。在实际开发中,我们也遇到过一些坑,比如编码速度跟不上,导致渲染的时候出现跳帧,推测主要是CPU时间切片问题。
所以如果做成单线程编码,UI线程渲染是要非常谨慎的,否则很有可能你的编码业务全部被阻塞了,导致UI层面频繁丢帧。
在图片编码之后,我们需要考虑Bitmap的编码复用,这样可以避免每次decode一张图片都要全新申请一块内存,通过复用已有的bitmap实例对象我们可以做到编码的更低开销。
其核心在于 Options#inBitmap
,这个API是Android引入的一个新API,新API本身没什么问题,问题在于这个属性是API 11引入的,但并不可以直接使用,这导致做版本判断的时候比较坑(你无法确定这个接口是否可用,除非你穷举一下相关SDK版本)。根据源代码,在API 14的时候使用该属性会抛异常,具体大家可以去develop了解相关细节,。
在引入了Options#inBitmap的时候一定要注意姿势。否则的话极有可能出现大量日志警告:
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实例就可以了。
前面提到了,编码图片可能会跟不上渲染,这是因为编码一张图片需要CPU去处理图片,包括IO之类的,这需要编码线程申请CPU时间切片,如果UI线程或者其他线程池有高优的任务,那么编码这一块就会很尴尬。所以要解决这个问题一方面可以提供bitmap内存缓存,减少需要重新编码的次数,比如我们通过调试把图片复用的命中率提高到40%,50%之类。另外一个就是不能给编码线程设置过低的优先级
,不要觉得这是后台任务应该放一个BACKGROUND之类的低优先级,根据andorid的官方说明(具体文档我忘了),后台线程和前台(其他默认优先级线程)之间获取CPU的能力差异是非常大的,好像接近二八开的样子。
缓存策略也是需要认真考虑的地方,前面我们讲到了为了平衡编码压力,我们可以引入内存缓存,那么以什么策略来缓存图片呢?(在心里说出一个答案,看看对不对)
LRU?
如果你回答LRU的话,恭喜你,你的缓存完全失效了。
虽然LRU使用非常广泛的缓存算法,但是很遗憾他并不适合帧动画缓存,为什么呢?读者可以自己思考一下LRU的淘汰策略和帧动画的执行策略就明白了。
所以我们用的是什么装逼的算法呢?这个笔者并不是专研算法的,所以不懂得太多唬人的名字。不过回想起多年前求学时,耳(quan)熟(bu)能(wang)详(ji)的那些算法,你会想起还有一个叫LIFO的东西,也就是后进先出算法(Last in first out)。
我们根据需要可以稍加改造找一下,首先实现一个LifoCache,这个可以从LruCache略加改造,然后在取缓存/删缓存时,取倒数第二个即可。这样可以最早执行的动画被缓存下来,并且有机会再下一轮动画来临时被复用。当然机智的你也可以选择其他更好的算法。
以上是我们在实践当中总结的一些比较关键的点。出于尊重原创的职业素养,必须声明,我们的整体优化的启示灵感来源于FasterAnimationsContainer[2]
;
原项目更多的像一个示意demo,存在诸多bug,需要修复才能满足基本使用,因此我们修复了这些问题,并且发起了 Pull Request[3]
。
不过从维护记录来看原项目已经不太活跃,我们将这些改动和新增的功能优化做了梳理。
当然除了修复这些问题,我们实际上进行了几乎95%以上的重构,所以严格来说,这两个项目除去解决内存泄漏的思想,在工程上已经没有太多相似处了。
我们主要做了如下改动:
- 消除bitmap编码警告;
- 修复前后台切换后动画僵死的问题;
- 取消单例模式,支持每个ImageView控制独立的动画;
- 新增图片内存缓存,支持缓存比配置;
- 动画不显示时缓存自动释放与恢复;
- 支持原生xml定义,兼容原生写法;
这一节,我们简单对比下,手工实现的帧动画和原生帧动画的内存数据;
在这幅图中,我们执行了一个完整的操作流程
前面已经提到,我们的帧动画支持缓存设置,所以分别看一下缓存40%和100%缓存的两种情况。
我们保持一致的操作流程,来看一下优化后的帧动画的数据;
类似的当我们把缓存比调大,比如调到100%后,可以得到一个新的图。这个图的内存高峰和CPU状态基本可以看做是上面两种情况的结合体。当缓存占比高了,那么后续需要重新编码的次数也就少了,所以CPU的占用也就少了。
以我们的测试为例,选用了 16
张 600x600
的jpg图片,每张大小约为 14~18KB
之间。
为了方便,对比我们选取几个关键点的近似内存和CPU数据。
动画类型 | 阶段 | 内存/MB | CPU/% |
---|---|---|---|
原生帧动画 | 启动页面 | 65 | 1% |
原生帧动画 | 加载动画 | 65 | 1% |
懒加载帧动画 | 启动页面 | 17 | 1% |
懒加载帧动画 | 加载动画 | 32 | 15% |
介绍完了原理,就来看下怎么使用吧,API层面没有做太多改变和原生使用比较接近。由于项目是开源的,这里放上传送门:
通过上述地址可以获取项目最新的变化和源代码等信息。 下面我们简单看下目前如何使用我们的优化方案来播放一个帧动画。
compile 'com.github.avenwu:animation:0.2.0'
先进行动画定义,一般都是用一个xml搞定,方便省事。
<?xml version="1.0" encoding="utf-8"?>
<animation-list
xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">
<item
android:drawable="@drawable/num_0"
android:duration="120"/>
<item
android:drawable="@drawable/num_1"
android:duration="120"/>
<item
android:drawable="@drawable/num_2"
android:duration="120"/>
<item
android:drawable="@drawable/num_3"
android:duration="120"/>
<item
android:drawable="@drawable/num_4"
android:duration="120"/>
<item
android:drawable="@drawable/num_5"
android:duration="120"/>
<item
android:drawable="@drawable/num_6"
android:duration="120"/>
<item
android:drawable="@drawable/num_7"
android:duration="120"/>
<item
android:drawable="@drawable/num_8"
android:duration="120"/>
<item
android:drawable="@drawable/num_9"
android:duration="120"/>
<item
android:drawable="@drawable/num_a"
android:duration="120"/>
<item
android:drawable="@drawable/num_b"
android:duration="120"/>
<item
android:drawable="@drawable/num_c"
android:duration="120"/>
<item
android:drawable="@drawable/num_d"
android:duration="120"/>
<item
android:drawable="@drawable/num_e"
android:duration="120"/>
<item
android:drawable="@drawable/num_f"
android:duration="120"/>
</animation-list>
在layout中进行布局定义
<cn.hacktons.animation.MockFrameImageView
android:id="@+id/imageview"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center"
app:cache_percent="0.4"
app:src="@drawable/loading_animation"/>
最后在Activity或者其他地方就可以启动动画了
ImageView imageView = (ImageView) findViewById(R.id.imageview);
animateDrawable = (Animatable) imageView.getDrawable();
((Switch) findViewById(R.id.button)).setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (isChecked) {
animateDrawable.start();
} else {
animateDrawable.stop();
}
}
});
上面的使用方法维持了和原生帧动画一致的操作和配置,除了layout里面写的控件不是ImageView,其他一模一样的。
除此之外,我们也可以直接用代码来实现这个动画配置,这也是项目最开始所支持的方式,这个就不需要依赖任何自定义的View,直接在原生ImageView上生效:
int[] FRAMES = {
R.drawable.num_0,
R.drawable.num_1,
R.drawable.num_2,
R.drawable.num_3,
R.drawable.num_4,
R.drawable.num_5,
R.drawable.num_6,
R.drawable.num_7,
R.drawable.num_8,
R.drawable.num_9,
R.drawable.num_a,
R.drawable.num_b,
R.drawable.num_c,
R.drawable.num_d,
R.drawable.num_e,
R.drawable.num_f,
};
animateDrawable = new AnimationBuilder()
.frames(FRAMES, 120/*duration*/)
.cachePercent(0.4f)
.oneShot(false)
.into(findViewById(R.id.imageview));
((Switch) findViewById(R.id.button)).setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (isChecked) {
animateDrawable.start();
} else {
animateDrawable.stop();
}
}
});
最后看一下各种帧动画的实现效果。
综合来看,原生帧动画更适合体量较小,内存压力不那么大的帧动画,如此一次性加载所有帧,可以保证后续帧切换的流程性;而MockFrameAnimation则为了解决内存问题,采取动态编码序列帧。
这相当于用CPU的编码/计算能力换取了内存消耗;同时为了达到适合的平衡,我们允许开发者设置图片缓存的张数,缓存数越大那么内存消耗越多,需要重新编码的次数也就相对更少;