1. 程式人生 > >Android自繪動畫實現與優化實戰

Android自繪動畫實現與優化實戰

前言
我們所熟知的,Android 的圖形繪製主要是基於 View 這個類實現。 每個 View 的繪製都需要經過 onMeasure、onLayout、onDraw 三步曲,分別對應到測量大小、佈局、繪製。

Android 系統為了簡化執行緒開發,降低應用開發的難度,將這三個過程都放在應用的主執行緒(UI 執行緒)中執行,以保證繪製系統的執行緒安全。

這三個過程通過一個叫 Choreographer 的定時器來驅動呼叫更新, Choreographer 每16ms被 vsync 這個訊號喚醒呼叫一次,這有點類似早期的電視機重新整理的機制。在 Choreographer 的 doFrame 方法中,通過樹狀結構儲存的 ViewGroup,依次遞迴的呼叫到每個 View 的 onMeasure、onLayout、onDraw 方法,從而最後將每個 View 都繪製出來(當然最後還會經過 SurfaceFlinger 的類來將 View 合成起來顯示,實際過程很複雜)。

同時每個 View 都儲存了很多標記值 flag,用來判斷是否該 View 需要重新被 Measure、Layout、Draw。 這樣對於那些沒有變化,不需要重繪的 View,則不再呼叫它們的方法,從而能夠提高繪製效率。

Android 為了方便開發者進行動畫開發,提供了好幾種動畫實現的方式。 其中比較常用的是屬性動畫類(ObjectAnimator),它通過定時以一定的曲線速率來改變 View 的一系列屬性,最後產生 View 的動畫的效果。比較常見的屬性動畫能夠動態的改變 View 的大小、顏色、透明度、位置等值,此種方式實現的效率比較高,也是官方推薦的動畫形式。

為了進一步的提升動畫的效率,防止每次都需要多次呼叫 onMeasure、onLayout、onDraw,重新繪製 View 本身。 Android 還提出了一個層 Layer 的概念。

通過將 View 儲存在圖層中,對於平移、旋轉、伸縮等動畫,只需要對該層進行整體變化,而不再需要重新繪製 View 本身。 層 Layer 又分為軟繪層(Software Layer)和硬繪層(Harderware Layer) 。它們可以通過 View 類的 setLayerType(layerType, paint);方法進行設定。軟繪層將 View 儲存成 bitmap,它會佔用普通記憶體;而硬繪層則將 View 儲存成紋理(Texture),佔用 GPU 中的儲存。 需要注意的是,由於將 View 儲存在圖層中,都會佔用相應的記憶體,因此在動畫結束之後需要重新設定成LAYER TYPE NONE,釋放記憶體。
由於普通的 View 都處於主執行緒中,Android 除了繪製之外,在主執行緒中還需要處理使用者的各種點選事件。很多情況,在主執行緒中還需要執行額外的使用者處理邏輯、輪詢訊息事件等。 如果主執行緒過於繁忙,不能及時的處理和響應使用者的輸入,會讓使用者的體驗急劇降低。如果更嚴重的情況,當主執行緒延遲時間達到5s的時候,還會觸發 ANR(Application Not Responding)。 這樣當介面的繪製和動畫比較複雜,計算量比較大的情況,就不再適合使用 View 這種方式來繪製了。

Android 考慮到這種場景,提出了 SurfaceView 的機制。SurfaceView 能夠在非 UI 執行緒中進行圖形繪製,釋放了 UI 執行緒的壓力。SurfaceView 的使用方法一般是複寫一下三種方法:

public void surfaceCreated(SurfaceHolder holder);
public void surfaceChanged(SurfaceHolder holder, int format, int width,
                           int height);
public void surfaceDestroyed(SurfaceHolder holder);

