本文为原创文章,如需转载请注明出处,谢谢!
最近项目中添加了白板涂鸦的功能,需求是手指在屏幕上滑动需要绘制出光滑曲线,可切换颜色,选择笔宽,开关画笔,撤销笔画,清空画板。网上很多实现画板都是用的 View ,我个人感觉 View 对 Canvas 的处理没有 SurfaceView 方便并且 SurfaceView 在频繁绘制的状况下性能优于 View ,所以选择了继承 SurfaceView 来实现画板功能。
先来看看效果
涉及知识
- View onTouchEvent 方法的使用
- SurfaceView 的基本使用
- Path 的基本使用
注:本人也只是个小白,本文只介绍我的想法(可能有些low)如果想了解 SurfaceView 的原理「双缓冲、绘图机制 balabala...」,去看看大神写的原理分析吧~
实现思路
1. 重写 onTouchEvent 方法
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
float x = event.getX();
float y = event.getY();
switch (action) {
case MotionEvent.ACTION_DOWN:
mPrevX = x;
mPrevY = y;
mPath = new Path();
mPath.moveTo(x, y);//将 Path 起始坐标设为手指按下屏幕的坐标
break;
case MotionEvent.ACTION_MOVE:
Canvas canvas = mSurfaceHolder.lockCanvas();
restorePreAction(canvas);//首先恢复之前绘制的内容
mPath.quadTo(mPrevX, mPrevY, (x + mPrevX) / 2, (y + mPrevY) / 2);
//绘制贝塞尔曲线,也就是光滑的曲线,如果此处使用 lineTo 方法滑出的曲线会有折角
mPrevX = x;
mPrevY = y;
canvas.drawPath(mPath, mPaint);
mSurfaceHolder.unlockCanvasAndPost(canvas);
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
这段代码中有一个方法 restorePreAction,这段代码之后会给出。用于恢复之前绘画的内容,canvas 每次都只能绘制一次内容并且不会帮我们保存,如果用 View 来实现画板也需要自己用 Bitmap 缓存之前绘制的内容,而使用 SurfaceView 简化了我们对 canvas 的处理。
接着我们来简单的说一下 mSurfaceHolder。首先 mSurfaceHolder 是在初始化时通过 getHolder() 方法获取实例,然后需要调用mSurfaceHolder.addCallback(this) 方法,给 SurfaceHolder 添加监听,具体的监听内容如下
@Override
public void surfaceCreated(SurfaceHolder holder) {
//在 SurfaceView 初始化的时候回调
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
//这个方法没用到,具体使用情况请同学自己再查一下吧,按方法名的意思应该是 Surface 发生改变时回调
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
//在 SurfaceView 销毁时调用,比如点击 home 键 app 进入后台时会调用这个方法
}
然后简单说一下 SurfaceView 双缓冲机制,说白了其实就是 SurfaceView 管理着两个画布,一个是 front 也就是摆在最前面被我们看到的画布,一个是 back 是后面作为缓冲的画布,我们新绘制的内容都会在 back 上,也就是通过 lockCanvas() 得到的画布,等绘制完毕后我们调用 unlockCanvasAndPost(canvas)方法,这时会把 back 画布变为 front,这样新画的内容就会显示在眼前,然后之前的 front 会变为 back,继续等待 lockCanvas 的调用。
2.优化 onTouchEvent 方法
现在考虑一个问题:「在 onTouchEvent 中,我们直接对 Path 进行操作,使得绘制的图形受到了拘束,如果以后需求扩展,要求可以画圆画方,那就需要直接修改代码,违背了面向对象的设计原则」那么应该如何解决呢?
解决方案其实就是抽象,无论画圆画方还是画线,其实都是在画图形,再深一步思考,onTouchEvent 中处理的实际是我们手指的动作,所以我们只需要用一个抽象动作去处理坐标就可以了,至于具体要画什么,怎么处理坐标就可以交给子类处理了。于是我抽象出了一个类 DoodleAction 用于处理坐标。代码如下
public abstract class DoodleAction {
protected int color;
protected float strokeWidth;
DoodleAction() {
}
public int getColor() {
return color;
}
public void setColor(int color) {
this.color = color;
}
public float getStrokeWidth() {
return strokeWidth;
}
public void setStrokeWidth(float strokeWidth) {
this.strokeWidth = strokeWidth;
}
@Override
public String toString() {
return "DoodleAction{" +
", color=" + color +
", strokeWidth=" + strokeWidth +
'}';
}
/**
* 绘制当前动作内容
*
* @param canvas 新画布
*/
public abstract void draw(Canvas canvas);
/**
* 根据手指移动坐标进行绘制
*
* @param x
* @param y
*/
public abstract void move(float x, float y);
}
此类中包含两个核心抽象方法:
- draw 方法:通过传过来的 canvas 绘制不同的图形
- move 方法:用于记录手指划过的坐标,并进行对应的处理
优化后的代码如下
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
float x = event.getX();
float y = event.getY();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (!mIsDoodleEnabled) return false; //如果当前设置不可绘制 直接 return false 不消费这次事件
mDownX = x;
mDownY = y;
setCurDoodleAction(x, y);
break;
case MotionEvent.ACTION_MOVE:
Canvas canvas = mSurfaceHolder.lockCanvas();
restorePreAction(canvas);//首先恢复之前绘制的内容
mCurAction.move(x, y);
mCurAction.draw(canvas); //绘制当前Action
mSurfaceHolder.unlockCanvasAndPost(canvas);
break;
case MotionEvent.ACTION_UP:
if (x == mDownX && y == mDownY) {
//目前 ACTION_DOWN --> ACTION_UP 不做任何处理,如想处理可加回调
} else {
//只有手指完成滑动动作 才会添加并发送动作
mDoodleActionList.add(mCurAction);//添加当前动作
}
mCurAction = null;//每次动作执行完毕应该将对象置为 null
break;
}
return true;
}
首先在 ACTION_DOWN 中执行 setCurDoodleAction 方法
/**
* 设置当前绘制动作类型
*
* @param startX 初始X坐标
* @param startY 初始Y坐标
*/
private void setCurDoodleAction(float startX, float startY) {
switch (mType) {
case Path:
mCurAction = new DoodlePath(startX, startY);
break;
case Oval:
//TODO 添加Oval
break;
}
mCurAction.setColor(mCurColor);
mCurAction.setStrokeWidth(mCurStrokeWidth);
}
这个方法中初始化了我们需要的动作,mType 是我定义的 enum 类型,同学们可自行扩展。
然后在 ACTION_MOVE 中执行 move draw 方法。这里我们使用抽象类型与 SurfaceView 进行交互,更利于维护和以后扩展功能。
最后在 ACTION_UP 中做了一个特殊处理,手指触摸屏幕一下立即抬起即 ACTION_DOWN --> ACTION_UP ,这个操作在真正使用时很容易误操作,具体原因不在此解释了,如果需要处理这个功能可以自己在这加个回调。最后 mDoodleActionList 是管理每次操作的 ArrayList,马上介绍。
DoodlePath 就是继承 DoodleAction 的类,代码比较简单,直接贴出来了
/**
* 自由曲线
*/
class DoodlePath extends DoodleAction {
private Path mPath;
private float mPrevX;
private float mPrevY;
private Paint mPaint;
DoodlePath() {
this(0, 0, 0, 10.0f);
}
DoodlePath(float startX, float startY) {
this(startX, startY, 0, 10.0f);
}
DoodlePath(float startX, float startY, int color, float strokeWidth) {
this.color = color;
this.strokeWidth = strokeWidth;
mPath = new Path();
mPath.moveTo(startX, startY);
mPrevX = startX;
mPrevY = startY;
initPaint();
}
private void initPaint() {
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mPaint.setColor(color);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(strokeWidth);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setStrokeJoin(Paint.Join.ROUND);
}
@Override
public void setColor(int color) {
super.setColor(color);
mPaint.setColor(color);
}
@Override
public void setStrokeWidth(float strokeWidth) {
super.setStrokeWidth(strokeWidth);
mPaint.setStrokeWidth(strokeWidth);
}
@Override
public void draw(Canvas canvas) {
if (canvas != null) {
canvas.drawPath(mPath, mPaint);
}
}
@Override
public void move(float x, float y) {
mPath.quadTo(mPrevX, mPrevY, (x + mPrevX) / 2, (y + mPrevY) / 2);
mPrevX = x;
mPrevY = y;
}
public void moveTo(float startX, float startY) {
mPath.moveTo(startX, startY);
mPrevX = startX;
mPrevY = startY;
}
}
3.管理 DoodleAction
上文代码中,我们每完成一次绘制,都会在 List 中添加一个对象,通过 List 进行管理 DoodleAction,之前一直没解释的 restorePreAction 方法就是通过遍历 List 把之前已有的动作全部再画一遍,代码如下。
/**
* 重新加载之前绘制的内容
*
* @param canvas 画布
*/
private void restorePreAction(Canvas canvas) {
if (canvas == null) {
return;
}
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); //加载之前内容前清空画布
if (mDoodleActionList != null && mDoodleActionList.size() > 0) {
for (DoodleAction action : mDoodleActionList) {
action.draw(canvas);
}
}
}
在遍历 List 之前需要清空画板,否则界面会重复绘制之前的内容。
此外,通过 List 我们可以容易的实现撤销和清空画板的需求,现在来看这两个方法:
public void undoAction() {
int size = mDoodleActionList == null? 0 : mDoodleActionList.size();
if (size > 0) {
mDoodleActionList.remove(size - 1);
Canvas canvas = mSurfaceHolder.lockCanvas();
restorePreAction(canvas);
mSurfaceHolder.unlockCanvasAndPost(canvas);
}
}
撤销很简单,只是将 List 中最后一个对象 remove,然后重新绘制内容即可。
清空更容易,直接清空 List,让后执行清空画板的操作就行,代码如下
public void cleanWhiteBoard() {
if (mDoodleActionList != null && mDoodleActionList.size() > 0) {
mDoodleActionList.clear();
Canvas canvas = mSurfaceHolder.lockCanvas();
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
mSurfaceHolder.unlockCanvasAndPost(canvas);
}
}
4.涂鸦数据的通信
上面的介绍已经可以实现一个单机版的画板了,如果现在需要将涂鸦数据封装,然后通过网络发送给其他终端,应该如何处理呢?由于后台和前端通可以用很多方式实现,我只说一下大概的思路。
首先需要设计一个承载涂鸦数据的对象,对象的属性可能包括
- 画笔颜色 paintColor
- 画笔宽度 paintStrokeWidth
- 坐标集合 pointList
- 用户 Id userId
对象设计好后就可以进行通信了,这里说一下前端的做法,分为发送方和接收方。
发送方:
在 ACTION_DOWN 的时候创建传输对象,然后初始化画笔信息,然后在 ACTION_MOVE 的时候采集坐标,最后在 ACTION_UP 的时候添加一个回调,将对象传过去,之后就可以做网络请求了。接收方:
假如数据传输格式为 json,将 json 解析为对象,然后通过 Path 连接对象中的坐标集合,设置画笔信息,然后展示在 SurfaceView 上即可
总结
本文没涉及原理的讲解,只是向大家阐述了我通过 SurfaceView 实现画板的核心思路,如果各位小伙伴想要更深入了解原理可以参考下面的文章哦!
「史上讲的最细的Path」http://www.jianshu.com/p/b872b064d369
「老罗对 SurfaceView 的详细分析」http://blog.csdn.net/luoshengyang/article/details/8661317
如果文章中有说的不对的地方,请及时告诉我!因为我也是个初学者,望各位大神多多指点!
需要看源码的同学,可以到我的 github clone, 欢迎给位提 issue,如果能给个 star 更感激不尽!