繼上篇用“SurfaceView逐幀解析 & 幀復(fù)用”優(yōu)化了幀動(dòng)畫內(nèi)存性能后,一個(gè)更復(fù)雜的問(wèn)題付出了水面:幀動(dòng)畫時(shí)間性能。這一篇試著讓每幀素材大小 1MB 的幀動(dòng)畫流暢播放的同時(shí)不讓內(nèi)存膨脹。在整個(gè)優(yōu)化過(guò)程中,綜合運(yùn)用了多線程、阻塞隊(duì)列、消息機(jī)制、滑動(dòng)窗口機(jī)制。也體悟到了計(jì)算機(jī)設(shè)計(jì)的中庸之道。
在此要感謝評(píng)論上一篇文章的掘友“小前鋒”,是你的提問(wèn)指引了我在這個(gè)方向上繼續(xù)探索。
(ps:粗斜體表示引導(dǎo)方案逐步進(jìn)化的關(guān)鍵點(diǎn))
SurfaceView逐幀解析 & 幀復(fù)用
簡(jiǎn)單回顧下上一篇的內(nèi)容:原生幀動(dòng)畫在播放前解析所有幀,對(duì)內(nèi)存壓力大。SurfaceView可以精細(xì)地控制幀動(dòng)畫每一幀的繪制,在每一幀繪制前才解析當(dāng)前幀,且解析后續(xù)幀時(shí)復(fù)用前幀內(nèi)存空間。遂整個(gè)過(guò)程在內(nèi)存只申請(qǐng)了一幀圖片大小的空間。下面羅列了一些關(guān)鍵代碼:
基類:定義繪制框架
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()執(zhí)行超時(shí),會(huì)導(dǎo)致下一幀的繪制被推后,預(yù)定的幀時(shí)間間隔不生效
handler.postDelayed(this, frameDuration);
}
}
protected abstract void onFrameDraw(Canvas canvas);
}
//幀動(dòng)畫繪制類:將繪制內(nèi)容具體化為一張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);
//復(fù)用幀
options.inBitmap = frameBitmap;
//繪制幀
canvas.drawBitmap(frameBitmap, srcRect, dstRect, paint);
bitmapIndex++;
}
...
}
對(duì)比圖片解析速度
對(duì)于素材在 100k 以下的幀動(dòng)畫,上一篇的逐幀解析方案完全能夠勝任。但如果素材是幾百k,時(shí)間性能就不如預(yù)期。
掘友“小前鋒”問(wèn):“你的方案有測(cè)試過(guò)大圖嗎?比如1024*768px”
在逐幀解析SurfaceView上試了下這個(gè)大小的幀動(dòng)畫,雖然播放過(guò)程很連續(xù),但 600ms 的幀動(dòng)畫被放成了 1s。因?yàn)轭A(yù)定義的每幀播放時(shí)間被解碼時(shí)間拉長(zhǎng)了。
有沒(méi)有比BitmapFactory.decodeResource()更快的解碼方式?
于是乎對(duì)比了各種圖片解碼的速度,其中包括BitmapFactory.decodeStream()、BitmapFactory.decodeResource()、并分別將圖片放到res/raw、res/drawable、及assets,還在 GitHub 上發(fā)現(xiàn)了RapidDecoder這個(gè)庫(kù)(興奮不已!)。自定義了測(cè)量函數(shù)執(zhí)行時(shí)間的工具類:
public class MethodUtil {
//測(cè)量并打印單次函數(shù)執(zhí)行耗時(shí)
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;
//統(tǒng)計(jì)并打印多次執(zhí)行時(shí)間的平均值
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;
}
}
經(jīng)多次測(cè)試取平均值,執(zhí)行時(shí)間最長(zhǎng)的是BitmapFactory.decodeResource(),最短的是用BitmapFactory.decodeStream()解析assets圖片,后者只用了前者一半時(shí)間。而RapidDecoder庫(kù)的時(shí)間介于兩者之間(失望至極~),不過(guò)它提供了一種邊解碼邊繪制的技術(shù)號(hào)稱比先解碼再繪制要快,還沒(méi)來(lái)得及試。
雖然將解碼時(shí)間減半了,但解碼一張 1MB 圖片還是需要 60+ms,仍不能滿足時(shí)間性能要求。
獨(dú)立解碼線程
現(xiàn)在的矛盾是 圖片解析速度 慢于 圖片繪制速度,如果解碼和繪制在同一個(gè)線程串行的進(jìn)行,那解碼勢(shì)必會(huì)拖慢繪制效率。
可不可以將解碼圖片放在一個(gè)單獨(dú)的線程中進(jìn)行?
在上一篇FrameSurfaceView的基礎(chǔ)上新增了獨(dú)立解碼線程:
public class FrameSurfaceView extends BaseSurfaceView {
...
//獨(dú)立解碼線程
private HandlerThread decodeThread;
//解碼算法寫在這里面
private DecodeRunnable decodeRunnable;
//播放幀動(dòng)畫時(shí)啟動(dòng)解碼線程
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() {
//在這里解碼
}
}
}
這樣一來(lái),基類中有獨(dú)立的繪制線程,而子類中有獨(dú)立的解碼線程,解碼速度不再影響繪制速度。
新的問(wèn)題來(lái)了:圖片被解碼后存放在哪里?
生產(chǎn)者 & 消費(fèi)者
存放解碼圖片的容器,會(huì)被兩個(gè)線程訪問(wèn),繪制線程從中取圖片(消費(fèi)者),解碼線程往里存圖片(生產(chǎn)者),需考慮線程同步。第一個(gè)想到的就是LinkedBlockingQueue,于是乎在FrameSurfaceView中新增了大小為 1 的阻塞隊(duì)列及存取操作:
public class FrameSurfaceView extends BaseSurfaceView {
...
//解析隊(duì)列:存放已經(jīng)解析幀素材
private LinkedBlockingQueue<Bitmap> decodedBitmaps = new LinkedBlockingQueue<>(1);
//記錄已繪制的幀數(shù)
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;
}
}
}
}
- 繪制線程在每次繪制之前調(diào)用阻塞的
take()從解析隊(duì)列的隊(duì)頭拿幀圖片,解碼線程不斷地調(diào)用阻塞的put()往解析隊(duì)列的隊(duì)尾存幀圖片。 - 雖然
assets目錄下的圖片解析速度最快,但res/raw目錄的速度和它相差無(wú)幾,為了簡(jiǎn)單起見(jiàn),這里使用了openRawResource讀取res/raw中的圖片。 - 雖然解碼和繪制分別在不同線程,但如果存放解碼圖片容器大小為 1 ,繪制進(jìn)程必須等待解碼線程,繪制速度還是會(huì)被解碼速度拖累,看似互不影響的兩個(gè)線程,其實(shí)相互牽制。
滑動(dòng)窗口機(jī)制 & 預(yù)解析
為了讓速度不同的生產(chǎn)者和消費(fèi)者更流暢的協(xié)同工作,必須為速度較快的一方提供緩沖。
就好像 TCP 擁塞控制中的滑動(dòng)窗口機(jī)制,發(fā)送方產(chǎn)生報(bào)文的速度快于接收方消費(fèi)報(bào)文的速度,遂發(fā)送方不必等收到前一個(gè)報(bào)文的確認(rèn)再發(fā)送下一個(gè)報(bào)文。
對(duì)于當(dāng)前 case ,需要將存放圖片容器增大,并在幀動(dòng)畫開(kāi)始前預(yù)解析前幾幀存入解析隊(duì)列。
public class FrameSurfaceView extends BaseSurfaceView {
...
//下一個(gè)該被解析的素材索引
private int bitmapIdIndex;
//幀動(dòng)畫素材容器
private List<Integer> bitmapIds = new ArrayList<>();
//大小為3的解析隊(duì)列
private LinkedBlockingQueue<Bitmap> decodedBitmaps = new LinkedBlockingQueue<>(3);
//傳入幀動(dòng)畫素材
public void setBitmapIds(List<Integer> bitmapIds) {
if (bitmapIds == null || bitmapIds.size() == 0) {
return;
}
this.bitmapIds = bitmapIds;
preloadFrames();
}
//預(yù)解析前幾幀
private void preloadFrames() {
//解析一幀并將圖片入解析隊(duì)列
putDecodedBitmap(bitmapIds.get(bitmapIdIndex++), options);
putDecodedBitmap(bitmapIds.get(bitmapIdIndex++), options);
}
}
獨(dú)立解碼線程、滑動(dòng)窗口機(jī)制、預(yù)加載都已 code 完畢。運(yùn)行一把代碼(坐等驚喜~)。
居然流暢的播起來(lái)了!興奮的我忍不住播了好幾次。。。打開(kāi)內(nèi)存監(jiān)控一看(頭頂豎下三條線),一夜回到解放前:每播放一次,內(nèi)存中就會(huì)新增 N 個(gè)Bitmap對(duì)象(N為幀動(dòng)畫總幀數(shù))。
原來(lái)重構(gòu)過(guò)程中,將解碼時(shí)的幀復(fù)用邏輯去掉了。當(dāng)前 case 中,幀復(fù)用也變得復(fù)雜起來(lái)。
復(fù)用隊(duì)列
當(dāng)解碼和繪制是在一個(gè)線程中串行進(jìn)行,且只有一幀被復(fù)用,只需這樣寫代碼就能實(shí)現(xiàn)幀復(fù)用:
private void drawOneFrame(Canvas canvas) {
frameBitmap = BitmapFactory.decodeResource(getResources(), bitmaps.get(bitmapIndex), options);
//復(fù)用上一幀Bitmap的內(nèi)存
options.inBitmap = frameBitmap;
canvas.drawBitmap(frameBitmap, srcRect, dstRect, paint);
bitmapIndex++;
}
而現(xiàn)在解碼和繪制并發(fā)進(jìn)行,且有多幀能被復(fù)用。這時(shí)就需要一個(gè)隊(duì)列來(lái)維護(hù)可被復(fù)用的幀。
當(dāng)繪制線程從解析隊(duì)列頭部取出幀圖片并完成繪制后,該幀就可以被復(fù)用了,應(yīng)該將其加入到復(fù)用隊(duì)列隊(duì)頭。而解碼線程在解碼新的一幀圖片之前,應(yīng)該從復(fù)用隊(duì)列的隊(duì)尾取出可復(fù)用的幀。
一幀圖片就這樣在兩個(gè)隊(duì)列之間轉(zhuǎn)圈。通過(guò)這樣一個(gè)周而復(fù)始的循環(huán),就可以將內(nèi)存占用控制在有限范圍內(nèi)(解碼隊(duì)列長(zhǎng)度*幀大?。P略鰪?fù)用隊(duì)列代碼如下:
public class FrameSurfaceView extends BaseSurfaceView {
//復(fù)用隊(duì)列
private LinkedBlockingQueue<Bitmap> drawnBitmaps = new LinkedBlockingQueue<>(3);
//將已繪制圖片存入復(fù)用隊(duì)列
private void putDrawnBitmap(Bitmap bitmap) {
drawnBitmaps.offer(bitmap);
}
//從復(fù)用隊(duì)列中取圖片
private LinkedBitmap getDrawnBitmap() {
Bitmap bitmap = null;
try {
bitmap = drawnBitmaps.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
return bitmap;
}
//復(fù)用上一幀解析下一幀并入解析隊(duì)列
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);
}
//幀繪制完畢后將其存入復(fù)用隊(duì)列
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() {
//在解析線程復(fù)用上一幀并解析下一幀存入解析隊(duì)列
putDecodedBitmapByReuse(bitmapIds.get(index), options);
index++;
if (index < bitmapIds.size()) {
handler.post(this);
} else {
index = 0;
}
}
}
}
- 繪制幀完成后將其存入復(fù)用隊(duì)列時(shí)使用了不帶阻塞的
offer(),這是為了避免慢速解析拖累快速繪制:假設(shè)復(fù)用隊(duì)列已滿,但解析線程還未完成當(dāng)前解析,此時(shí)完成了一幀的繪制,并正在向復(fù)用隊(duì)列存幀,若采用阻塞方法,則繪制線程因慢速解析而被阻塞。 - 解析線程從復(fù)用隊(duì)列獲取復(fù)用幀時(shí)使用了阻塞的
take(),這是為了避免快速解析導(dǎo)致內(nèi)存溢出:假設(shè)復(fù)用隊(duì)列為空,但繪制線程還未完成當(dāng)前幀的繪制,此時(shí)解析線程完成了一幀的解析,并正在向復(fù)用隊(duì)列取幀,若不采取阻塞方法,則解析線程復(fù)用幀失敗,一塊新的內(nèi)存被申請(qǐng)用于存放解析出來(lái)的下一幀。
滿懷期待運(yùn)行代碼并打開(kāi)內(nèi)存監(jiān)控~~,內(nèi)存沒(méi)有膨脹,播了好幾次也沒(méi)有!動(dòng)畫也很流暢!
正打算慶祝的時(shí)候,內(nèi)存監(jiān)控中的一個(gè)對(duì)象引起了我的注意。
僅僅是播放了5-6次動(dòng)畫,就產(chǎn)生了600+個(gè)實(shí)例,而
Bitmap對(duì)象只有3個(gè)。更蹊蹺的是600個(gè)對(duì)象的內(nèi)存占用和3個(gè)
Bitmap的幾乎相等。仔細(xì)觀察這600個(gè)對(duì)象,其中只有3個(gè)對(duì)象
Retained size非常大,其余大小都是16k。點(diǎn)開(kāi)這3個(gè)對(duì)象的成員后發(fā)現(xiàn),每個(gè)對(duì)象都持有1個(gè)
Bitmap。而且這個(gè)對(duì)象的名字叫
LinkedBlockingQueue@Node。真相大白!
在向阻塞隊(duì)列插入元素的時(shí)候,其內(nèi)部會(huì)新建一個(gè)Node結(jié)點(diǎn)用于包裹插入元素,以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;
//新建結(jié)點(diǎn)
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 中的消息隊(duì)列,消息被處理后放入消息池,構(gòu)建新消息時(shí)會(huì)先從池中獲取,以此實(shí)現(xiàn)消息的復(fù)用。消息機(jī)制中也維護(hù)了兩個(gè)隊(duì)列,一個(gè)是消息隊(duì)列,一個(gè)是消息回收隊(duì)列,兩個(gè)隊(duì)列之間形成循環(huán),和本文中的場(chǎng)景非常相似。
為啥消息隊(duì)列不會(huì)產(chǎn)生這么多冗余對(duì)象?
原因就在于LinkedBlockingQueue默默為我們包了一層結(jié)點(diǎn),但我們并沒(méi)有能力處理這層額外的結(jié)點(diǎn)。
抓狂中~~~,只要用LinkedBlockingQueue就必然會(huì)新建結(jié)點(diǎn)。。。要不就不用它吧。。。但不用它,實(shí)現(xiàn)生產(chǎn)者消費(fèi)者就比較麻煩。。。還是得用。。。
無(wú)奈之下,只能使用復(fù)制粘貼大法,重寫了一個(gè)自己的LinkedBlockingQueue并刪除那句new Node<E>(),為簡(jiǎn)單起見(jiàn),只列舉了其中的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();
}
}
沒(méi)有了Node之后,Bitmap之間就無(wú)法串聯(lián)起來(lái),那就自己創(chuàng)建一個(gè)能串聯(lián)起來(lái)的Bitmap:
public class LinkedBitmap {
public Bitmap bitmap;
//用于連接下一個(gè)Bitmap的指針
public LinkedBitmap next;
}
將原本使用java.util.concurrent.LinkedBlockingQueue替換成自己的LinkedBlockingQueue(隱約覺(jué)得有更好的辦法,待熱心掘友點(diǎn)撥~),將原本使用Bitmap的地方替換成LinkedBitmap。大功告成??!源碼比較長(zhǎng)就不貼出來(lái)了(文末有鏈接)。
體悟中庸
原生幀動(dòng)畫采用提前解析:將全部素材以 Drawable 形式放在內(nèi)存,這是用空間換時(shí)間的做法,它擁有最好的時(shí)間性能和最差的內(nèi)存性能。
為了提高內(nèi)存性能采用逐幀解析,這是用時(shí)間換空間的做法,它擁有最好的內(nèi)存性能和最差的時(shí)間性能。
顯然這兩種極端的方案都不是最好的方案。但是極端方案的價(jià)值在于它為最終的中庸方案定義了兩個(gè)邊界,讓我知道好在哪個(gè)位置獲取中點(diǎn)。
本文的滑動(dòng)窗口式幀復(fù)用就是在上一篇逐幀解析的基礎(chǔ)上犧牲了一些內(nèi)存性能換取了一個(gè)折中的內(nèi)存和時(shí)間性能。
題外話
對(duì)上一篇的代碼做了重構(gòu),但所有改動(dòng)都發(fā)生在子類,基類BaseSurfaceView保持原樣。這得益于模版方法設(shè)計(jì)模式,將不變的算法框架抽象出來(lái)定義在基類中,將變化的部分組織成抽象函數(shù),其實(shí)現(xiàn)延遲到子類。
talk is cheap, show me the code
上述列舉代碼段省略了一些和重點(diǎn)無(wú)關(guān)的細(xì)節(jié),詳細(xì)源碼可點(diǎn)擊這里[https://github.com/wisdomtl/FrameSurfaceView]