前言
我們所熟知的,,Android 的圖形繪制主要是基于 View 這個(gè)類實(shí)現(xiàn)。每個(gè) View 的繪制都需要經(jīng)過 onMeasure,、onLayout,、onDraw 三步曲,分別對(duì)應(yīng)到測(cè)量大小,、布局,、繪制。
Android 系統(tǒng)為了簡(jiǎn)化線程開發(fā),,降低應(yīng)用開發(fā)的難度,,將這三個(gè)過程都放在應(yīng)用的主線程(UI 線程)中執(zhí)行,以保證繪制系統(tǒng)的線程安全,。
這三個(gè)過程通過一個(gè)叫 Choreographer 的定時(shí)器來驅(qū)動(dòng)調(diào)用更新,,Choreographer 每 16ms 被 vsync 這個(gè)信號(hào)喚醒調(diào)用一次,這有點(diǎn)類似早期的電視機(jī)刷新的機(jī)制,。在 Choreographer 的 doFrame 方法中,,通過樹狀結(jié)構(gòu)存儲(chǔ)的 ViewGroup,依次遞歸的調(diào)用到每個(gè) View 的 onMeasure,、onLayout,、onDraw 方法,從而最后將每個(gè) View 都繪制出來(當(dāng)然最后還會(huì)經(jīng)過 SurfaceFlinger 的類來將 View 合成起來顯示,,實(shí)際過程很復(fù)雜),。
同時(shí)每個(gè) View 都保存了很多標(biāo)記值 flag,用來判斷是否該 View 需要重新被 Measure,、Layout,、Draw。這樣對(duì)于那些沒有變化,,不需要重繪的 View,,則不再調(diào)用它們的方法,從而能夠提高繪制效率,。
Android 為了方便開發(fā)者進(jìn)行動(dòng)畫開發(fā),,提供了好幾種動(dòng)畫實(shí)現(xiàn)的方式。其中比較常用的是屬性動(dòng)畫類(ObjectAnimator),,它通過定時(shí)以一定的曲線速率來 改變 View 的一系列屬性,,最后產(chǎn)生 View 的動(dòng)畫的效果。比較常見的屬性動(dòng)畫能夠動(dòng)態(tài)的改變 View 的大小,、顏色,、透明度,、位置等值,此種方式實(shí)現(xiàn)的效率比較高,,也是官方推薦的動(dòng)畫形式,。
為了進(jìn)一步的提升動(dòng)畫的效率,防止每次都需要多次調(diào)用 onMeasure,、onLayout,、onDraw,重新繪制 View 本身,。Android 還提出了一個(gè)層 Layer 的概念,。
通過將 View 保存在圖層中,對(duì)于平移,、旋轉(zhuǎn),、伸縮等動(dòng)畫,只需要對(duì)該層進(jìn)行整體變化,,而不再需要重新繪制 View 本身,。層 Layer 又分為軟繪層(Software Layer)和硬繪層(Harderware Layer) 。它們可以通過 View 類的 setLayerType(layerType, paint);方法進(jìn)行設(shè)置,。軟繪層將 View 存儲(chǔ)成 bitmap,,它會(huì)占用普通內(nèi)存;而硬繪層則將 View 存儲(chǔ)成紋理(Texture),,占用 GPU 中的存儲(chǔ),。需要注意的是,由于將 View 保存在圖層中,,都會(huì)占用相應(yīng)的內(nèi)存,,因此在動(dòng)畫結(jié)束之后需要重新設(shè)置成LAYER_ TYPE_ NONE,釋放內(nèi)存,。
由于普通的 View 都處于主線程中,,Android 除了繪制之外,在主線程中還需要處理用戶的各種點(diǎn)擊事件,。很多情況,,在主線程中還需要運(yùn)行額外的用戶處理邏輯、輪詢消息事件等,。如果主線程過于繁忙,,不能 及時(shí)的處理和響應(yīng)用戶的輸入,會(huì)讓用戶的體驗(yàn)急劇降低,。如果更嚴(yán)重的情況,當(dāng)主線程延遲時(shí)間達(dá)到5s的時(shí)候,,還會(huì)觸發(fā) ANR(Application Not Responding),。這樣當(dāng)界面的繪制和動(dòng)畫比較復(fù)雜,,計(jì)算量比較大的情況,就不再適合使用 View 這種方式來繪制了,。
Android 考慮到這種場(chǎng)景,,提出了 SurfaceView 的機(jī)制。SurfaceView 能夠在非 UI 線程中進(jìn)行圖形繪制,,釋放了 UI 線程的壓力,。SurfaceView 的使用方法一般是復(fù)寫一下三種方法:
public void surfaceCreated(SurfaceHolder holder);
public void surfaceChanged(SurfaceHolder holder, int format, int width,
int height);
public void surfaceDestroyed(SurfaceHolder holder);
surfaceCreated 在 SurfaceView 被創(chuàng)建的時(shí)候調(diào)用,一般在該方法中創(chuàng)建繪制線程,,并啟動(dòng)這個(gè)線程,。
surfaceDestroyed 在 SurfaceView 被銷毀的時(shí)候調(diào)用,在該方法中設(shè)置標(biāo)記位,,讓繪制線程停止運(yùn)行,。
繪制子線程中,一般是一個(gè) while 循環(huán),,通過判斷標(biāo)記位來決定是否退出該子線程,。使用 sleep 函數(shù)來定時(shí)的調(diào)起繪制邏輯。通過 mHolder.lockCanvas()來獲得 canvas,,繪制完畢之后調(diào)用 mHolder.unlockCanvasAndPost(canvas); 來上屏,。這里特別要注意繪制線程和 surfaceDestroyed 中需要加鎖。否則會(huì)有 SurfaceView 被銷毀了,,但是繪制子線程中還是持有對(duì) Canvas 的引用,,而導(dǎo)致 crash。下面是一個(gè)常用的框架:
private final Object mSurfaceLock = new Object();
private DrawThread mThread;
@Override
public void surfaceCreated(SurfaceHolder holder) {
mThread = new DrawThread(holder);
mThread.setRun(true);
mThread.start();
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width,
int height) {
//這里可以獲取SurfaceView的寬高等信息
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
synchronized (mSurfaceLock) { //這里需要加鎖,,否則doDraw中有可能會(huì)crash
mThread.setRun(false);
}
}
private class DrawThread extends Thread {
private SurfaceHolder mHolder;
private boolean mIsRun = false;
public DrawThread(SurfaceHolder holder) {
super(TAG);
mHolder = holder;
}
@Override
public void run() {
while(true) {
synchronized (mSurfaceLock) {
if (!mIsRun) {
return;
}
Canvas canvas = mHolder.lockCanvas();
if (canvas != null) {
doDraw(canvas); //這里做真正繪制的事情
mHolder.unlockCanvasAndPost(canvas);
}
}
Thread.sleep(SLEEP_TIME);
}
}
public void setRun(boolean isRun) {
this.mIsRun = isRun;
}
}
Android 為繪制圖形提供了 Canvas 類,,可以理解這個(gè)類是一塊畫布,它提供了在畫布上畫不同圖形的方法,。它提供了一系列的繪制各種圖形的 API,,比如繪制矩形、圓形,、橢圓等,。對(duì)應(yīng)的 API 都是 drawXXX的形式。
不規(guī)則的圖形的繪制比較特殊,,它同于規(guī)則圖形已有繪制公式的情況,,它有可能是任意的線條組成。Canvas 為畫不規(guī)則形狀,,提供了 Path 這個(gè)類,。通過 Path 能夠記錄各種軌跡,它可以是點(diǎn),、線,、各種形狀的組合,。通過 drawPath 這個(gè)方法即可繪制出任意圖形。
有了畫布 Canvas 類,,提供了繪制各種圖形的工具之后,,還需要指定畫筆的顏色,樣式等屬性,,才能有效的繪圖,。Android 提供了 Paint 這個(gè)類,來抽象畫筆,。通過 Paint 可以指定繪制的顏色,,是否填充,如果處理交集等屬性,。
動(dòng)畫實(shí)現(xiàn)
既然是實(shí)戰(zhàn),,當(dāng)然要有一個(gè)例子啦。這里以 TOS 里面的錄音機(jī)的波形動(dòng)效實(shí)現(xiàn)為例,。首先看一下設(shè)計(jì)獅童鞋給的視覺設(shè)計(jì)圖:
下面是動(dòng)起來的效果圖:
看到這么高大上的動(dòng)效圖,,不得不贊嘆一下設(shè)計(jì)獅童鞋,但同時(shí)也深深的捏了把汗——這個(gè)動(dòng)畫要咋實(shí)現(xiàn)捏,。
粗略的看一下上面的視覺圖,。感覺像是多個(gè)正弦曲線組成。每條正弦線好像中間高,,兩邊低,,應(yīng)該有一個(gè)對(duì)稱的衰減系數(shù)。同時(shí)有兩組上下對(duì)稱的正弦線,,在對(duì)稱的正弦線中間采用漸變顏色來進(jìn)行填充,。然后看動(dòng)效的效果圖,好像這個(gè)不規(guī)則的正弦曲線有一個(gè)固定的速率向前在運(yùn)動(dòng),。
看來為了實(shí)現(xiàn)這個(gè)動(dòng)效圖,,還得把都已經(jīng)還給老師的那點(diǎn)可憐的數(shù)學(xué)知識(shí)撿起來。下面是正弦曲線的公式:
A 代表的是振幅,,對(duì)應(yīng)的波峰和波谷的高度,,即 y 軸上的距離;ω 是角速度,,換成頻率是 2πf,,能夠控制波形的寬度;φ 是初始相位,,能夠決定正弦曲線的初始 x 軸位置,;k 是偏距,能夠控制在 y 軸上的偏移量
為了能夠更加直觀,將公式圖形化的顯示出來,,這里強(qiáng)烈推薦一個(gè)網(wǎng)站:https://www./calculator,,它能將輸入的公式轉(zhuǎn)換成坐標(biāo)圖。這正是我們需要的,。比如 sin(0.75πx - 0.5π) 對(duì)應(yīng)的圖形是下圖:
與上面設(shè)計(jì)圖中的相比,還需要乘上一個(gè)對(duì)稱的衰減函數(shù),。我們挑選了如下的衰減函數(shù)425/(4+x4):
將sin(0.75πx - 0.5π) 乘以這個(gè)衰減函數(shù) 425/(4+x4),,然后乘以0.5。最后得出了下圖:
看起來這個(gè)曲線與視覺圖中的曲線已經(jīng)很像了,,無非就是多加幾個(gè)算法類似,,但是相位不同的曲線罷了。如下圖:
看看,,用了我們足(quan)夠(bu)強(qiáng)(wang)大(ji)的數(shù)學(xué)知識(shí)之后,,我們好像也創(chuàng)造出來了類似視覺稿中的波形了。
接下來,,我們只需要在 SurfaceView 中使用 Path,,通過上面的公式計(jì)算出一個(gè)個(gè)的點(diǎn),然后畫直線連接起來就行啦,!于是我們得出了下面的實(shí)際效果(為了方便顯示,,已將背景調(diào)成白色):
曲線畫出來了,然后要做的就是漸變色的填充了,。這也是視覺還原比較難實(shí)現(xiàn)的地方,。
對(duì)于漸變填充,Android 提供了 LinearGradient 這個(gè)類,。它需要提供起始點(diǎn)和終結(jié)點(diǎn)的坐標(biāo),,以及起始點(diǎn)和終結(jié)點(diǎn)的顏色值:
public LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1,
TileMode tile);
TileMode 包括了 CLAMP、REPEAT,、MIRROR 三種模式,。它指定了,如果填充的區(qū)域超過了起始點(diǎn)和終結(jié)點(diǎn)的距離,,顏色重復(fù)的模式,。CLAMP 指使用終點(diǎn)邊緣的顏色,REPEAT 指重復(fù)的漸變,,而MIRROR則指的是鏡像重復(fù),。
從 LinearGradient 的構(gòu)造函數(shù)就可以預(yù)知,漸變填充的時(shí)候,,一定要指定精確的起始點(diǎn)和終結(jié)點(diǎn),。否則如果漸變距離大于填充區(qū)域,會(huì)出現(xiàn)漸變不完整,而漸變距離小于填充區(qū)域則會(huì)出現(xiàn)多個(gè)漸變或填不滿的情況,。如下圖所示:
圖中左邊是精確設(shè)置漸變起點(diǎn)和終點(diǎn)為矩形的頂部和底部,; 圖中中間為設(shè)置的漸變起點(diǎn)為頂部,終點(diǎn)為矩形的中間,; 右邊的則設(shè)置的漸變起點(diǎn)和終點(diǎn)都大于矩形的頂部和底部,。代碼如下:
LinearGradient gradient = new LinearGradient(100, mHeight_2 - 200, 100, mHeight_2 + 200,
line_1_start_color, region_1_end_color, Shader.TileMode.REPEAT);
mPaint.setShader(gradient);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(100, mHeight_2 - 200, 300, mHeight_2 + 200, mPaint);
gradient = new LinearGradient(400, mHeight_2 - 200, 400, mHeight_2,
line_1_start_color, region_1_end_color, Shader.TileMode.REPEAT);
mPaint.setShader(gradient);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(400, mHeight_2 - 200, 600, mHeight_2 + 200, mPaint);
gradient = new LinearGradient(700, mHeight_2 - 400, 700, mHeight_2 + 400,
line_1_start_color, region_1_end_color, Shader.TileMode.REPEAT);
mPaint.setShader(gradient);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(700, mHeight_2 - 200, 900, mHeight_2 + 200, mPaint);
對(duì)于矩形這種規(guī)則圖形進(jìn)行漸變填充,能夠很容易設(shè)置漸變顏色的起點(diǎn)和終點(diǎn),。但是對(duì)于上圖中的正弦曲線如果做到呢,? 難道需要將一組正弦曲線的每個(gè)點(diǎn)上下連接,使用漸變進(jìn)行繪制,? 那樣計(jì)算量將會(huì)是非常巨大的,!那又有其他什么好的方法呢?
Paint 中提供了 Xfermode 圖像混合模式的機(jī)制,。它能夠控制繪制圖形與之前已經(jīng)存在圖形的混合交疊模式,。其中比較有用的是 PorterDuffXfermode 這個(gè)類。它有多種混合模式,,如下圖所示:
這里 canvas 原有的圖片可以理解為背景,,就是 dst; 新畫上去的圖片可以理解為前景,,就是 src,。有了這種圖形混合技術(shù),能夠完成各種圖形交集的顯示,。
那我們是否可以腦洞大開一下,,將上圖已經(jīng)繪制好的波形圖,與漸變的矩形進(jìn)行交集,,將它們相交的地方畫出來呢,。它們相交的地方好像恰好就是我們需要的效果呢。
這樣,,我們只需要先填充波形,,然后在每組正弦線相交的封閉區(qū)域畫一個(gè)以波峰和波谷為高的矩形,然后將這個(gè)矩形染色成漸變色,。以這個(gè)矩形與波形做出交 集,,選擇 SrcIn 模式,即能只顯示相交部分矩形的這一塊的顏色,。這個(gè)方案看起來可行,,先試試。下面圖是沒有執(zhí)行 Xfermode 的疊加圖,,從圖中可以看出,,兩個(gè)正弦線中間的區(qū)域正是我們需要的,!
下面是執(zhí)行 SrcIn 模式混合之后的圖像:
神奇的事情出現(xiàn)了,視覺圖中的效果被還原了,。
我們?cè)僖篮J畫瓢,,再繪制另外一組正弦曲線。這里需要注意的是,,由于 Xfermode 中的 Dst 指的原有的背景,,因此這里兩組正弦線的混合會(huì)互相產(chǎn)生影響。即第二組在調(diào)用 SrcIn 模式進(jìn)行混合的時(shí)候,,會(huì)將第一組的圖形進(jìn)行剪切,。如下圖所示:
因此在繪制的時(shí)候,必須將兩組正弦曲線分開單獨(dú)繪制在不同 Canvas 層上,。好在 Android 系統(tǒng)為我們提供了這個(gè)功能,Android 提供了不同 Canvas 層,,以用于進(jìn)行離屏緩存的繪制,。我們可以先繪制一組圖形,然后調(diào)用 canvas.saveLayer 方法將它存在離屏緩存中,,然后再繪制另外一組曲線,。最后調(diào)用 canvas.restoreToCount(sc);方法恢復(fù) Canvas,將兩屏混合顯示,。最后的效果圖如下所示:
這里總結(jié)一下繪制的順序:
計(jì)算出曲線需要繪制的點(diǎn)
填充出正弦線
-
在每組正弦線相交的地方,,根據(jù)波峰波谷繪制出一個(gè)漸變線填充的矩形。并且設(shè)置圖形混合模式為 SrcIn
mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
對(duì)正弦線進(jìn)行描邊
離屏存儲(chǔ) Canvas,,再進(jìn)行下一組曲線的繪制
靜態(tài)的繪制已經(jīng)完成了,。接下來就是讓它動(dòng)起來了。根據(jù)上面給出來的框架,,在繪制線程中會(huì)定時(shí)執(zhí)行 doDraw 方法,。我們只需要在 doDraw 方法中每次將波形往前移動(dòng)一個(gè)距離,即可達(dá)到讓波形往前移動(dòng)的效果,。具體對(duì)應(yīng)到正弦公式中的 φ 值,,每次只需要在原有值的基礎(chǔ)上修改這個(gè)值即能改變波形在 X 軸的位置。每次執(zhí)行 doDraw 都會(huì)根據(jù)下面的計(jì)算方法重新計(jì)算圖形的初相值:
this.mPhase = (float) ((this.mPhase + Math.PI * mSpeed) % (2 * Math.PI));
在計(jì)算波形高度的時(shí)候,,還可以乘以音量大小,。即正弦公式中的 A 的值可以為 volume 繪制的最大高度 425/(4+x4)。這樣波形的振幅即能與音量正相關(guān),。波形可以隨著音量跳動(dòng)大小,。
動(dòng)畫的優(yōu)化
雖然上面已經(jīng)實(shí)現(xiàn)了波形的動(dòng)畫。但是如果以為工作已經(jīng)結(jié)束了,,那就真是太 sample,,naive了。
現(xiàn)在手機(jī)的分辨率變的越來越大,一般都是1080p的分辨率,。隨著分辨率的增加,,圖形繪制所需要的計(jì)算量也越來越大(像素點(diǎn)多了)。這樣導(dǎo)致在某些 低端手機(jī)中,,或某些偽高端手機(jī)(比如某星S4)中,,CPU 的計(jì)算能力不足,從而導(dǎo)致動(dòng)畫的卡頓,。因此對(duì)于自繪動(dòng)畫,,可能還需要不斷的進(jìn)行代碼和算法的優(yōu)化,提高繪制的效率,,盡量減少計(jì)算量,。
自繪動(dòng)畫優(yōu)化的最終目的是減少計(jì)算量,降低 CPU 的負(fù)擔(dān),。為了達(dá)到這個(gè)目的,,筆者總結(jié)歸納了以下幾種方法,如果大家有更多更好的方法,,歡迎分享:
1. 降低分辨率
在實(shí)際動(dòng)畫繪制的過程中,,如果對(duì)每個(gè)像素點(diǎn)的去計(jì)算(x,y)值,會(huì)導(dǎo)致大量的計(jì)算,。但是這種密集的計(jì)算往往都是不需要的,。對(duì)于動(dòng)畫,人的肉眼是有 一定的容忍度的,,在一定范圍內(nèi)的圖形失真是無法察覺的,,特別是那種一閃而過的東西更是如此。這樣在實(shí)現(xiàn)的時(shí)候,,可以都自己擬定一個(gè)比實(shí)際分辨率小很多的圖 形密度,,這個(gè)圖形密度上來計(jì)算 Y 值。然后將我們自己定義的圖形密度成比例的映射到真實(shí)的分辨率上,。比如上面繪制正弦曲線的時(shí)候,,我們完全可以只計(jì)算100個(gè)點(diǎn)。然后將這60個(gè)點(diǎn)成比例的 放在1024個(gè)點(diǎn)的X軸上,。這樣我們一下子便減少了接近10倍的計(jì)算量,。這有點(diǎn)類似柵格化一副圖片。
由于采用了低密度的繪制,,將這些低密度的點(diǎn)用直線連接起來,,會(huì)產(chǎn)生鋸齒的現(xiàn)象,這樣同樣會(huì)對(duì)體驗(yàn)產(chǎn)生影響,。但是別怕,,Android 已經(jīng)為我們提供了抗鋸齒的功能,。在 Paint 類中即可進(jìn)行設(shè)置:
mPaint.setAntiAlias(true);
使用 Android 優(yōu)化過了的抗鋸齒功能,一定會(huì)比我們每個(gè)點(diǎn)的去繪制效率更高,。
通過動(dòng)態(tài)調(diào)節(jié)自定義的繪制密度,,在繪制密度與最終實(shí)現(xiàn)效果中找到一個(gè)平衡點(diǎn)(即不影響最后的視覺效果,同時(shí)還能最大限度的減少計(jì)算量),,這個(gè)是最直接,,也最簡(jiǎn)單的優(yōu)化方法。
2. 減少實(shí)時(shí)計(jì)算量
我們知道在過去嵌入式設(shè)備中計(jì)算資源都是相當(dāng)有限的,,運(yùn)行的代碼經(jīng)常需要優(yōu)化,,甚至有時(shí)候需要在匯編級(jí)別進(jìn)行。雖然現(xiàn)在手機(jī)中的處理器已經(jīng)越來越強(qiáng) 大,,但是在處理動(dòng)畫這種短時(shí)間間隔的大量運(yùn)算,,還是需要仔細(xì)的編寫代碼。一般的動(dòng)畫刷新周期是16ms,,這也意味著動(dòng)畫的計(jì)算需要盡可能的少做運(yùn)算,。
只要能夠減少實(shí)時(shí)計(jì)算量的事情,都應(yīng)該是我們應(yīng)該做的,。那么如何才能做到盡量少做實(shí)時(shí)運(yùn)算呢? 一個(gè)比較重要的思維和方法是利用用空間來?yè)Q取時(shí)間,。一般我們?cè)谧鲎岳L動(dòng)畫的時(shí)候,,會(huì)需要做大量的中間運(yùn)算。而這些運(yùn)算有可能在每次繪制定時(shí)到來的時(shí)候,,產(chǎn) 生的結(jié)果都是一樣的,。這也意味著有可能我們重復(fù)的做出了需要冗余的計(jì)算。我們可以將這些中間運(yùn)算的結(jié)果,,存儲(chǔ)在內(nèi)存中,。這樣下次需要的時(shí)候,便不再需要重 新計(jì)算,,只需要取出來直接使用即可,。比較常用的查表法即使利用這種空間換時(shí)間的方法來提高速度的。
具體針對(duì)本例而言,,在計(jì)算 425/(4+x4) 這個(gè)衰減系數(shù)的時(shí)候,,對(duì)每個(gè) X 軸上固定點(diǎn)來說,它的計(jì)算結(jié)果都是相同的,。因此我們只需要將每個(gè)點(diǎn)對(duì)應(yīng)的 y 值存儲(chǔ)在一個(gè)數(shù)組中,,每次直接從這個(gè)數(shù)組中獲取即可。這樣能夠節(jié)省出不少 CPU 在計(jì)算乘方和除法運(yùn)算的計(jì)算量,。同樣道理,,由于 sin 函數(shù)具有周期性,,因此我們只需要將這個(gè)周期中的固定 N 個(gè)點(diǎn)計(jì)算出值,然后存儲(chǔ)在數(shù)組中,。每次需要計(jì)算 sin 值的時(shí)候,,直接從之前已經(jīng)計(jì)算好的結(jié)果中找出近似的那個(gè)就可以了。當(dāng)然其實(shí)這里計(jì)算 sin 不需要我們做這樣的優(yōu)化,,因?yàn)?Android 系統(tǒng)提供的 Math 方法庫(kù)中計(jì)算 sin 的方法肯定已經(jīng)運(yùn)用類似的原理優(yōu)化過了,。
CPU 一般都有一個(gè)特點(diǎn),它在快速的處理加減乘運(yùn)算,,但是在處理浮點(diǎn)型的除法的時(shí)候,,則會(huì)變的特別的慢,多要多個(gè)指令周期才能完成,。因此我們還應(yīng)該努力減少運(yùn)算 量,,特別是浮點(diǎn)型的除法運(yùn)算。一般比較通用的做法是講浮點(diǎn)型的運(yùn)算轉(zhuǎn)換成整型的運(yùn)算,,這樣對(duì)速度的提升也會(huì)比較明顯,。但是整型運(yùn)算同時(shí)也意味著會(huì)丟失數(shù)據(jù) 的精確度,這樣往往會(huì)導(dǎo)致繪制出來的圖形有鋸齒感,。之前有同事便遇到即使采用了 Android 系統(tǒng)提供的抗鋸齒方法,,但是繪制出來的圖形鋸齒感還是很強(qiáng)烈,有可能就是數(shù)值計(jì)算中的精確度的問題,,比如采用了不正確的整型計(jì)算,,或者錯(cuò)誤的四舍五入。為 了保證精確度,,同時(shí)還能使用整型來進(jìn)行運(yùn)算,,往往可以將需要計(jì)算的參數(shù),統(tǒng)一乘上一個(gè)精確度(比如乘以100或者1000,,視需要的精確范圍而定)取整計(jì) 算,,最后再將結(jié)果除以這個(gè)精確度。這里還需要注意整型溢出的問題,。
3. 減少內(nèi)存分配次數(shù)
Android 在內(nèi)存分配和釋放方面,,采用了 JAVA 的垃圾回收 GC 模式。當(dāng)分配的內(nèi)存不再使用的時(shí)候,,系統(tǒng)會(huì)定時(shí)幫我們自動(dòng)清理,。這給我們應(yīng)用開發(fā)帶來了極大的便利,我們從此不再需要過多的關(guān)注內(nèi)存的分配與回收,,也因此 減少很多內(nèi)存使用的風(fēng)險(xiǎn),。但是內(nèi)存的自動(dòng)回收,也意味著會(huì)消耗系統(tǒng)額外的資源,。一般的 GC 過程會(huì)消耗系統(tǒng)ms級(jí)別的計(jì)算時(shí)間,。在普通的場(chǎng)景中,,開發(fā)者無需過多的關(guān)心內(nèi)存的細(xì)節(jié)。但是在自繪動(dòng)畫開發(fā)中,,卻不能忽略內(nèi)存的分配,。
由于動(dòng)畫一般由一個(gè)16ms的定時(shí)器來進(jìn)行驅(qū)動(dòng),這意味著動(dòng)畫的邏輯代碼會(huì)在短時(shí)間內(nèi)被循環(huán)往復(fù)的調(diào)用,。這樣如果在邏輯代碼中在堆上創(chuàng)建過多的臨時(shí)變量,,會(huì)導(dǎo)致內(nèi)存的使用量在短時(shí)間穩(wěn)步上升,從而頻繁的引發(fā)系統(tǒng)的GC行為,。這樣無疑會(huì)拖累動(dòng)畫的效率,,讓動(dòng)畫變得卡頓。
處理分析內(nèi)存分配,,減少不必要的分配呢,,首先我們需要先分析內(nèi)存的分配行為。對(duì)于Android內(nèi)存的使用情況,,Android Studio提供了很好用,,直觀的分析工具。為了更加直觀的表現(xiàn)內(nèi)存分配的影響,,在程序中故意創(chuàng)建了一些比較大的臨時(shí)變量,。然后使用Memory Monitor工具得到了下面的圖:
并且在log中看到有頻繁的打印
D/dalvikvm: GC_FOR_ALLOC freed 3777K, 18% free 30426K/36952K, paused 33ms, total 34ms
圖中每次漲跌的鋸齒意味著發(fā)生了一次GC,然后又分配了多個(gè)內(nèi)存,,這個(gè)過程不斷的往復(fù),。從log中可以看到系統(tǒng)在頻繁的發(fā)起GC,并且每次GC都會(huì) 將系統(tǒng)暫停33ms,,這當(dāng)然會(huì)對(duì)動(dòng)畫造成影響。當(dāng)然這個(gè)是測(cè)試的比較極端的情況,,一般來說,,如果內(nèi)存被更加穩(wěn)定的使用的話,觸發(fā)GC的概率也會(huì)大大的降 低,,上面圖中的顛簸鋸齒出現(xiàn)到概率也會(huì)越低,。
上面內(nèi)存使用的情況,也被稱為內(nèi)存抖動(dòng),,它除了在周期性的調(diào)用過程中出現(xiàn),,另外一個(gè)高發(fā)場(chǎng)景是在for循環(huán)中分配、釋放內(nèi)存,。它影響的不僅僅是自繪動(dòng)畫中,,其他場(chǎng)景下也需要盡量避免。
從上圖中可以直觀的看到內(nèi)存在一定時(shí)間段內(nèi)分配和釋放的情況,,得出是否內(nèi)存的使用是否平穩(wěn),。但是當(dāng)出現(xiàn)問題之后,,我們還需要借助 Allocation Tracker 這個(gè)工具來追蹤問題發(fā)生的原因,并最后解決它,。Allocation Tracker 這個(gè)工具能夠幫助我們追蹤內(nèi)存對(duì)象的分配和釋放情況,,能夠獲取內(nèi)存對(duì)象的來源。比如上面的例子,,我們?cè)谝欢螘r(shí)間內(nèi)進(jìn)行追蹤,,可以得到如下圖:
從圖中我們可以看到大部分的內(nèi)存分配都來自線程18 Thread 18,這也是我們的動(dòng)畫的繪制線程,。從圖中可以看到主要的內(nèi)存分配有以下幾個(gè)地方:
我們故意創(chuàng)建的臨時(shí)大數(shù)組
來自 getColor 函數(shù),,它來自對(duì) getResources().getColor()的調(diào)用,需要獲取從系統(tǒng)資源中獲取顏色資源,。這個(gè)方法中會(huì)創(chuàng)建多個(gè) StringBuilder 的變量
創(chuàng)建 Xfermode 的臨時(shí)變量,,來自 mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); 這個(gè)調(diào)用
-
創(chuàng)建漸變值的
LinearGradient gradient = new LinearGradient(getXPos(startX), startY, getXPos(startX), endY, gradientStartColor, gradientEndColor, Shader.TileMode.REPEAT);
對(duì)于第2、3,,這些變量完全不需要每次循環(huán)執(zhí)行的時(shí)候,,重復(fù)創(chuàng)建變量。因?yàn)槊看嗡麄兊氖褂枚际枪潭ǖ???梢钥紤]將它們從臨時(shí)變量轉(zhuǎn)為成員變量,在動(dòng)畫初始化的同時(shí)也將這些成員變量初始化好,。需要的時(shí)候直接調(diào)用即可,。
而對(duì)于第4類這樣的內(nèi)存分配,由于每次動(dòng)畫中的波形形狀都不一樣,,因此漸變色必現(xiàn)得重新創(chuàng)建并設(shè)值,。因此這里并不能將它作為成員變量使用。這里是屬于必須要分配的,。好在這個(gè)對(duì)象也不大,,影響很小。
對(duì)于那些無法避免,,每次又必須分配的大量對(duì)象,,我們還能夠采用對(duì)象池模型的方式來分配對(duì)象。對(duì)象池來解決頻繁創(chuàng)建與銷毀的問題,,但是這里需要注意結(jié)束使用之后,,需要手動(dòng)釋放對(duì)象池中的對(duì)象。
經(jīng)過優(yōu)化的內(nèi)存分配,,會(huì)變得平緩很多,。比如對(duì)于上面的例子。去除上面故意創(chuàng)建的大量數(shù)組,,以及優(yōu)化了2,、3兩個(gè)點(diǎn)之后的內(nèi)存分配如下圖所示:
可以看出短時(shí)間內(nèi),,內(nèi)存并沒有什么明顯的變化。并且在很長(zhǎng)一段時(shí)間內(nèi)都沒有觸發(fā)一次 GC
4. 減少 Path 的創(chuàng)建次數(shù)
這里涉及到對(duì)特殊規(guī)則圖形的繪制的優(yōu)化,。Path 的創(chuàng)建也涉及到內(nèi)存的分配和釋放,,這些都是需要消耗資源的。并且對(duì)于越復(fù)雜的 Path,,Canvas 在繪制的時(shí)候,,也會(huì)更加的耗時(shí)。因此我們需要做的就是盡量?jī)?yōu)化 Path 的創(chuàng)建過程,,簡(jiǎn)化運(yùn)算量,。這一塊并沒有很多統(tǒng)一的標(biāo)準(zhǔn)方法,更多的是依靠經(jīng)驗(yàn),,并且將上面提到到的3點(diǎn)優(yōu)化方法靈活運(yùn)用,。
首先 Path 類中本身即提供了數(shù)據(jù)結(jié)構(gòu)重用的接口。它除了提供 reset 復(fù)位方法之外,,還提供了 rewind 的方法,。這樣每次動(dòng)畫循環(huán)調(diào)用的時(shí)候,能夠做到不釋放之前已經(jīng)分配的內(nèi)存就能夠重用,。這樣避免的內(nèi)存的反復(fù)釋放和分配,。特別是對(duì)于本例中,每次繪制的 Path 中的點(diǎn)都是一樣多的情況更加適用,。
采用方法一種低密度的繪圖方法,,同樣還能夠減少 Path 中線段的數(shù)量,這樣降低了 Path 構(gòu)造的次數(shù),,同能 Canvas 在繪制 Path 的時(shí)候,,由于 Path 變的簡(jiǎn)單了,同樣能夠加快繪制速度,。
特別的,,對(duì)于本文中的波形例子。視覺圖中給出來的效果圖,,除了要用漸變色填充正弦線中間的區(qū)域之外,。還需要對(duì)正弦線本身進(jìn)行描邊,。同時(shí)一組正弦線中的上下兩根正弦線的顏色還不一樣,。這樣對(duì)于一組完整的正弦線的繪制其實(shí)需要三個(gè)步驟:
填充正弦線
描正弦線上邊沿
描正弦線下邊沿
如何很好的將這三個(gè)步驟組合起來,盡量減少 Path 的創(chuàng)建也很有講究,。比如,,如果我們直接按照上面列出來的步驟來繪制的話,首先需要?jiǎng)?chuàng)建一個(gè)同時(shí)包含上下正弦線的 Path,,需要計(jì)算一遍上下正弦線的點(diǎn),,然后對(duì)這個(gè) Path 使用填充的方式來繪制,。然后再計(jì)算一遍上弦線的點(diǎn),創(chuàng)建只有上弦線的 Path,,然后使用 Stroke 的模式來繪制,,接著下弦線。這樣我們將會(huì)重復(fù)創(chuàng)建兩邊 Path,,并且還會(huì)重復(fù)一倍點(diǎn)坐標(biāo)的計(jì)算量,。
如果我們能采用上面步驟2中提到的,利用空間換取時(shí)間的方法,。首先把所有點(diǎn)位置都記在一個(gè)數(shù)組中,,然后利用這些點(diǎn)來計(jì)算并繪制上弦線的 Path,然后保存下來,;再計(jì)算和繪制下弦線的 Path 并保存,。最后創(chuàng)建一個(gè)專門記錄填充區(qū)的 Path,利用 mPath.addPath();的功能,,將之前的兩個(gè) path 填充到該 Path 中,。這樣便能夠減少 Path 的計(jì)算量。同時(shí)將三個(gè) Path 分別用不同的變量來記錄,,這樣在下次循環(huán)到來的時(shí)候,,還能利用 rewind 方法來進(jìn)行內(nèi)存重用。
這里需要注意的是,,Path 提供了 close的方法,,來將一段線封閉。這個(gè)函數(shù)能夠提供一定的方便,。但是并不是每個(gè)時(shí)候都好用,。有的時(shí)候,還是需要我們手動(dòng)的去添加線段來閉合一個(gè)區(qū)域,。比如下面圖中的情形,,采用 close,就會(huì)導(dǎo)致中間有一段空白的區(qū)域:
5. 優(yōu)化繪制的步驟
什么,? 經(jīng)過上面幾個(gè)步驟的優(yōu)化,,動(dòng)畫還是卡頓?不要慌,,這里再提供一個(gè)精確分析卡頓的工具,。Android 還為我們提供了能夠追蹤監(jiān)控每個(gè)方法執(zhí)行時(shí)間的工具 TraceView。它在 Android Device Monitor 中打開,。比如筆者在開發(fā)過程中發(fā)現(xiàn)動(dòng)畫有卡頓,,然后用上面 TraceView 工具查看得到下圖:
發(fā)現(xiàn) clapGradientRectAndDrawStroke 這個(gè)方法占用了72.1%的 CPU 時(shí)間,而這個(gè)方法中實(shí)際占用時(shí)間的是 drawPath。這說明此處的繪制存在明顯的缺陷與不合理,,大部分的時(shí)間都用在繪制 clapGradientRectAndDrawStroke 上面了,。那么我們?cè)倏匆幌轮袄L制的原理,為了能夠從矩形和正弦線之間剪切出交集,,并顯示漸變區(qū)域,。筆者做出了如下圖的嘗試:
首先繪制出漸變填充的矩形; 然后再將正弦線包裹的區(qū)域用透明顏色進(jìn)行反向填充(白色區(qū)域),,這樣它們交集的地方利用 SrcIn 模式進(jìn)行剪切,,這時(shí)候顯示出來便是白色覆蓋了矩形的區(qū)域(實(shí)際是透明色)加上它們未交集的地方(正弦框內(nèi))。這樣同樣能夠到達(dá)設(shè)計(jì)圖中給出的效果,。代碼如 下:
mPath.rewind();
mPath.addPath(mPathLine1);
mPath.lineTo(getXPos(mDensity - 1), -mLineCacheY[mDensity - 1] + mHeight_2 * 2);
mPath.addPath(mPathLine2);
mPath.lineTo(getXPos(0), mLineCacheY[0]);
mPath.setFillType(Path.FillType.INVERSE_WINDING);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setShader(null);
mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
mPaint.setColor(getResources().getColor(android.R.color.transparent));
canvas.drawPath(mPath, mPaint);
mPaint.setXfermode(null);
雖然上面的代碼同樣也實(shí)現(xiàn)了效果,,但是由于使用的反向填充,導(dǎo)致填充區(qū)域急劇變大,。最后導(dǎo)致 canvas.drawPath(mPath, mPaint);調(diào)用占據(jù)了70%以上的計(jì)算量,。
找到瓶頸點(diǎn)并知道原因之后,我們就能做出針對(duì)性的改進(jìn),。我們只需要調(diào)整繪制的順序,,先將正弦線區(qū)域內(nèi)做正向填充,然后再以 SrcIn 模式繪制漸變色填充的矩形,。這樣減少了需要繪制的區(qū)域,,同時(shí)也達(dá)到預(yù)期的效果。
下面是改進(jìn)之后 TraceView 的結(jié)果截圖:
從截圖中可以看到計(jì)算量被均分到不同的繪制方法中,,已經(jīng)沒有瓶頸點(diǎn)了,,并且實(shí)測(cè)動(dòng)畫也變得流暢了。一般卡頓都能通過此種方法比較精確的找到真正的瓶頸點(diǎn),。
總結(jié)
本文主要簡(jiǎn)單介紹了一下 Android 普通 View 和 SurfaceView 的繪制與動(dòng)畫原理,,然后介紹了一下錄音機(jī)波形動(dòng)畫的具體實(shí)現(xiàn)和優(yōu)化的方法。但是限于筆者的水平和經(jīng)驗(yàn)有限,,肯定有很多紕漏和錯(cuò)誤的地方,。大家有更多更好的 建議,歡迎一起分享討論,,共同進(jìn)步,。