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

9,401 阅读10分钟

这是 Android 性能优化系列文章的第二篇,文章列表如下:

  1. Android性能优化 | 帧动画OOM?优化帧动画之 SurfaceView逐帧解析
  2. Android性能优化 | 大图做帧动画卡顿?优化帧动画之 SurfaceView滑动窗口式帧复用
  3. Android性能优化 | 把构建布局用时缩短 20 倍(上)
  4. Android性能优化 | 把构建布局用时缩短 20 倍(下)

上篇用“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/rawres/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(),这是为了避免快速解析导致内存溢出:假设复用队列为空,但绘制线程还未完成当前帧的绘制,此时解析线程完成了一帧的解析,并正在向复用队列取帧,若不采取阻塞方法,则解析线程复用帧失败,一块新的内存被申请用于存放解析出来的下一帧。

现在就形成了如下这个循环: 微信截图_20220502184354.png

其中的绿色表示非阻塞地生产。其余的红色表示阻塞地。

通过这种方式,将帧动画内存的消耗固定在6个Bitmap之内。

满怀期待运行代码并打开内存监控~~,内存没有膨胀,播了好几次也没有!动画也很流畅!

正打算庆祝的时候,内存监控中的一个对象引起了我的注意。

仅仅是播放了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。大功告成!!源码比较长就不贴出来了(文末有链接)。

性能比对

image8.png

上图是使用原生帧动画播放10张1MB的帧动画时,内存占用情况。

下图是换用 FrameSurfaceView 的内存占用情况

image7.png

体悟中庸

原生帧动画采用提前解析:将全部素材以 Drawable 形式放在内存,这是用空间换时间的做法,它拥有最好的时间性能和最差的内存性能

为了提高内存性能采用逐帧解析,这是用时间换空间的做法,它拥有最好的内存性能和最差的时间性能

显然这两种极端的方案都不是最好的方案。但是极端方案的价值在于它为最终的中庸方案定义了两个边界,让我知道好在哪个位置获取中点。

本文的滑动窗口式帧复用就是在上一篇逐帧解析的基础上牺牲了一些内存性能换取了一个折中的内存和时间性能。

题外话

对上一篇的代码做了重构,但所有改动都发生在子类,基类BaseSurfaceView保持原样。这得益于模版方法设计模式,将不变的算法框架抽象出来定义在基类中,将变化的部分组织成抽象函数,其实现延迟到子类。

talk is cheap, show me the code

上述列举代码段省略了一些和重点无关的细节,详细源码可点击这里