surfaceCreated 在 SurfaceView 被建立的時候呼叫, 一般在該方法中建立繪製執行緒,並啟動這個執行緒。
surfaceDestroyed 在 SurfaceView 被銷燬的時候呼叫,在該方法中設定標記位,讓繪製執行緒停止執行。
繪製子執行緒中,一般是一個 while 迴圈,通過判斷標記位來決定是否退出該子執行緒。 使用 sleep 函式來定時的調起繪製邏輯。 通過 mHolder.lockCanvas()來獲得 canvas,繪製完畢之後呼叫 mHolder.unlockCanvasAndPost(canvas);來上屏。 這裡特別要注意繪製執行緒和 surfaceDestroyed 中需要加鎖。否則會有 SurfaceView 被銷燬了,但是繪製子執行緒中還是持有對 Canvas 的引用,而導致 crash。下面是一個常用的框架:

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中有可能會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 類,可以理解這個類是一塊畫布,它提供了在畫布上畫不同圖形的方法。它提供了一系列的繪製各種圖形的 API, 比如繪製矩形、圓形、橢圓等。對應的 API 都是 drawXXX的形式。
不規則的圖形的繪製比較特殊,它同於規則圖形已有繪製公式的情況,它有可能是任意的線條組成。Canvas 為畫不規則形狀,提供了 Path 這個類。通過 Path 能夠記錄各種軌跡,它可以是點、線、各種形狀的組合。通過 drawPath 這個方法即可繪製出任意圖形。
有了畫布 Canvas 類,提供了繪製各種圖形的工具之後,還需要指定畫筆的顏色,樣式等屬性,才能有效的繪圖。Android 提供了 Paint 這個類,來抽象畫筆。 通過 Paint 可以指定繪製的顏色,是否填充,如果處理交集等屬性。

動畫實現
既然是實戰,當然要有一個例子啦。 這裡以 TOS 裡面的錄音機的波形動效實現為例。 首先看一下設計獅童鞋給的視覺設計圖:

下面是動起來的效果圖:

看到這麼高大上的動效圖,不得不讚嘆一下設計獅童鞋,但同時也深深的捏了把汗——這個動畫要咋實現捏。
粗略的看一下上面的視覺圖。 感覺像是多個正弦曲線組成。 每條正弦線好像中間高,兩邊低,應該有一個對稱的衰減係數。 同時有兩組上下對稱的正弦線,在對稱的正弦線中間採用漸變顏色來進行填充。然後看動效的效果圖,好像這個不規則的正弦曲線有一個固定的速率向前在運動。

看來為了實現這個動效圖,還得把都已經還給老師的那點可憐的數學知識撿起來。下面是正弦曲線的公式:
y=Asin(ωx+φ)+k
A 代表的是振幅,對應的波峰和波谷的高度,即 y 軸上的距離;ω 是角速度,換成頻率是 2πf,能夠控制波形的寬度;φ 是初始相位,能夠決定正弦曲線的初始 x 軸位置;k 是偏距,能夠控制在 y 軸上的偏移量

為了能夠更加直觀,將公式圖形化的顯示出來,這裡強烈推薦一個網站:https://www.desmos.com/calculator ,它能將輸入的公式轉換成座標圖。這正是我們需要的。比如 sin(0.75πx - 0.5π) 對應的圖形是下圖:

與上面設計圖中的相比,還需要乘上一個對稱的衰減函式。 我們挑選瞭如下的衰減函式 425/(4+x4):

將sin(0.75πx - 0.5π) 乘以這個衰減函式 425/(4+x4),然後乘以0.5。 最後得出了下圖:

看起來這個曲線與視覺圖中的曲線已經很像了,無非就是多加幾個演算法類似,但是相位不同的曲線罷了。 如下圖:

看看,用了我們足(quan)夠(bu)強(wang)大(ji)的數學知識之後, 我們好像也創造出來了類似視覺稿中的波形了。
接下來,我們只需要在 SurfaceView 中使用 Path,通過上面的公式計算出一個個的點,然後畫直線連線起來就行啦! 於是我們得出了下面的實際效果(為了方便顯示,已將背景調成白色):

