双缓存
为什么叫“双缓存”?说白了就是有两个绘图区,一个是 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