Android绘图(三)双缓存技术

目录

一、概述

什么叫“双缓存”?说白了就是有两个绘图区,一个是 Bitmap 的 Canvas,另一个就是当前
View 的 Canvas。先将图形绘制在 Bitmap 上,然后再将 Bitmap 绘制在 View 上,也就是说,我们 在 View 上看到的效果其实就是 Bitmap 上的内容。这样做有什么意义呢?概括起来,有以下几
点:
1)高绘图性能

先将内容绘制在 Bitmap 上,再统一将内容绘制在 View 上,可以提高绘图的性能。

2)可以在屏幕上展示绘图的过程

将线条直接绘制在 View 上和先绘制在 Bitmap 上再绘制在 View 上是感受不到这个作用的,但是,如果是画一个矩形呢?情况就完全不一样了。我们用手指在屏幕上按下,斜拉,此时应该从按下的位置开始,拉出一个随手指变化大小的矩形。因为要向用户展示整个过程,所以需要不断绘制矩形,但是,对,但是,手指抬起后留下的其实只需要最后一个,所以,问题就在这里。怎么解决呢?使用双缓存。在 View 的onDraw()方法中绘制用于展示绘制过程的矩形,在手指移动的过程中,会不断刷新重绘,用户总能看到当前应有的大小的矩形,而且不会留下历史痕迹(因为重绘了,只重绘最后一次的)。

3)保存绘图历史

前面提到,因为直接在 View 的 Canvas 上绘图不会保存历史痕迹,所以也带来了副作用,以前绘制的内容也没有了(可能当前绘制的是第二个矩形),这个时候,双缓存的优势就体现出来了,我们可以将绘制的历史结果保存在一个 Bitmap 上,当手指松开时,将最后的矩形绘制在 Bitmap 上,同时再将 Bitmap 的内容整个绘制在 View 上。

二、在屏幕上绘制曲线

这是一个入门级的讨论,在屏幕上绘制曲线根本不会遇到什么问题,只要知道在屏幕上随手指绘制曲线的原理就行了。我们简要的分析一下。我们在屏幕上绘制的曲线,本质上是由无数条直线构成的,就算曲线比较平滑,看不到折线,也是由于构成曲线的直线足够短,我们用下面的示意图来说明这个问题:
Android绘图(三)双缓存技术
当手指在屏幕上移动时,会产生三个动作:手指按下(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 绘图,但如果要保存绘图历史,就要使用双缓存技术了。

2.1错误示例-在屏幕上绘制曲线

下面展示错误的代码

public class MyView extends View {
    public MyView(Context context) {
        this(context, null);
    }

    public MyView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private Paint paint;
    // 上一个点的坐标
    private int preX, preY;
    // 当前点的坐标
    private int currentX, currentY;

    private void init() {
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setColor(Color.WHITE);
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(5);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawColor(Color.BLACK);
        // 绘制直线
        canvas.drawLine(preX, preY, currentX, currentY, paint);
    }

    @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;
                this.invalidate();
                break;
            case MotionEvent.ACTION_UP:
                invalidate();
                break;
        }
        return true;
    }
}

效果图:
Android绘图(三)双缓存技术
可以看到每次只能画一条线,上一次画的内容会消失不见,这是因为我们没有采用"双缓存技术"来保存历史记录

2.2 使用“双缓存技术”-在屏幕上绘制曲线

代码调整如下:


private Paint paint;
// 上一个点的坐标
private int preX, preY;
// 当前点的坐标
private int currentX, currentY;

/**
 * Bitmap 缓存区
 */
private Bitmap bitmapBuffer;
private Canvas bitmapCanvas;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.WHITE);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeJoin(Paint.Join.ROUND);
    paint.setStrokeCap(Paint.Cap.ROUND);
    paint.setStrokeWidth(5);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    // 此方法会在onLayout之后回调,这样就可以确保拿到View的宽高了
    if (bitmapBuffer == null) {
        // 创建和View的宽高等同的bitmap
        bitmapBuffer = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        // 关联Canvas
        bitmapCanvas = new Canvas(bitmapBuffer);
    }
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    //将缓存中的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;
            // 将线条绘制到缓存bitmapBuffer中
            bitmapCanvas.drawLine(preX, preY, currentX, currentY, paint);
            // 刷新View
            this.invalidate();
            //当前点的坐标成为下一个点的起始坐标
            preX = currentX;
            preY = currentY;
            break;
        case MotionEvent.ACTION_UP:
            invalidate();
            break;
    }
    return true;
}