曲線畫出來了,然後要做的就是漸變色的填充了。 這也是視覺還原比較難實現的地方。
對於漸變填充,Android 提供了 LinearGradient 這個類。它需要提供起始點和終結點的座標,以及起始點和終結點的顏色值:
[Java] 純文字檢視 複製程式碼
?
1
2
public LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1,
TileMode tile);
TileMode 包括了 CLAMP、REPEAT、MIRROR 三種模式。 它指定了,如果填充的區域超過了起始點和終結點的距離,顏色重複的模式。CLAMP 指使用終點邊緣的顏色,REPEAT 指重複的漸變,而MIRROR則指的是映象重複。

從 LinearGradient 的建構函式就可以預知,漸變填充的時候,一定要指定精確的起始點和終結點。否則如果漸變距離大於填充區域,會出現漸變不完整,而漸變距離小於填充區域則會出現多個漸變或填不滿的情況。如下圖所示:

圖中左邊是精確設定漸變起點和終點為矩形的頂部和底部; 圖中中間為設定的漸變起點為頂部,終點為矩形的中間; 右邊的則設定的漸變起點和終點都大於矩形的頂部和底部。程式碼如下:

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);

對於矩形這種規則圖形進行漸變填充,能夠很容易設定漸變顏色的起點和終點。 但是對於上圖中的正弦曲線如果做到呢? 難道需要將一組正弦曲線的每個點上下連線,使用漸變進行繪製? 那樣計算量將會是非常巨大的!那又有其他什麼好的方法呢?
Paint 中提供了 Xfermode 影象混合模式的機制。 它能夠控制繪製圖形與之前已經存在圖形的混合交疊模式。其中比較有用的是 PorterDuffXfermode 這個類。它有多種混合模式,如下圖所示:

這裡 canvas 原有的圖片可以理解為背景,就是 dst; 新畫上去的圖片可以理解為前景,就是 src。有了這種圖形混合技術,能夠完成各種圖形交集的顯示。
那我們是否可以腦洞大開一下,將上圖已經繪製好的波形圖,與漸變的矩形進行交集,將它們相交的地方畫出來呢。 它們相交的地方好像恰好就是我們需要的效果呢。

這樣,我們只需要先填充波形,然後在每組正弦線相交的封閉區域畫一個以波峰和波谷為高的矩形,然後將這個矩形染色成漸變色。以這個矩形與波形做出交集,選擇 SrcIn 模式,即能只顯示相交部分矩形的這一塊的顏色。 這個方案看起來可行,先試試。下面圖是沒有執行 Xfermode 的疊加圖, 從圖中可以看出,兩個正弦線中間的區域正是我們需要的!

下面是執行 SrcIn 模式混合之後的影象:

神奇的事情出現了, 視覺圖中的效果被還原了。

我們再依葫蘆畫瓢,再繪製另外一組正弦曲線。 這裡需要注意的是,由於 Xfermode 中的 Dst 指的原有的背景,因此這裡兩組正弦線的混合會互相產生影響。 即第二組在呼叫 SrcIn 模式進行混合的時候,會將第一組的圖形進行剪下。如下圖所示:

因此在繪製的時候,必須將兩組正弦曲線分開單獨繪製在不同 Canvas 層上。 好在 Android 系統為我們提供了這個功能,Android 提供了不同 Canvas 層,以用於進行離屏快取的繪製。我們可以先繪製一組圖形,然後呼叫 canvas.saveLayer 方法將它存在離屏快取中,然後再繪製另外一組曲線。最後呼叫 canvas.restoreToCount(sc);方法恢復 Canvas,將兩屏混合顯示。最後的效果圖如下所示:

這裡總結一下繪製的順序:
1、計算出曲線需要繪製的點
2、填充出正弦線
3、在每組正弦線相交的地方,根據波峰波谷繪製出一個漸變線填充的矩形。並且設定圖形混合模式為 SrcIn
[Java] 純文字檢視 複製程式碼
?
1
mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
4、對正弦線進行描邊
5、離屏儲存 Canvas,再進行下一組曲線的繪製

