Android开发Android知识Android开发经验谈

使用 SurfaceView 写个画板

2017-03-26  本文已影响523人  Android_ZzT

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

最近项目中添加了白板涂鸦的功能,需求是手指在屏幕上滑动需要绘制出光滑曲线,可切换颜色,选择笔宽,开关画笔,撤销笔画,清空画板。网上很多实现画板都是用的 View ,我个人感觉 View 对 Canvas 的处理没有 SurfaceView 方便并且 SurfaceView 在频繁绘制的状况下性能优于 View ,所以选择了继承 SurfaceView 来实现画板功能。

先来看看效果

DoodleSurfaceView.png

涉及知识

注:本人也只是个小白,本文只介绍我的想法(可能有些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);

}

此类中包含两个核心抽象方法:

优化后的代码如下

@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

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

总结

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

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

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

上一篇下一篇

猜你喜欢

热点阅读