1. 程式人生 > >自定義元件開發四 雙快取技術

自定義元件開發四 雙快取技術

雙快取
為什麼叫“雙快取”?說白了就是有兩個繪圖區,一個是 Bitmap 的 Canvas,另一個就是當前View 的 Canvas。先將圖形繪製在 Bitmap 上,然後再將 Bitmap 繪製在 View 上,也就是說,我們在 View 上看到的效果其實就是 Bitmap 上的內容。這樣做有什麼意義呢?概括起來,有以下幾點:

提高繪圖效能
先將內容繪製在 Bitmap 上,再統一將內容繪製在 View 上,可以提高繪圖的效能。

可以在螢幕上展示繪圖的過程
將線條直接繪製在 View 上和先繪製在 Bitmap 上再繪製在 View 上是感受不到這個作用的但是,如果是畫一個矩形呢?情況就完全不一樣了。我們用手指在螢幕上按下,斜拉,此時應該從按下的位置開始,拉出一個隨手指變化大小的矩形。因為要向用戶展示整個過程,所以需要不斷繪製矩形,但是,對,但是,手指擡起後留下的其實只需要最後一個,所以,問題就在這裡。怎麼解決呢?使用雙快取。在 View 的 onDraw()方法中繪製用於展示繪製過程的矩形,在手指移動的過程中,會不斷重新整理重繪,使用者總能看到當前應有的大小的矩形,而且不會留下歷史痕跡(因為重繪了,只重繪最後一次的)。

儲存繪圖歷史
前面提到,因為直接在 View 的 Canvas 上繪圖不會儲存歷史痕跡,所以也帶來了副作用,以前繪製的內容也沒有了(可能當前繪製的是第二個矩形),這個時候,雙快取的優勢就體現出來了,我們可以將繪製的歷史結果儲存在一個 Bitmap 上,當手指鬆開時,將最後的矩形繪製在 Bitmap 上,同時再將 Bitmap 的內容整個繪製在 View 上。

在螢幕上繪製曲線
在螢幕上繪製曲線根本不會遇到什麼問題,只要知道在螢幕上隨手指繪製曲線的原理就行了。我們簡要的分析一下。

我們在螢幕上繪製的曲線,本質上是由無數條直線構成的,就算曲線比較平滑,看不到折線,也是由於構成曲線的直線足夠短,我們用下面的示意圖(如圖 4-1 所示)來說明這個問題:
這裡寫圖片描述

當手指在螢幕上移動時,會產生三個動作:手指按下(ACTION_DOWN)、手指移動(ACTION_MOVE)、手指鬆開(ACTION_UP)。手指按下時,要記錄手指所在的座標,假設此時的x 方向和 y 方向的座標分別為 preX 和 preY,當手指在螢幕上移動時,系統會每隔一段時間自動告知手指的當前位置,假設手指的當前位置是 x 和 y。現在,上一個點的座標為(preX,preY),當前點的座標是(x,y),呼叫 drawLine(preX, preY, x, y, paint)方法可以將這兩個點連線起來,同時,當前點的座標會成為下一條直線的上一個點的座標,preX = x,preY = y,如此迴圈反覆,直到鬆開手指,一條由若干條直線組成的曲線便繪製好了。

雖然我們知道,呼叫 View 的 invalidate()方法重繪時,最終呼叫的是 onDraw()方法,但一定要注意,由於重繪請求最終會一級級往上提交到 ViewRoot,然後 ViewRoot 再呼叫scheduleTraversals()方法發起重繪請求,而 scheduleTraversals()傳送的是非同步訊息,所以,在通過手勢繪製線條時,為了解決這個問題,可以使用 Path 繪圖,但如果要儲存繪圖歷史,就要使用雙快取技術了。

下面的程式碼實現了通過手勢在螢幕上繪製曲線的功能:

public class Line1View extends View {

    /**
     * 上一個點的座標
     */
    private int preX, preY;
    /**
     * 當前點的座標
     */
    private int currentX, currentY;
    /**
     * Bitmap 快取區
     */
    private Bitmap bitmapBuffer;
    private Canvas bitmapCanvas;
    private Paint paint;

    public Line1View(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setStyle(Paint.Style.STROKE);
        paint.setColor(Color.RED);
        paint.setStrokeWidth(5);
    }

    /**
     * 元件大小發生改變時回撥 onSizeChanged 方法,我們在這裡建立 Bitmap
     */
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (bitmapBuffer == null) {
            int width = getMeasuredWidth();//獲取 View 的寬度
            int height = getMeasuredHeight(); //獲取 View 的高度
            //新建 Bitmap 物件
            bitmapBuffer = Bitmap.createBitmap(width, height,
                    Bitmap.Config.ARGB_8888);
            bitmapCanvas = new Canvas(bitmapBuffer);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //將 Bitmap 中的內容繪製在 View 上
        canvas.drawBitmap(bitmapBuffer, 0, 0, null);
    }