靜態的繪製已經完成了。接下來就是讓它動起來了。 根據上面給出來的框架,在繪製執行緒中會定時執行 doDraw 方法。我們只需要在 doDraw 方法中每次將波形往前移動一個距離,即可達到讓波形往前移動的效果。具體對應到正弦公式 y=Asin(ωx+φ)+k 中的 φ 值,每次只需要在原有值的基礎上修改這個值即能改變波形在 X 軸的位置。每次執行 doDraw 都會根據下面的計算方法重新計算圖形的初相值:
[Java] 純文字檢視 複製程式碼
?
1
this.mPhase = (float) ((this.mPhase + Math.PI * mSpeed) % (2 * Math.PI));
在計算波形高度的時候,還可以乘以音量大小。即正弦公式中的 A 的值可以為 volume * 繪製的最大高度 * 425/(4+x4)。 這樣波形的振幅即能與音量正相關。波形可以隨著音量跳動大小。

動畫的優化
雖然上面已經實現了波形的動畫。但是如果以為工作已經結束了,那就真是太 sample,naive了。
現在手機的解析度變的越來越大,一般都是1080p的解析度。隨著解析度的增加,圖形繪製所需要的計算量也越來越大(畫素點多了)。這樣導致在某些低端手機中,或某些偽高階手機(比如某星S4)中,CPU 的計算能力不足,從而導致動畫的卡頓。 因此對於自繪動畫,可能還需要不斷的進行程式碼和演算法的優化,提高繪製的效率,儘量減少計算量。
自繪動畫優化的最終目的是減少計算量,降低 CPU 的負擔。為了達到這個目的,筆者總結歸納了以下幾種方法,如果大家有更多更好的方法,歡迎分享:

1、降低解析度
在實際動畫繪製的過程中,如果對每個畫素點的去計算(x,y)值,會導致大量的計算。但是這種密集的計算往往都是不需要的。 對於動畫,人的肉眼是有一定的容忍度的,在一定範圍內的圖形失真是無法察覺的,特別是那種一閃而過的東西更是如此。 這樣在實現的時候,可以都自己擬定一個比實際解析度小很多的圖形密度,這個圖形密度上來計算 Y 值。然後將我們自己定義的圖形密度成比例的對映到真實的解析度上。 比如上面繪製正弦曲線的時候,我們完全可以只計算100個點。然後將這60個點成比例的放在1024個點的X軸上。 這樣我們一下子便減少了接近10倍的計算量。這有點類似柵格化一副圖片。

由於採用了低密度的繪製,將這些低密度的點用直線連線起來,會產生鋸齒的現象,這樣同樣會對體驗產生影響。但是別怕,Android 已經為我們提供了抗鋸齒的功能。在 Paint 類中即可進行設定:
[Java] 純文字檢視 複製程式碼
?
1
mPaint.setAntiAlias(true);
使用 Android 優化過了的抗鋸齒功能,一定會比我們每個點的去繪製效率更高。
通過動態調節自定義的繪製密度,在繪製密度與最終實現效果中找到一個平衡點(即不影響最後的視覺效果,同時還能最大限度的減少計算量),這個是最直接,也最簡單的優化方法。

2、減少實時計算量
我們知道在過去嵌入式裝置中計算資源都是相當有限的,執行的程式碼經常需要優化,甚至有時候需要在彙編級別進行。雖然現在手機中的處理器已經越來越強大,但是在處理動畫這種短時間間隔的大量運算,還是需要仔細的編寫程式碼。 一般的動畫重新整理週期是16ms,這也意味著動畫的計算需要儘可能的少做運算。
只要能夠減少實時計算量的事情,都應該是我們應該做的。那麼如何才能做到儘量少做實時運算呢? 一個比較重要的思維和方法是利用用空間來換取時間。一般我們在做自繪動畫的時候,會需要做大量的中間運算。而這些運算有可能在每次繪製定時到來的時候,產生的結果都是一樣的。這也意味著有可能我們重複的做出了需要冗餘的計算。 我們可以將這些中間運算的結果,儲存在記憶體中。這樣下次需要的時候,便不再需要重新計算,只需要取出來直接使用即可。 比較常用的查表法即使利用這種空間換時間的方法來提高速度的。

