Android性能优化 _ 大图做帧动画卡?优化帧动画之 SurfaceView滑动窗口式帧复用

private class DecodeRunnable implements Runnable {

@Override
public void run() {
//在这里解码
}
}
}

这样一来,基类中有独立的绘制线程,而子类中有独立的解码线程,解码速度不再影响绘制速度。

新的问题来了:图片被解码后存放在哪里?

生产者 & 消费者

存放解码图片的容器,会被两个线程访问,绘制线程从中取图片(消费者),解码线程往里存图片(生产者),需考虑线程同步。第一个想到的就是LinkedBlockingQueue,于是乎在FrameSurfaceView中新增了大小为 1 的阻塞队列及存取操作:

public class FrameSurfaceView extends BaseSurfaceView {

//解析队列:存放已经解析帧素材
private LinkedBlockingQueue 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 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();
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();
最后我还整理了很多Android中高级的PDF技术文档。以及一些大厂面试真题解析文档。需要的朋友都可以点击GitHub直接获取方式

Android性能优化 _ 大图做帧动画卡?优化帧动画之 SurfaceView滑动窗口式帧复用

Android高级架构师之路很漫长,一起共勉吧!

术文档。以及一些大厂面试真题解析文档。需要的朋友都可以点击GitHub直接获取方式**

[外链图片转存中…(img-XlOEan2e-1644033032141)]

Android高级架构师之路很漫长,一起共勉吧!

上一篇:洛谷P3321 [SDOI2015]序列统计【NTT+原根】


下一篇:Ubuntu Linux下安装Oracle JDK