繼上篇用「SurfaceView逐幀解析 & 幀複用」優化了幀動畫內存性能後,一個更復雜的問題浮出水面:幀動畫時間性能。這一篇試着讓每幀素材大小 1MB 的幀動畫流暢播放的同時不讓內存膨脹。在整個優化過程當中,綜合運用了多線程、阻塞隊列、消息機制、滑動窗口機制。也體悟到了計算機設計的中庸之道。java
在此要感謝評論上一篇文章的掘友「小前鋒」,是你的提問指引了我在這個方向上繼續探索。node
(ps:粗斜體表示引導方案逐步進化的關鍵點)git
簡單回顧下上一篇的內容:原生幀動畫在播放前解析全部幀,對內存壓力大。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/raw
、res/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
中的圖片。爲了讓速度不一樣的生產者和消費者更流暢的協同工做,必須爲速度較快的一方提供緩衝。
就好像 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
保持原樣。這得益於模版方法設計模式
,將不變的算法框架抽象出來定義在基類中,將變化的部分組織成抽象函數,其實現延遲到子類。
上述列舉代碼段省略了一些和重點無關的細節,詳細源碼可點擊這裏