具體針對本例而言, 在計算 425/(4+x4) 這個衰減係數的時候,對每個 X 軸上固定點來說,它的計算結果都是相同的。 因此我們只需要將每個點對應的 y 值儲存在一個數組中,每次直接從這個陣列中獲取即可。這樣能夠節省出不少 CPU 在計算乘方和除法運算的計算量。 同樣道理,由於 sin 函式具有周期性,因此我們只需要將這個週期中的固定 N 個點計算出值,然後儲存在陣列中。每次需要計算 sin 值的時候,直接從之前已經計算好的結果中找出近似的那個就可以了。 當然其實這裡計算 sin 不需要我們做這樣的優化,因為 Android 系統提供的 Math 方法庫中計算 sin 的方法肯定已經運用類似的原理優化過了。

CPU 一般都有一個特點,它在快速的處理加減乘運算,但是在處理浮點型的除法的時候,則會變的特別的慢,多要多個指令週期才能完成。因此我們還應該努力減少運算量,特別是浮點型的除法運算。 一般比較通用的做法是講浮點型的運算轉換成整型的運算,這樣對速度的提升也會比較明顯。 但是整型運算同時也意味著會丟失資料的精確度,這樣往往會導致繪製出來的圖形有鋸齒感。 之前有同事便遇到即使採用了 Android 系統提供的抗鋸齒方法,但是繪製出來的圖形鋸齒感還是很強烈,有可能就是數值計算中的精確度的問題,比如採用了不正確的整型計算,或者錯誤的四捨五入。 為了保證精確度,同時還能使用整型來進行運算,往往可以將需要計算的引數,統一乘上一個精確度(比如乘以100或者1000,視需要的精確範圍而定)取整計算,最後再將結果除以這個精確度。 這裡還需要注意整型溢位的問題。

3、減少記憶體分配次數
Android 在記憶體分配和釋放方面,採用了 JAVA 的垃圾回收 GC 模式。 當分配的記憶體不再使用的時候,系統會定時幫我們自動清理。這給我們應用開發帶來了極大的便利,我們從此不再需要過多的關注記憶體的分配與回收,也因此減少很多記憶體使用的風險。但是記憶體的自動回收,也意味著會消耗系統額外的資源。一般的 GC 過程會消耗系統ms級別的計算時間。在普通的場景中,開發者無需過多的關心記憶體的細節。但是在自繪動畫開發中,卻不能忽略記憶體的分配。

由於動畫一般由一個16ms的定時器來進行驅動,這意味著動畫的邏輯程式碼會在短時間內被迴圈往復的呼叫。 這樣如果在邏輯程式碼中在堆上建立過多的臨時變數,會導致記憶體的使用量在短時間穩步上升,從而頻繁的引發系統的GC行為。這樣無疑會拖累動畫的效率,讓動畫變得卡頓。
處理分析記憶體分配,減少不必要的分配呢, 首先我們需要先分析記憶體的分配行為。 對於Android記憶體的使用情況,Android Studio提供了很好用,直觀的分析工具。 為了更加直觀的表現記憶體分配的影響,在程式中故意建立了一些比較大的臨時變數。然後使用Memory Monitor工具得到了下面的圖:

並且在log中看到有頻繁的列印D/dalvikvm: GC_FOR_ALLOC freed 3777K, 18% free 30426K/36952K, paused 33ms, total 34ms
圖中每次漲跌的鋸齒意味著發生了一次GC,然後又分配了多個記憶體,這個過程不斷的往復。 從log中可以看到系統在頻繁的發起GC,並且每次GC都會將系統暫停33ms,這當然會對動畫造成影響。 當然這個是測試的比較極端的情況,一般來說,如果記憶體被更加穩定的使用的話,觸發GC的概率也會大大的降低,上面圖中的顛簸鋸齒出現到概率也會越低。
上面記憶體使用的情況,也被稱為記憶體抖動,它除了在週期性的呼叫過程中出現,另外一個高發場景是在for迴圈中分配、釋放記憶體。它影響的不僅僅是自繪動畫中,其他場景下也需要儘量避免。

