}
protected abstract void onFrameDraw(Canvas canvas); }
//帧动画绘制类:将绘制内容具体化为一张Bitmap public class FrameSurfaceView extends BaseSurfaceView { … private BitmapFactory.Options options;
@Override protected void onFrameDraw(Canvas canvas) { clearCanvas(canvas); if (!isStart()) { return; } if (!isFinish()) { //绘制一帧 drawOneFrame(canvas); } else { onFrameAnimationEnd(); } }
private void drawOneFrame(Canvas canvas) { //解析帧 frameBitmap = BitmapFactory.decodeResource(getResources(), bitmaps.get(bitmapIndex), options); //复用帧 options.inBitmap = frameBitmap; //绘制帧 canvas.drawBitmap(frameBitmap, srcRect, dstRect, paint); bitmapIndex++; } … }
对比图片解析速度
对于素材在 100k 以下的帧动画,上一篇的逐帧解析方案完全能够胜任。但如果素材是几百k,时间性能就不如预期。
掘友“小前锋”问:“你的方案有测试过大图吗?比如1024*768px”
在逐帧解析SurfaceView上试了下这个大小的帧动画,虽然播放过程很连续,但 600ms 的帧动画被放成了 1s。因为预定义的每帧播放时间被解码时间拉长了。
有没有比BitmapFactory.decodeResource() 更快的解码方式?
于是乎对比了各种图片解码的速度,其中包括BitmapFactory.decodeStream() 、BitmapFactory.decodeResource() 、并分别将图片放到res/raw 、res/drawable 、及assets ,还在 GitHub 上发现了RapidDecoder 这个库(兴奋不已!)。自定义了测量函数执行时间的工具类:
public class MethodUtil { //测量并打印单次函数执行耗时 public static long time(Runnable runnable) { long start = SystemClock.elapsedRealtime(); runnable.run(); long end = SystemClock.elapsedRealtime(); long span = end - start; Log.v(“ttaylor”, “MethodUtil.time()” + " time span = " + span + " ms"); return span; } }
public class NumberUtil { private static long total; private static int times; private static String tag;
//统计并打印多次执行时间的平均值 public static void average(String tag, Long l) { if (!TextUtils.isEmpty(tag) && !tag.equals(NumberUtil.tag)) { reset(); NumberUtil.tag = tag; } times++; total += l; int average = total / times ; Log.v(“ttaylor”, "Average.average() " + NumberUtil.tag + " average = " + average); }
private static void reset() { total = 0; times = 0; } }
经多次测试取平均值,执行时间最长的是BitmapFactory.decodeResource() ,最短的是用BitmapFactory.decodeStream() 解析assets 图片,后者只用了前者一半时间。而RapidDecoder 库的时间介于两者之间(失望至极~),不过它提供了一种边解码边绘制的技术号称比先解码再绘制要快,还没来得及试。
虽然将解码时间减半了,但解码一张 1MB 图片还是需要 60+ms,仍不能满足时间性能要求。
独立解码线程
现在的矛盾是 图片解析速度 慢于 图片绘制速度,如果解码和绘制在同一个线程串行的进行,那解码势必会拖慢绘制效率。
可不可以将解码图片放在一个单独的线程中进行?
在上一篇FrameSurfaceView 的基础上新增了独立解码线程:
public class FrameSurfaceView extends BaseSurfaceView { … //独立解码线程 private HandlerThread decodeThread; //解码算法写在这里面 private DecodeRunnable decodeRunnable;
//播放帧动画时启动解码线程 public void start() { decodeThread = new HandlerThread(DECODE_THREAD_NAME); decodeThread.start(); handler = new Handler(decodeThread.getLooper()); handler.post(decodeRunnable); }
private class DecodeRunnable implements Runnable {
@Override public void run() { //在这里解码 } } }
这样一来,基类中有独立的绘制线程,而子类中有独立的解码线程,解码速度不再影响绘制速度。
新的问题来了:图片被解码后存放在哪里?
生产者 & 消费者
存放解码图片的容器,会被两个线程访问,绘制线程从中取图片(消费者),解码线程往里存图片(生产者),需考虑线程同步。第一个想到的就是LinkedBlockingQueue ,于是乎在FrameSurfaceView 中新增了大小为 1 的阻塞队列及存取操作:
public class FrameSurfaceView extends BaseSurfaceView { … //解析队列:存放已经解析帧素材 private LinkedBlockingQueue decodedBitmaps =
《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
【docs.qq.com/doc/DSkNLaERkbnFoS0ZF】 完整内容开源分享
new LinkedBlockingQueue<>(1); //记录已绘制的帧数 private int frameIndex ;
//存解码图片 private void putDecodedBitmap(int resId, BitmapFactory.Options options) { Bitmap bitmap = decodeBitmap(resId, options); try { decodedBitmaps.put(bitmap); } catch (InterruptedException e) { e.printStackTrace(); } }
//取解码图片 private Bitmap getDecodedBitmap() { Bitmap bitmap = null; try { bitmap = decodedBitmaps.take(); } catch (InterruptedException e) { e.printStackTrace(); } return bitmap; }
//解码图片 private Bitmap decodeBitmap(int resId, BitmapFactory.Options options) { options.inScaled = false; InputStream inputStream = getResources().openRawResource(resId); return BitmapFactory.decodeStream(inputStream, null, options); }
private void drawOneFrame(Canvas canvas) { //在绘制线程中取解码图片并绘制 Bitmap bitmap = getDecodedBitmap(); if (bitmap != null) { canvas.drawBitmap(bitmap, srcRect, dstRect, paint); } frameIndex++; }
private class DecodeRunnable implements Runnable { private int index; private List bitmapIds; private BitmapFactory.Options options;
public DecodeRunnable(int index, List bitmapIds, BitmapFactory.Options options) { this.index = index; this.bitmapIds = bitmapIds; this.options = options; }
@Override public void run() { //在解码线程中解码图片 putDecodedBitmap(bitmapIds.get(index), options); index++; if (index < bitmapIds.size()) { handler.post(this); } else { index = 0; } } } }
- 绘制线程在每次绘制之前调用阻塞的
take() 从解析队列的队头拿帧图片,解码线程不断地调用阻塞的put() 往解析队列的队尾存帧图片。 - 虽然
assets 目录下的图片解析速度最快,但res/raw 目录的速度和它相差无几,为了简单起见,这里使用了openRawResource 读取res/raw 中的图片。 - 虽然解码和绘制分别在不同线程,但如果存放解码图片容器大小为 1 ,绘制进程必须等待解码线程,绘制速度还是会被解码速度拖累,看似互不影响的两个线程,其实相互牵制。
滑动窗口机制 & 预解析
为了让速度不同的生产者和消费者更流畅的协同工作,必须为速度较快的一方提供缓冲。
就好像 TCP 拥塞控制中的滑动窗口机制 ,发送方产生报文的速度快于接收方消费报文的速度,遂发送方不必等收到前一个报文的确认再发送下一个报文。
对于当前 case ,需要将存放图片容器增大,并在帧动画开始前预解析前几帧存入解析队列。
public class FrameSurfaceView extends BaseSurfaceView { … //下一个该被解析的素材索引 private int bitmapIdIndex; //帧动画素材容器 private List bitmapIds = new ArrayList<>(); //大小为3的解析队列 private LinkedBlockingQueue decodedBitmaps = new LinkedBlockingQueue<>(3);
//传入帧动画素材 public void setBitmapIds(List bitmapIds) { if (bitmapIds == null || bitmapIds.size() == 0) { return; } this.bitmapIds = bitmapIds; preloadFrames(); }
//预解析前几帧 private void preloadFrames() { //解析一帧并将图片入解析队列 putDecodedBitmap(bitmapIds.get(bitmapIdIndex++), options); putDecodedBitmap(bitmapIds.get(bitmapIdIndex++), options); } }
独立解码线程、滑动窗口机制、预加载都已 code 完毕。运行一把代码(坐等惊喜~)。
居然流畅的播起来了!兴奋的我忍不住播了好几次。。。打开内存监控一看(头顶竖下三条线),一夜回到解放前:每播放一次,内存中就会新增 N 个Bitmap对象(N为帧动画总帧数)。
原来重构过程中,将解码时的帧复用逻辑去掉了。当前 case 中,帧复用也变得复杂起来。
复用队列
当解码和绘制是在一个线程中串行进行,且只有一帧被复用,只需这样写代码就能实现帧复用:
private void drawOneFrame(Canvas canvas) { frameBitmap = BitmapFactory.decodeResource(getResources(), bitmaps.get(bitmapIndex), options); //复用上一帧Bitmap的内存 options.inBitmap = frameBitmap; canvas.drawBitmap(frameBitmap, srcRect, dstRect, paint); bitmapIndex++; }
而现在解码和绘制并发进行,且有多帧能被复用。这时就需要一个队列来维护可被复用的帧。
当绘制线程从解析队列头部取出帧图片并完成绘制后,该帧就可以被复用了,应该将其加入到复用队列队头。而解码线程在解码新的一帧图片之前,应该从复用队列的队尾取出可复用的帧。
一帧图片就这样在两个队列之间转圈。通过这样一个周而复始的循环,就可以将内存占用控制在有限范围内(解码队列长度*帧大小)。新增复用队列代码如下:
public class FrameSurfaceView extends BaseSurfaceView { //复用队列 private LinkedBlockingQueue drawnBitmaps = new LinkedBlockingQueue<>(3);
//将已绘制图片存入复用队列 private void putDrawnBitmap(Bitmap bitmap) { drawnBitmaps.offer(bitmap); }
//从复用队列中取图片 private LinkedBitmap getDrawnBitmap() { Bitmap bitmap = null; try { bitmap = drawnBitmaps.take(); } catch (InterruptedException e) { e.printStackTrace(); } return bitmap; }
//复用上一帧解析下一帧并入解析队列 private void putDecodedBitmapByReuse(int resId, BitmapFactory.Options options) { Bitmap bitmap = getDrawnBitmap(); options.inBitmap = bitmap; putDecodedBitmap(resId, options); }
private void drawOneFrame(Canvas canvas) { Bitmap bitmap = getDecodedBitmap(); if (bitmap != null) { canvas.drawBitmap(bitmap, srcRect, dstRect, paint); } //帧绘制完毕后将其存入复用队列 putDrawnBitmap(bitmap); frameIndex++; }
private class DecodeRunnable implements Runnable { private int index; private List bitmapIds; private BitmapFactory.Options options;
public DecodeRunnable(int index, List bitmapIds, BitmapFactory.Options options) { this.index = index; this.bitmapIds = bitmapIds; this.options = options; }
@Override public void run() { //在解析线程复用上一帧并解析下一帧存入解析队列 putDecodedBitmapByReuse(bitmapIds.get(index), options); index++; if (index < bitmapIds.size()) { handler.post(this); } else { index = 0; } } } }
- 绘制帧完成后将其存入复用队列时使用了不带阻塞的
offer() ,这是为了避免慢速解析拖累快速绘制:假设复用队列已满,但解析线程还未完成当前解析,此时完成了一帧的绘制,并正在向复用队列存帧,若采用阻塞方法,则绘制线程因慢速解析而被阻塞。 - 解析线程从复用队列获取复用帧时使用了阻塞的
take() ,这是为了避免快速解析导致内存溢出:假设复用队列为空,但绘制线程还未完成当前帧的绘制,此时解析线程完成了一帧的解析,并正在向复用队列取帧,若不采取阻塞方法,则解析线程复用帧失败,一块新的内存被申请用于存放解析出来的下一帧。
满怀期待运行代码并打开内存监控~~,内存没有膨胀,播了好几次也没有!动画也很流畅!
正打算庆祝的时候,内存监控中的一个对象引起了我的注意。
仅仅是播放了5-6次动画,就产生了600+个实例,而Bitmap 对象只有3个。
更蹊跷的是600个对象的内存占用和3个Bitmap 的几乎相等。
仔细观察这600个对象,其中只有3个对象Retained size 非常大,其余大小都是16k。
点开这3个对象的成员后发现,每个对象都持有1个Bitmap 。
而且这个对象的名字叫LinkedBlockingQueue@Node 。
真相大白!
在向阻塞队列插入元素的时候,其内部会新建一个Node 结点用于包裹插入元素,以offer() 为例:
public class LinkedBlockingQueue extends AbstractQueue implements BlockingQueue, java.io.Serializable { public boolean offer(E e) { if (e == null) throw new NullPointerException();
|