继用“SurfaceView逐帧解析 & 帧复用”优化了帧动画内存性能后,一个更复杂的问题浮出水面:帧动画时间性能。这一篇试着让每帧素材大小 1MB 的帧动画流畅播放的同时不让内存膨胀。在整个优化过程中,综合运用了多线程、阻塞队列、消息机制、滑动窗口机制。也体悟到了计算机设计的中庸之道。
在此要感谢评论上一篇文章的掘友“小前锋”,是你的提问指引了我在这个方向上继续探索。
(ps:粗斜体表示引导方案逐步进化的关键点)
SurfaceView逐帧解析 & 帧复用
简单回顾下上一篇的内容:原生帧动画在播放前解析所有帧,对内存压力大。SurfaceView
可以精细地控制帧动画每一帧的绘制,在每一帧绘制前才解析当前帧,且解析后续帧时复用前帧内存空间。遂整个过程在内存只申请了一帧图片大小的空间。下面罗列了一些关键代码:
基类:定义绘制框架public abstract class BaseSurfaceView extends SurfaceView implements SurfaceHolder.Callback { ... //绘制线程 private HandlerThread handlerThread; private Handler handler; @Override public void surfaceCreated(SurfaceHolder holder) { startDrawThread(); } private void startDrawThread() { handlerThread = new HandlerThread("SurfaceViewThread"); handlerThread.start(); handler = new Handler(handlerThread.getLooper()); handler.post(new DrawRunnable()); } private class DrawRunnable implements Runnable { @Override public void run() { try { canvas = getHolder().lockCanvas(); //绘制一帧,包括解码+绘制帧 onFrameDraw(canvas); } catch (Exception e) { e.printStackTrace(); } finally { getHolder().unlockCanvasAndPost(canvas); onFrameDrawFinish(); } //若onFrameDraw()执行超时,会导致下一帧的绘制被推后,预定的帧时间间隔不生效 handler.postDelayed(this, frameDuration); } } protected abstract void onFrameDraw(Canvas canvas);}//帧动画绘制类:将绘制内容具体化为一张Bitmappublic 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 LinkedBlockingQueuedecodedBitmaps = 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 ListbitmapIds = 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 LinkedBlockingQueuedrawnBitmaps = 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 LinkedBlockingQueueextends AbstractQueue implements BlockingQueue , java.io.Serializable { public boolean offer(E e) { if (e == null) throw new NullPointerException(); final AtomicInteger count = this.count; if (count.get() == capacity) return false; int c = -1; //新建结点 Node node = new Node (e); final ReentrantLock putLock = this.putLock; putLock.lock(); try { if (count.get() < capacity) { enqueue(node); c = count.getAndIncrement(); if (c + 1 < capacity) notFull.signal(); } } finally { putLock.unlock(); } if (c == 0) signalNotEmpty(); return c >= 0; }}复制代码
突然想到了 Android 中的消息队列
,消息被处理后放入消息池,构建新消息时会先从池中获取,以此实现消息的复用。消息机制
中也维护了两个队列,一个是消息队列,一个是消息回收队列,两个队列之间形成循环,和本文中的场景非常相似。
为啥消息队列不会产生这么多冗余对象?
原因就在于LinkedBlockingQueue
默默为我们包了一层结点,但我们并没有能力处理这层额外的结点。
抓狂中~~~,只要用LinkedBlockingQueue
就必然会新建结点。。。要不就不用它吧。。。但不用它,实现生产者消费者就比较麻烦。。。还是得用。。。
无奈之下,只能使用复制粘贴大法,重写了一个自己的LinkedBlockingQueue
并删除那句new Node<E>()
,为简单起见,只列举了其中的put()
,代码如下:
public class LinkedBlockingQueue { private final AtomicInteger count = new AtomicInteger(); private final ReentrantLock takeLock = new ReentrantLock(); private final Condition notEmpty = takeLock.newCondition(); private final ReentrantLock putLock = new ReentrantLock(); private final Condition notFull = putLock.newCondition(); private final int capacity; private LinkedBitmap head; private LinkedBitmap tail; public LinkedBlockingQueue(int capacity) { if (capacity <= 0) throw new IllegalArgumentException(); this.capacity = capacity; } public void put(LinkedBitmap bitmap) throws InterruptedException { if (bitmap == null) throw new NullPointerException(); int c = -1; final ReentrantLock putLock = this.putLock; final AtomicInteger count = this.count; putLock.lockInterruptibly(); try { while (count.get() == capacity) { notFull.await(); } enqueue(bitmap); c = count.getAndIncrement(); if (c + 1 < capacity) notFull.signal(); } finally { putLock.unlock(); } if (c == 0) signalNotEmpty(); }}复制代码
没有了Node
之后,Bitmap
之间就无法串联起来,那就自己创建一个能串联起来的Bitmap
:
public class LinkedBitmap { public Bitmap bitmap; //用于连接下一个Bitmap的指针 public LinkedBitmap next;}复制代码
将原本使用java.util.concurrent.LinkedBlockingQueue
替换成自己的LinkedBlockingQueue
(隐约觉得有更好的办法,待热心掘友点拨~),将原本使用Bitmap
的地方替换成LinkedBitmap
。大功告成!!源码比较长就不贴出来了(文末有链接)。
体悟中庸
原生帧动画采用提前解析:将全部素材以 Drawable 形式放在内存,这是用空间换时间的做法,它拥有最好的时间性能和最差的内存性能。
为了提高内存性能采用逐帧解析,这是用时间换空间的做法,它拥有最好的内存性能和最差的时间性能。
显然这两种极端的方案都不是最好的方案。但是极端方案的价值在于它为最终的中庸方案定义了两个边界,让我知道好在哪个位置获取中点。
本文的滑动窗口式帧复用
就是在上一篇逐帧解析
的基础上牺牲了一些内存性能换取了一个折中的内存和时间性能。
题外话
对上一篇的代码做了重构,但所有改动都发生在子类,基类BaseSurfaceView
保持原样。这得益于模版方法设计模式
,将不变的算法框架抽象出来定义在基类中,将变化的部分组织成抽象函数,其实现延迟到子类。
talk is cheap, show me the code
上述列举代码段省略了一些和重点无关的细节,详细源码可点击