Android帧动画导致的OOM和ANR的解决
SilkyAnimation
如果大家有播放超多帧动画的需求,首先可以直接点击 SilkyAnimation 在github查看。
关于Android帧动画
当在应用中需要使用帧动画的时候,最先想到的就是Android提供的AnimationDrawable了,但是如果帧动画中如果包含上百帧图片,此时再用AnimationDrawable就不是那么理想了。AnimationDrawable使用一个Drawable数组来存储每一帧的图像,会直接把全部图片加载进内存。随着帧数量的增多,就算性能再强劲的机器也会卡顿、OOM。
使用SurfaceView来实现帧动画的效果
最近的项目中需要用到大量的帧动画(各种闪瞎24K钛合金狗眼的礼物效果,多的高达200帧),既然AnimationDrawable不行,就想到了两种解决方法。
第一个想到的解决办法就是用openGL来绘制了。
因为是直播的项目,包含人脸贴图等都是用opengl绘制的,如果用OpenGL绘制一层Texture直接推流还省事。只在主播端处理就行了,但是IOS那边都弄得差不多了,直接原生的不用处理也不会有什么异常什么的。。很尴尬。
第二个就是使用Android自带的surfaceView了
好吧,第一个不行那就想到Android自带的surfaceView啦。我首先用不同的手机测试了下应用从本地decode一个bitmap的时间(png格式,414*736,大小在30-100k之间),因为帧动画的每帧不会太大,在性能好点的设备上基本保持在10-30ms之间(不推流基本上推流状态下10ms左右,推流状态下20左右),在性能稍差的设备中基本上也不会超过50ms,所以说是没什么大问题的。
在移动设备播放的帧动画一定要尽最大可能的压缩,推荐一个网站,会把图片颜色深度压缩成8位的。TinyPNG
实现思路
整个思路大概如图。 绘制过程2.png既然不能完全加载到内存,想到的就是类似视频播放或者视频直播类似的思路。首先定义一个Bitmap的缓冲区,边绘制边加载。首先加载一定数量的帧到Bitmap缓冲区,加载完成后通知SurfaceView开始绘制。SurfaceView绘制一帧完成后通知Bitmap缓冲区加载下一帧,同时将绘制过的一帧的从Bitmap缓冲区移除。一帧绘制完成后,绘制线程根据设置的帧间隔休眠一段时间,休眠完成后开始从Bitmap缓冲区获取下一帧,依此类推,一直循环,直到播放完成或者手动停止。按照这种方式实现起来,发现oom卡顿什么的果然不存在了,内存的使用情况如图。
内存抖动@2x.png
但是看着这个垃圾桶一个挨一个,这个内存回收情况完全不正常!GC太频繁了。想着应该是这里出现了问题。[图片上传失败...(image-96f387-1512626035688)]
频繁的添加移除bitmap,导致了不算太严重的内存抖动。之所以称之为不算太严重,因为大概400ms一次,一次gc花费2ms左右。不看内存,只看运行效果。真的感觉不出来。但是呢,这样显然也是不行滴。
内存抖动的解决
最常见的解决方法就是对象的复用,创建各种pool。Android也提供了Bitmap的复用方式,在加载bitmap的时候传入一个inBitmap,那么加载的bitmap就会复用原bitmap的内存空间,所以理论上将要复用的bitmap和新加载的bitmap在颜色深度一样的情况下,复用的bitmap宽高要大于新加载的bitmap。50L的桶毕竟最多只能装50L的水。关于inBitmap更多资料可以参考这里,还有这里。(请自备梯子)。 使用起来很简单,大概就是这样
Bitmap mInBitmap;
BitmapFactory.Options mOptions = new BitmapFactory.Options();
mOptions.inMutable = true;
mOptions.inSampleSize = 1;
//mInBitmap不能为null,此处省去赋值
mOptions.inBitmap = mInBitmap;
Bitmap bitmap = BitmapFactory.decodeStream(mAssetManager.open(path), null, mOptions);
然后实现思路就是在这里修改了,把将要删除的哪一帧留下来作为inBitmap。
修改后的
。但是这里有个大坑,也坑了自己一天。因为SurfaceView的双缓冲,如果SurfaceView绘制完成直接把刚才绘制完成的bitmap给复用了,会出现大问题。因为绘制完成的并没有被直接显示到window上,而是作为缓存,在下一个lockAndPost时才会显示到window。所以在并没有显示到window的时候直接将bitmap复用,会出现帧的错乱,类似上半部分为第一帧的图像下半部分为第二帧的图像。原因是surfaceView将bitmap所在那块内存作为下一次post到window的缓存,但是还没来得及用,就被复用修改了。可能SurfaceView在post的时候你正在往bitmap所在的那块内存写入新的bitmap,写到一半时,SurfaceView来取缓存了,所以导致显示出来的图像是两帧的混合。最终采用的方案是,多缓存两帧,取即刚绘制过的前第二帧作为复用的对象。代码如下
private void writeInBitmap(int position) {
if (!mSupportInBitmap) {
mBitmapCache.remove(position);
return;
}
mInBitmapFlag++;
if (mInBitmapFlag > 1) {
int writePosition = position - 2;
//得到正确的position
if (writePosition < 0) {
writePosition = mTotalCount + writePosition;
}
mInBitmap = mBitmapCache.get(writePosition);
mBitmapCache.remove(writePosition);
}
}
最后再看下内存的使用情况,首先是运行动画前。
运行前.png
然后是运行动画时
运行中.png
内存的使用非常平稳,其实一直是加载到内存中的那几帧,和上图抖动的垃圾桶形成了鲜明的对比。内存占用和播放动画之前只多了那么一丢丢。就是你有1000000000张帧动画要播放,还是这么一丢丢。
使用
关于代码我觉得不贴了,贴了也不一定有人看,这里只分享下核心的实现思路。大家有兴趣的可以自己搞一下。更可以方便的直接去github查看和使用SilkyAnimation。可以超级方便的播放帧动画。
SilkyAnimation mAnimation=
new SilkyAnimation.Builder(mSurfaceView)
.build();
//初始化完成之后直接就调用start传入file或者asset资源目录播放了
File file=new FIle(Environment.getExternalStorageDirectory() + File.separator+ "bird")
mAnimation.start(file);
//或者
String assetsPath="bird/crow";
mAnimation.start(assetsPath);
//然后你还可有更多设置
new SilkyAnimation.Builder(mSurfaceView)
//设置常驻内存的缓存数量, 默认5.
.setCacheCount(8)
//设置帧间隔, 默认100
.setFrameInterval(80)
//设置缩放类型, 默认fit center,与ImageView的缩放模式通用
.setScaleType(SilkyAnimation.SCALE_TYPE_FIT_END)
//设置动画开始结束状态监听
.setAnimationListener(listener)
//设置是否支持bitmap复用,默认为true
.setSupportInBitmap(false)
//设置循环模式, 默认不循环
.setRepeatMode(SilkyAnimation.MODE_INFINITE)
.build();
看上去是不是超级方便,如果对大家有帮助的希望大家点下鼠标给个star,如果有任何问题的话也可以直接在github提交issue。
问题
关于从本地加载图片的方式,当时在想用多线程异步加载或者单线程同步阻塞加载的哪一个。最后选择了单线程同步阻塞加载,因为个人觉得决定加载速度的更多应该是io速度,多线程并不能解决。如果再加上各种锁,或许多线程异步加载并没有什么优势,并且实现起来单线程明显工作量小很多,而且最后实现起来并没有发现因为加载速度导致的问题。预先加载5个到内存,更多是为了对冲加载某个图片耗时异常的风险,如果加载每个超大图片的时间都很长,那么解决的方式只能是增大帧间隔。这就像class4 的tf卡不能用来拍摄4K视频似的。以上也只是个人的想法,如果有错误的地方也欢迎小伙伴们指正。