    /**
     * 處理手勢
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //手指按下,記錄第一個點的座標
                preX = x;
                preY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //手指移動,記錄當前點的座標
                currentX = x;
                currentY = y;
                bitmapCanvas.drawLine(preX, preY, currentX, currentY, paint);
                this.invalidate();
                //當前點的座標成為下一個點的起始座標
                preX = currentX;
                preY = currentY;
                break;
            case MotionEvent.ACTION_UP:
                invalidate();
                break;
            default:
                break;
        }
        return true;
    }
}

首先定義了一個名為 bitmapBuffer 的 Bitmap 物件,為了在該物件上繪圖,建立了一個與之關聯的 Canvas 物件 bitmapCanvas。建立 Bitmap 物件時,需要考慮它的大小,在 Line1View類的構造方法中,因為 Line1View 尚未建立,還不知道寬度和高度,所以,重寫了 onSizeChanged()方法,該方法在元件建立後且大小發生改變時回撥(View 第一次顯示時肯定會呼叫),程式碼中看到,Bitmap 物件的寬度和高度與 View 相同。手指按下後,將第一次的座標值儲存在 preX 和 preY兩個變數中,手指移動時,獲取手指所在的新位置,並儲存到 currentX 和 currentY 中,此時,已經知道了起點和終點兩個點的座標,將這兩個點確定的一條直線繪製到 bitmapBuffer 物件,然後,立馬又將 bitmapBuffer 物件繪製在 View 上,最後,重新設定 preX 和 preY 的值,確保(preX,preY)成為下一個點的起始點座標。bitmapBuffer 物件儲存了所有的繪圖歷史,這也是雙快取的作用之一。

上面的例子是直接在 Bitmap 關聯的 Canvas 上繪製直線,其實更好的做法是通過 Path來繪圖,不管從功能上還是效率上這都是更優的選擇,主要體現在:

Path 可以用於儲存實時繪圖座標,避免呼叫 invalidate()方法重繪時因 ViewRoot 的scheduleTraversals()方法傳送非同步請求出現的問題;

Path 可以用來繪製複雜的圖形;

使用 Path 繪圖效率更高。

public class Line2View extends View {
    private Path path;
    private int preX, preY;
    private Paint paint;
    public Line2View(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setStyle(Paint.Style.STROKE);
        paint.setColor(Color.RED);

        paint.setStrokeWidth(5);
        path = new Path();
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawPath(path, paint);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                path.reset();
                preX = x;
                preY = y;
                //移動
                path.moveTo(x, y);
                break;
            case MotionEvent.ACTION_MOVE:
                //繪製曲線
                path.quadTo(preX, preY, x, y);
                invalidate();
                preX = x;
                preY = y;
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
        return true;
    }
}

上面使用了 Path 來繪製曲線,Path 物件儲存了手指從按下到移動到鬆開的整個運動軌跡,進行第二次繪製時,Path 呼叫 reset()方法重置,繼續進行下一條曲線的繪圖。通過呼叫 quadTo()方法繪製二階貝塞爾曲線,因為需要指定一個起始點,所以手指按下時呼叫了 moveTo(x, y)方法。但是,執行後我們發現,繪製當前曲線沒有問題,但繪製下一條曲線的時候前一條曲線消失了,原因是沒有儲存繪圖歷史,這需要通過“雙快取”技術來解決。

public class Line2View extends View {
    private Path path;
    private int preX, preY;
    private Paint paint;
    private Bitmap bitmapBuffer;
    private Canvas bitmapCanvas;
    public Line2View(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setStyle(Paint.Style.STROKE);
        paint.setColor(Color.RED);

        paint.setStrokeWidth(5);
        path = new Path();
    }
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if(bitmapBuffer == null){
            int width = getMeasuredWidth();
            int height = getMeasuredHeight();
            bitmapBuffer = Bitmap.createBitmap(width, height,
                    Bitmap.Config.ARGB_8888);
            bitmapCanvas = new Canvas(bitmapBuffer);
        }
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bitmapBuffer, 0, 0, null);
        canvas.drawPath(path, paint);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                path.reset();
                preX = x;
                preY = y;
                path.moveTo(x, y);
                break;
            case MotionEvent.ACTION_MOVE:
                path.quadTo(preX, preY, x, y);
                invalidate();
                preX = x;
                preY = y;
                break;
            case MotionEvent.ACTION_UP:
                //手指鬆開後將最終的繪圖結果繪製在 bitmapBuffer 中,同時繪製到 View 上
                bitmapCanvas.drawPath(path, paint);
                invalidate();
                break;
            default:
                break;
        }
        return true;
    }
}

在畫曲線時,使用了 Path 類的 quadTo()方法,該方法能繪製出相對平滑的貝塞爾曲線,但是控制點和起點使用了同一個點,這樣效果不是很理想。現提供一種計算控制點的方法,假如起點座標為(x1,y1),終點座標為(x2,y2),控制點座標即為((x1 + x2)/ 2,(y1 + y2)/ 2)。case MotionEvent.ACTION_MOV 處的程式碼可以改為:

         case MotionEvent.ACTION_MOVE:
                //手指移動過程中只顯示繪製過程
                //使用貝塞爾曲線進行繪圖,需要一個起點(preX,preY)
                //一個終點(x, y),一個控制點((preX + x)/2, (preY + y) / 2))
                int controlX = (x + preX) / 2;
                int controlY = (y + preY) / 2;
                path.quadTo(controlX, controlY, x, y);
                invalidate();
                preX = x;
                preY = y;
                break;

在螢幕上繪製矩形

繪製矩形的邏輯和曲線不一樣,手指按下時,記錄初始座標(firstX,firstY),手指移動過程中,不斷獲取新的座標(x,y),然後以(firstX,firstY)為左上角位置,(x,y)為右下角位置畫出矩形,矩形的 4 個屬性 left、top、right 和 bottom 的值分別為 firstX、firstY、x 和 y。我們首先實現沒有使用雙快取技術的效果。

public class Rect1View extends View {
    private int firstX, firstY;
    private Path path;
    private Paint paint;
    public Rect1View(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setStyle(Paint.Style.STROKE);
        paint.setColor(Color.RED);
        paint.setStrokeWidth(5);
        path = new Path();
    }
    @Override
    protected void onDraw(Canvas canvas) {

        super.onDraw(canvas);
        canvas.drawPath(path, paint);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                path.reset();
                firstX = x;
                firstY = y;
                break;
            case MotionEvent.ACTION_MOVE:
            //繪製矩形時,要先清除前一次的結果
                path.reset();
                path.addRect(firstX, firstY, x, y, Path.Direction.CCW);
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                invalidate();
                break;
            default:
                break;
        }
        return true;
    }
}

和前面的曲線一樣,並沒有顯示歷史繪圖,因為 invalidate 後繪圖歷史根本沒有儲存,Path物件中只儲存當前正在繪製的矩形資訊。要實現正確的效果,必須將每一次的繪圖都儲存在Bitmap 快取中,這樣,Bitmap 儲存繪圖歷史,Path 中儲存當前正在繪製的內容,即實現了功能,又照顧了使用者體驗。

public Rect3View(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setStyle(Paint.Style.STROKE);
        paint.setColor(Color.RED);
        paint.setStrokeWidth(5);
        path = new Path();
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bitmapBuffer, 0, 0, null);
        canvas.drawPath(path, paint);
    }
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if(bitmapBuffer == null){
            int width = getMeasuredWidth();
            int height = getMeasuredHeight();
            bitmapBuffer = Bitmap.createBitmap(width, height,
                    Bitmap.Config.ARGB_8888);
            bitmapCanvas = new Canvas(bitmapBuffer);
        }
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                firstX = x;
                firstY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //繪製矩形時,要先清除前一次的結果
                path.reset();
                path.addRect(firstX, firstY, x, y, Path.Direction.CCW);
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                bitmapCanvas.drawPath(path, paint);
                invalidate();
                break;
            default:
                break;
        }
        return true;
    }

不過,上面的實現並不完美,只支援↘方向的繪圖,另外三個方向↖、↙、↗就無能為力了。因此,我們需要在手指進行任意方向的移動時,重新計算矩形的 left、top、right 和 bottom 四個屬性值。

            case MotionEvent.ACTION_MOVE:
                //繪製矩形時,要先清除前一次的結果
                path.reset();
                if (firstX < x && firstY < y) {
                    //↘方向
                    path.addRect(firstX, firstY, x, y, Path.Direction.CCW);
                } else if (firstX > x && firstY > y) {
                    //↖方向
                    path.addRect(x, y, firstX, firstY, Path.Direction.CCW);
                } else if (firstX > x && firstY < y) {
                    //↙方向
                    path.addRect(x, firstY, firstX, y, Path.Direction.CCW);
                } else if (firstX < x && firstY > y) {
                    //↗方向
                    path.addRect(firstX, y, x, firstY, Path.Direction.CCW);
                }
                invalidate();
                break;

手指的移動方向不同,(firstX,firstY)和(x,y)代表的將是不同的角的座標,那麼,矩形的 left、top、right 和 bottom 四個屬性值也會發生變化,
這裡寫圖片描述

謝謝認真觀讀本文的每一位小夥伴,衷心歡迎小夥伴給我指出文中的錯誤,也歡迎小夥伴與我交流學習。
歡迎愛學習的小夥伴加群一起進步:230274309