使用 SurfaceView 写个画板

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

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

先来看看效果

DoodleSurfaceView.png

涉及知识

  • 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」http://www.jianshu.com/p/b872b064d369
「老罗对 SurfaceView 的详细分析」http://blog.csdn.net/luoshengyang/article/details/8661317

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容