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

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
私网连接 PrivateLink,5万GB流量 1.5万小时实例时长
简介: 继上篇用“SurfaceView逐帧解析&帧复用”优化了帧动画内存性能后,一个更复杂的问题浮出水面:帧动画时间性能。这一篇试着让每帧素材大小 1MB 的帧动画流畅播放的同时不让内存膨胀。

这是 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

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

目录
相关文章
|
2月前
|
算法 数据处理 Android开发
掌握安卓性能优化的秘诀:电池寿命与运行效率的提升
【10月更文挑战第6天】 本文深入探讨了安卓应用开发中的性能优化技巧,重点分析了影响电池寿命和运行效率的关键因素,并提供了针对性的优化策略。通过代码优化、资源管理、后台任务处理等方法,开发者可以显著提升应用的续航能力和流畅度。同时,结合具体案例,展示了如何在实际开发中应用这些技巧,确保应用在各种场景下都能保持高效运行。本文旨在为安卓开发者提供实用的性能优化指导,助力其打造更优质的应用体验。
57 2
|
28天前
|
Android开发 开发者
Android性能优化——内存管理的艺术
Android性能优化——内存管理的艺术
|
1月前
|
Android开发 开发者 UED
安卓开发中自定义View的实现与性能优化
【10月更文挑战第28天】在安卓开发领域,自定义View是提升应用界面独特性和用户体验的重要手段。本文将深入探讨如何高效地创建和管理自定义View,以及如何通过代码和性能调优来确保流畅的交互体验。我们将一起学习自定义View的生命周期、绘图基础和事件处理,进而探索内存和布局优化技巧,最终实现既美观又高效的安卓界面。
33 5
|
1月前
|
缓存 数据库 Android开发
安卓开发中的性能优化技巧
【10月更文挑战第29天】在移动应用的海洋中,性能是船只能否破浪前行的关键。本文将深入探讨安卓开发中的性能优化策略,从代码层面到系统层面,揭示如何让应用运行得更快、更流畅。我们将以实际案例和最佳实践为灯塔,引领开发者避开性能瓶颈的暗礁。
51 3
|
3月前
|
存储 缓存 编解码
Android经典面试题之图片Bitmap怎么做优化
本文介绍了图片相关的内存优化方法,包括分辨率适配、图片压缩与缓存。文中详细讲解了如何根据不同分辨率放置图片资源,避免图片拉伸变形;并通过示例代码展示了使用`BitmapFactory.Options`进行图片压缩的具体步骤。此外,还介绍了Glide等第三方库如何利用LRU算法实现高效图片缓存。
71 20
Android经典面试题之图片Bitmap怎么做优化
|
2月前
|
调度 Android开发 开发者
构建高效Android应用:探究Kotlin多线程优化策略
【10月更文挑战第11天】本文探讨了如何在Kotlin中实现高效的多线程方案,特别是在Android应用开发中。通过介绍Kotlin协程的基础知识、异步数据加载的实际案例,以及合理使用不同调度器的方法,帮助开发者提升应用性能和用户体验。
58 4
|
2月前
|
存储 缓存 网络协议
5个Android性能优化相关的深度面试题
本文涵盖五个Android面试题及其解答,包括优化应用启动速度、内存泄漏的检测与解决、UI渲染性能优化、减少内存抖动和内存溢出、优化网络请求性能。每个问题都提供了详细的解答和示例代码。
28 2
|
2月前
|
监控 测试技术 Android开发
掌握安卓性能优化的关键策略
【10月更文挑战第7天】 在移动应用开发领域,性能优化是一项至关重要的任务。本文将探讨安卓应用性能优化的重要性、关键策略以及实际操作建议,帮助开发者提升应用的用户体验和竞争力。通过深入浅出的方式,我们将从背景介绍到具体实践,全面解析安卓性能优化的各个维度。
|
1月前
|
安全 Android开发 iOS开发
深入探索iOS与Android系统的差异性及优化策略
在当今数字化时代,移动操作系统的竞争尤为激烈,其中iOS和Android作为市场上的两大巨头,各自拥有庞大的用户基础和独特的技术特点。本文旨在通过对比分析iOS与Android的核心差异,探讨各自的优势与局限,并提出针对性的优化策略,以期为用户提供更优质的使用体验和为开发者提供有价值的参考。
|
缓存 Android开发 数据格式
Android ListView性能优化,异步加载图片
版权声明:本文为博主原创文章,转载请标明出处。 https://blog.csdn.net/lyhhj/article/details/48184383 ListView性能优...
1170 0