使用 SurfaceView 写个画板

3,240 阅读8分钟

本文为原创文章,如需转载请注明出处,谢谢!

最近项目中添加了白板涂鸦的功能,需求是手指在屏幕上滑动需要绘制出光滑曲线,可切换颜色,选择笔宽,开关画笔,撤销笔画,清空画板。网上很多实现画板都是用的 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.涂鸦数据的通信

上面的介绍已经可以实现一个单机版的画板了,如果现在需要将涂鸦数据封装,然后通过网络发送给其他终端,应该如何处理呢?由于后台和前端通可以用很多方式实现,我只说一下大概的思路。

首先需要设计一个承载涂鸦数据的对象,对象的属性可能包括

  1. 画笔颜色 paintColor
  2. 画笔宽度 paintStrokeWidth
  3. 坐标集合 pointList
  4. 用户 Id userId

对象设计好后就可以进行通信了,这里说一下前端的做法,分为发送方和接收方。

  • 发送方:
    在 ACTION_DOWN 的时候创建传输对象,然后初始化画笔信息,然后在 ACTION_MOVE 的时候采集坐标,最后在 ACTION_UP 的时候添加一个回调,将对象传过去,之后就可以做网络请求了。

  • 接收方:
    假如数据传输格式为 json,将 json 解析为对象,然后通过 Path 连接对象中的坐标集合,设置画笔信息,然后展示在 SurfaceView 上即可

总结

本文没涉及原理的讲解,只是向大家阐述了我通过 SurfaceView 实现画板的核心思路,如果各位小伙伴想要更深入了解原理可以参考下面的文章哦!
「史上讲的最细的Path」www.jianshu.com/p/b872b064d…
「老罗对 SurfaceView 的详细分析」blog.csdn.net/luoshengyan…

如果文章中有说的不对的地方,请及时告诉我!因为我也是个初学者,望各位大神多多指点!

需要看源码的同学,可以到我的 github clone, 欢迎给位提 issue,如果能给个 star 更感激不尽!