继上篇用“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);
}
//帧动画绘制类:将绘制内容具体化为一张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<Bitmap> decodedBitmaps = 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<Integer> bitmapIds;
private BitmapFactory.Options options;
public DecodeRunnable(int index, List<Integer> 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<Integer> bitmapIds = new ArrayList<>();
//大小为3的解析队列
private LinkedBlockingQueue<Bitmap> decodedBitmaps = new LinkedBlockingQueue<>(3);
//传入帧动画素材
public void setBitmapIds(List<Integer> 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<Bitmap> 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<Integer> bitmapIds;
private BitmapFactory.Options options;
public DecodeRunnable(int index, List<Integer> 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<E> extends AbstractQueue<E> implements BlockingQueue<E>, 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<E> node = new Node<E>(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
上述列举代码段省略了一些和重点无关的细节,详细源码可点击这里