從上圖中可以直觀的看到記憶體在一定時間段內分配和釋放的情況,得出是否記憶體的使用是否平穩。但是當出現問題之後,我們還需要藉助 Allocation Tracker 這個工具來追蹤問題發生的原因,並最後解決它。Allocation Tracker 這個工具能夠幫助我們追蹤記憶體物件的分配和釋放情況,能夠獲取記憶體物件的來源。比如上面的例子,我們在一段時間內進行追蹤,可以得到如下圖:

從圖中我們可以看到大部分的記憶體分配都來自執行緒18 Thread 18,這也是我們的動畫的繪製執行緒。 從圖中可以看到主要的記憶體分配有以下幾個地方:
1、我們故意建立的臨時大陣列
2、來自 getColor 函式, 它來自對 getResources().getColor()的呼叫,需要獲取從系統資源中獲取顏色資源。這個方法中會建立多個 StringBuilder 的變數
3、建立 Xfermode 的臨時變數,來自 mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); 這個呼叫。
4、建立漸變值的 LinearGradient gradient = new LinearGradient(getXPos(startX), startY, getXPos(startX), endY,
gradientStartColor, gradientEndColor, Shader.TileMode.REPEAT);

對於第2、3,這些變數完全不需要每次迴圈執行的時候,重複建立變數。 因為每次他們的使用都是固定的。可以考慮將它們從臨時變數轉為成員變數,在動畫初始化的同時也將這些成員變數初始化好。需要的時候直接呼叫即可。
而對於第4類這樣的記憶體分配,由於每次動畫中的波形形狀都不一樣,因此漸變色必現得重新建立並設值。因此這裡並不能將它作為成員變數使用。這裡是屬於必須要分配的。好在這個物件也不大,影響很小。
對於那些無法避免,每次又必須分配的大量物件,我們還能夠採用物件池模型的方式來分配物件。物件池來解決頻繁建立與銷燬的問題,但是這裡需要注意結束使用之後,需要手動釋放物件池中的物件。

經過優化的記憶體分配,會變得平緩很多。比如對於上面的例子。 去除上面故意建立的大量陣列,以及優化了2、3兩個點之後的記憶體分配如下圖所示:

可以看出短時間內,記憶體並沒有什麼明顯的變化。並且在很長一段時間內都沒有觸發一次 GC

4、減少 Path 的建立次數
這裡涉及到對特殊規則圖形的繪製的優化。 Path 的建立也涉及到記憶體的分配和釋放,這些都是需要消耗資源的。並且對於越複雜的 Path,Canvas 在繪製的時候,也會更加的耗時。因此我們需要做的就是儘量優化 Path 的建立過程,簡化運算量。這一塊並沒有很多統一的標準方法,更多的是依靠經驗,並且將上面提到到的3點優化方法靈活運用。

首先 Path 類中本身即提供了資料結構重用的介面。它除了提供 reset 復位方法之外,還提供了 rewind 的方法。這樣每次動畫迴圈呼叫的時候,能夠做到不釋放之前已經分配的記憶體就能夠重用。這樣避免的記憶體的反覆釋放和分配。特別是對於本例中,每次繪製的 Path 中的點都是一樣多的情況更加適用。

採用方法一種低密度的繪圖方法,同樣還能夠減少 Path 中線段的數量,這樣降低了 Path 構造的次數,同能 Canvas 在繪製 Path 的時候,由於 Path 變的簡單了,同樣能夠加快繪製速度。

特別的,對於本文中的波形例子。 視覺圖中給出來的效果圖,除了要用漸變色填充正弦線中間的區域之外。還需要對正弦線本身進行描邊。 同時一組正弦線中的上下兩根正弦線的顏色還不一樣。 這樣對於一組完整的正弦線的繪製其實需要三個步驟:
1、填充正弦線
2、描正弦線上邊沿
3、描正弦線下邊沿

