大圖作幀動畫卡頓?不存在的!—— 優化幀動畫之SurfaceView 滑動窗口式幀複用

上篇用「SurfaceView逐幀解析 & 幀複用」優化了幀動畫內存性能後,一個更復雜的問題浮出水面:幀動畫時間性能。這一篇試着讓每幀素材大小 1MB 的幀動畫流暢播放的同時不讓內存膨脹。在整個優化過程當中,綜合運用了多線程、阻塞隊列、消息機制、滑動窗口機制。也體悟到了計算機設計的中庸之道。java

在此要感謝評論上一篇文章的掘友「小前鋒」,是你的提問指引了我在這個方向上繼續探索。node

(ps:粗斜體表示引導方案逐步進化的關鍵點)git

SurfaceView逐幀解析 & 幀複用

簡單回顧下上一篇的內容:原生幀動畫在播放前解析全部幀,對內存壓力大。SurfaceView能夠精細地控制幀動畫每一幀的繪製,在每一幀繪製前才解析當前幀,且解析後續幀時複用前幀內存空間。遂整個過程在內存只申請了一幀圖片大小的空間。下面羅列了一些關鍵代碼:github

基類:定義繪製框架
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」canvas

在逐幀解析SurfaceView上試了下這個大小的幀動畫,雖然播放過程很連續,但 600ms 的幀動畫被放成了 1s。由於預約義的每幀播放時間被解碼時間拉長了。設計模式

有沒有比BitmapFactory.decodeResource()更快的解碼方式?api

因而乎對比了各類圖片解碼的速度,其中包括BitmapFactory.decodeStream()BitmapFactory.decodeResource()、並分別將圖片放到res/rawres/drawable、及assets,還在 GitHub 上發現了RapidDecoder這個庫(興奮不已!)。自定義了測量函數執行時間的工具類:bash

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

上述列舉代碼段省略了一些和重點無關的細節,詳細源碼可點擊這裏

相關文章
相關標籤/搜索