首先定义了一个名为 bitmapBuffer 的 Bitmap 对象,为了在该对象上绘图,创建了一个与之关联的Canvas 对象 bitmapCanvas。创建 Bitmap 对象时,需要考虑它的大小,在 MyView类的构造方法中,因为此时MyView 尚未创建,还不知道宽度和高度,所以,重写了 onSizeChanged()方法,该方法在组件创建后且大小发生改变时回调(View 第一次显示时肯定会调用),代码中看到,Bitmap 对象的宽度和高度与 View 相同。手指按下后,将第一次的坐标值保存在 preX 和 preY两个变量中,手指移动时,获取手指所在的新位置,并保存到 currentX 和 currentY 中,此时,已经知道了起点和终点两个点的坐标,将这两个点确定的一条直线绘制到 bitmapBuffer 对象,然后,立马又将 bitmapBuffer 对象绘制在 View 上,最后,重新设置 preX 和 preY 的值,确保(preX,preY)成为下一个点的起始点坐标。从下面的运行效果中看出,bitmapBuffer 对象保存了所有的绘图历史,这也是双缓存的作用之一。效果图如下:
Android绘图(三)双缓存技术

2.3 使用Path优化-在屏幕上绘制曲线

上面的案例中,我们直接在 Bitmap 关联的 Canvas 上绘制直线,其实更好的做法是通过 Path来绘图,不管从功能上还是效率上这都是更优的选择,主要体现在:

  1. Path 可以用于保存实时绘图坐标,避免调用 invalidate()方法重绘时因 ViewRoot 的
    scheduleTraversals()方法发送异步请求出现的问题;
  2. Path 可以用来绘制复杂的图形;
  3. 使用 Path 绘图效率更高。

上代码:


private Paint paint;
// 上一个点的坐标
private int preX, preY;
// 操作的路径
private Path path;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.WHITE);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeJoin(Paint.Join.ROUND);
    paint.setStrokeCap(Paint.Cap.ROUND);
    paint.setStrokeWidth(5);
    path = new Path();
}


@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 绘制路径
    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);
            // 刷新View
            this.invalidate();
            // 修改控制点
            preX = x;
            preY = y;
            break;
        case MotionEvent.ACTION_UP:
            break;
    }
    return true;
}

效果图如下:
Android绘图(三)双缓存技术

上面使用了 Path 来绘制曲线,Path 对象保存了手指从按下到移动到松开的整个运动轨迹,进行第二次绘制时,Path 调用 reset()方法重置,继续进行下一条曲线的绘图。通过调用 quadTo()方法绘制二阶贝塞尔曲线,因为需要指定一个起始点,所以手指按下时调用了 moveTo(x,y)方法。但是,运行后我们发现,绘制当前曲线没有问题,但绘制下一条曲线的时候前一条曲线消失了(这是因为每次down的时候path都reset了),如果要保存绘图历史,这需要通过“双缓存”技术来解决。

2.4 使用Path优化+“双缓存技术”-在屏幕上绘制曲线

直接上代码:


private Paint paint;
// 上一个点的坐标
private int preX, preY;
// 操作的路径
private Path path;

/**
 * Bitmap 缓存区
 */
private Bitmap bitmapBuffer;
private Canvas bitmapCanvas;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.WHITE);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeJoin(Paint.Join.ROUND);
    paint.setStrokeCap(Paint.Cap.ROUND);
    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) {
        bitmapBuffer = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        bitmapCanvas = new Canvas(bitmapBuffer);
    }
}

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 绘制历史路径
    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);
            // 刷新View
            this.invalidate();
            // 修改控制点
            preX = x;
            preY = y;
            break;
        case MotionEvent.ACTION_UP:
            // 手指松开后将最终的path绘图结果绘制在 bitmapBuffer中,因为path在移动的过程中会不断的记录
            bitmapCanvas.drawPath(path,paint);
            invalidate();
            break;
    }
    return true;
}