如何很好的將這三個步驟組合起來,儘量減少 Path 的建立也很有講究。比如,如果我們直接按照上面列出來的步驟來繪製的話,首先需要建立一個同時包含上下正弦線的 Path,需要計算一遍上下正弦線的點,然後對這個 Path 使用填充的方式來繪製。 然後再計算一遍上弦線的點,建立只有上弦線的 Path,然後使用 Stroke 的模式來繪製,接著下弦線。 這樣我們將會重複建立兩邊 Path,並且還會重複一倍點座標的計算量。

如果我們能採用上面步驟2中提到的,利用空間換取時間的方法。 首先把所有點位置都記在一個數組中,然後利用這些點來計算並繪製上弦線的 Path,然後儲存下來;再計算和繪製下弦線的 Path 並儲存。最後建立一個專門記錄填充區的 Path,利用 mPath.addPath();的功能,將之前的兩個 path 填充到該 Path 中。 這樣便能夠減少 Path 的計算量。同時將三個 Path 分別用不同的變數來記錄,這樣在下次迴圈到來的時候,還能利用 rewind 方法來進行記憶體重用。

這裡需要注意的是,Path 提供了 close的方法,來將一段線封閉。 這個函式能夠提供一定的方便。但是並不是每個時候都好用。有的時候,還是需要我們手動的去新增線段來閉合一個區域。比如下面圖中的情形,採用 close,就會導致中間有一段空白的區域:

5、優化繪製的步驟
什麼? 經過上面幾個步驟的優化,動畫還是卡頓?不要慌,這裡再提供一個精確分析卡頓的工具。 Android 還為我們提供了能夠追蹤監控每個方法執行時間的工具 TraceView。 它在 Android Device Monitor 中開啟。比如筆者在開發過程中發現動畫有卡頓,然後用上面 TraceView 工具檢視得到下圖:

發現 clapGradientRectAndDrawStroke 這個方法佔用了72.1%的 CPU 時間,而這個方法中實際佔用時間的是 drawPath。這說明此處的繪製存在明顯的缺陷與不合理,大部分的時間都用在繪製 clapGradientRectAndDrawStroke 上面了。那麼我們再看一下之前繪製的原理,為了能夠從矩形和正弦線之間剪切出交集,並顯示漸變區域。筆者做出瞭如下圖的嘗試:

首先繪製出漸變填充的矩形; 然後再將正弦線包裹的區域用透明顏色進行反向填充(白色區域),這樣它們交集的地方利用 SrcIn 模式進行剪下,這時候顯示出來便是白色覆蓋了矩形的區域(實際是透明色)加上它們未交集的地方(正弦框內)。這樣同樣能夠到達設計圖中給出的效果。程式碼如下:

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);

雖然上面的程式碼同樣也實現了效果,但是由於使用的反向填充,導致填充區域急劇變大。最後導致 canvas.drawPath(mPath, mPaint);呼叫佔據了70%以上的計算量。
找到瓶頸點並知道原因之後,我們就能做出針對性的改進。 我們只需要調整繪製的順序,先將正弦線區域內做正向填充,然後再以 SrcIn 模式繪製漸變色填充的矩形。 這樣減少了需要繪製的區域,同時也達到預期的效果。

下面是改進之後 TraceView 的結果截圖:

從截圖中可以看到計算量被均分到不同的繪製方法中,已經沒有瓶頸點了,並且實測動畫也變得流暢了。 一般卡頓都能通過此種方法比較精確的找到真正的瓶頸點。

總結
本文主要簡單介紹了一下 Android 普通 View 和 SurfaceView 的繪製與動畫原理,然後介紹了一下錄音機波形動畫的具體實現和優化的方法。但是限於筆者的水平和經驗有限,肯定有很多紕漏和錯誤的地方。大家有更多更好的建議,歡迎一起分享討論,共同進步。