使用 SurfaceView 写个画板

作者: Android_ZzT | 来源:发表于2017-03-26 12:15 被阅读523次

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

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

    相关文章

      网友评论

        本文标题:使用 SurfaceView 写个画板

        本文链接:https://www.haomeiwen.com/subject/bxxkottx.html