效果图:
Android绘图(三)双缓存技术

2.5 优化path的控制点-在屏幕上绘制曲线(终极方案)

我们在画曲线时,使用了 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;

效果图:
Android绘图(三)双缓存技术

是不是感觉圆滑很多了.

三、在屏幕上绘制矩形

绘制矩形的逻辑和曲线不一样,手指按下时,记录初始坐标(firstX,firstY),手指移动过程中,不断获取新的坐标(x,y),然后以(firstX,firstY)为左上角位置,(x,y)为右下角位置画出矩形,矩形的 4 个属性 left、top、right 和 bottom 的值分别为 firstX、firstY、x 和 y。我们首先实现没有使用双缓存技术的效果。

3.1 错误示例-在屏幕上绘制矩形

private Paint paint;
// 上一个点的坐标
private int firstX, firstY;
// 操作的路径
private Path path;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.WHITE);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeJoin(Paint.Join.ROUND);
    paint.setStrokeCap(Paint.Cap.ROUND);
    paint.setStrokeWidth(5);
    path = new Path();
}


@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 绘制当前路径
    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(new RectF(firstX, firstY, x, y), Path.Direction.CCW);
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            invalidate();
            break;
    }
    return true;
}

效果图如下:
Android绘图(三)双缓存技术
可以看到和前面的曲线一样,并没有显示历史绘图,因为 invalidate 后绘图历史根本没有保存,Path对象中只保存当前正在绘制的矩形信息。要实现正确的效果,必须将每一次的绘图都保存在Bitmap 缓存中,这样,Bitmap 保存绘图历史,Path 中保存当前正在绘制的内容,即实现了功能,又照顾了用户体验。

3.2 使用“双缓冲技术”-在屏幕上绘制矩形

上代码:


private Paint paint;
// 上一个点的坐标
private int firstX, firstY;
// 操作的路径
private Path path;
/**
 * Bitmap 缓存区
 */
private Bitmap bitmapBuffer;
private Canvas bitmapCanvas;


private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(Color.WHITE);
    paint.setStyle(Paint.Style.STROKE);
    paint.setStrokeJoin(Paint.Join.ROUND);
    paint.setStrokeCap(Paint.Cap.ROUND);
    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) {
        bitmapBuffer = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        bitmapCanvas = new Canvas(bitmapBuffer);
    }
}


@Override
protected void onDraw(Canvas canvas) {
    canvas.drawColor(Color.BLACK);
    // 绘制历史路径
    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();
            firstX = x;
            firstY = y;
            break;
        case MotionEvent.ACTION_MOVE:
            //绘制矩形时,要先清除前一次的结果
            path.reset();
            path.addRect(new RectF(firstX, firstY, x, y), Path.Direction.CCW);
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            // 手指松开后将最终的path绘图结果绘制在 bitmapBuffer中,因为path在移动的过程中会不断的记录
            bitmapCanvas.drawPath(path, paint);
            invalidate();
            break;
    }
    return true;
}

效果图如下:
Android绘图(三)双缓存技术
不过,上面的实现并不完美,只支持↘方向的绘图,另外三个方向↖、↙、↗就无能为力了(大家可以感受一下)。因此,我们需要在手指进行任意方向的移动时,重新计算矩形的 left、top、right 和 bottom 四个属性值。

3.3 实现四个方向-在屏幕上绘制矩形

如下图所示手指的移动方向不同,(firstX,firstY)和(x,y)代表的将是不同的角的坐标,那么,矩形的 left、top、right 和 bottom 四个属性值也会发生变化
Android绘图(三)双缓存技术
只需要在上一节的基础上修改onTouchEvent的case MotionEvent.ACTION_MOVE语句如下即可:

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;

效果图:
Android绘图(三)双缓存技术

上一篇:设计作品使用的工具


下一篇:Android技术分享| 【自习室】自定义View代替通知动画(2)