目录
一、概述
什么叫“双缓存”?说白了就是有两个绘图区,一个是 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 上。
二、在屏幕上绘制曲线
这是一个入门级的讨论,在屏幕上绘制曲线根本不会遇到什么问题,只要知道在屏幕上随手指绘制曲线的原理就行了。我们简要的分析一下。我们在屏幕上绘制的曲线,本质上是由无数条直线构成的,就算曲线比较平滑,看不到折线,也是由于构成曲线的直线足够短,我们用下面的示意图来说明这个问题:
当手指在屏幕上移动时,会产生三个动作:手指按下(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;
}
}
效果图:
可以看到每次只能画一条线,上一次画的内容会消失不见,这是因为我们没有采用"双缓存技术"来保存历史记录
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 对象保存了所有的绘图历史,这也是双缓存的作用之一。效果图如下:
2.3 使用Path优化-在屏幕上绘制曲线
上面的案例中,我们直接在 Bitmap 关联的 Canvas 上绘制直线,其实更好的做法是通过 Path来绘图,不管从功能上还是效率上这都是更优的选择,主要体现在:
- Path 可以用于保存实时绘图坐标,避免调用 invalidate()方法重绘时因 ViewRoot 的
scheduleTraversals()方法发送异步请求出现的问题; - Path 可以用来绘制复杂的图形;
- 使用 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;
}
效果图如下:
上面使用了 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;
}
效果图:
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;
效果图:
是不是感觉圆滑很多了.
三、在屏幕上绘制矩形
绘制矩形的逻辑和曲线不一样,手指按下时,记录初始坐标(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;
}
效果图如下:
可以看到和前面的曲线一样,并没有显示历史绘图,因为 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;
}
效果图如下:
不过,上面的实现并不完美,只支持↘方向的绘图,另外三个方向↖、↙、↗就无能为力了(大家可以感受一下)。因此,我们需要在手指进行任意方向的移动时,重新计算矩形的 left、top、right 和 bottom 四个属性值。
3.3 实现四个方向-在屏幕上绘制矩形
如下图所示手指的移动方向不同,(firstX,firstY)和(x,y)代表的将是不同的角的坐标,那么,矩形的 left、top、right 和 bottom 四个属性值也会发生变化
只需要在上一节的基础上修改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;
效果图: