android实现画板功能

96
wensefu
0.3 2017.03.24 03:16* 字数 1204

这两天闲来无事做了一个简易的画板程序,和大家分享一下。
效果图:

这是一个灰常简单的画板,不过麻雀虽小,五脏俱全:

  1. 支持撤销(undo);
  2. 支持反撤销(redo);
  3. 支持橡皮擦(eraser);
  4. 支持清除功能(clear);
  5. 支持保存为图像(save)。

github地址点这里,欢迎fork,star


关键代码

非常简短,只有200来行

/**
 * Created by wensefu on 17-3-21.
 */
public class PaletteView extends View {

    private Paint mPaint;
    private Path mPath;
    private float mLastX;
    private float mLastY;
    private Bitmap mBufferBitmap;
    private Canvas mBufferCanvas;

    private static final int MAX_CACHE_STEP = 20;

    private List<DrawingInfo> mDrawingList;
    private List<DrawingInfo> mRemovedList;

    private Xfermode mClearMode;
    private float mDrawSize;
    private float mEraserSize;

    private boolean mCanEraser;

    private Callback mCallback;

    public enum Mode {
        DRAW,
        ERASER
    }

    private Mode mMode = Mode.DRAW;

    public PaletteView(Context context) {
        this(context,null);
    }

    public PaletteView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setDrawingCacheEnabled(true);
        init();
    }

    public interface Callback {
        void onUndoRedoStatusChanged();
    }

    public void setCallback(Callback callback){
        mCallback = callback;
    }

    private void init() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setFilterBitmap(true);
        mPaint.setStrokeJoin(Paint.Join.ROUND);
        mPaint.setStrokeCap(Paint.Cap.ROUND);
        mDrawSize = 20;
        mEraserSize = 40;
        mPaint.setStrokeWidth(mDrawSize);
        mPaint.setColor(0XFF000000);

        mClearMode = new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
    }

    private void initBuffer(){
        mBufferBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        mBufferCanvas = new Canvas(mBufferBitmap);
    }

    private abstract static class DrawingInfo {
        Paint paint;
        abstract void draw(Canvas canvas);
    }

    private static class PathDrawingInfo extends DrawingInfo{

        Path path;

        @Override
        void draw(Canvas canvas) {
            canvas.drawPath(path, paint);
        }
    }

    public Mode getMode() {
        return mMode;
    }

    public void setMode(Mode mode) {
        if (mode != mMode) {
            mMode = mode;
            if (mMode == Mode.DRAW) {
                mPaint.setXfermode(null);
                mPaint.setStrokeWidth(mDrawSize);
            } else {
                mPaint.setXfermode(mClearMode);
                mPaint.setStrokeWidth(mEraserSize);
            }
        }
    }

    public void setEraserSize(float size) {
        mEraserSize = size;
    }

    public void setPenRawSize(float size) {
        mEraserSize = size;
    }

    public void setPenColor(int color) {
        mPaint.setColor(color);
    }

    public void setPenAlpha(int alpha) {
        mPaint.setAlpha(alpha);
    }

    private void reDraw(){
        if (mDrawingList != null) {
            mBufferBitmap.eraseColor(Color.TRANSPARENT);
            for (DrawingInfo drawingInfo : mDrawingList) {
                drawingInfo.draw(mBufferCanvas);
            }
            invalidate();
        }
    }

    public boolean canRedo() {
        return mRemovedList != null && mRemovedList.size() > 0;
    }

    public boolean canUndo(){
        return mDrawingList != null && mDrawingList.size() > 0;
    }

    public void redo() {
        int size = mRemovedList == null ? 0 : mRemovedList.size();
        if (size > 0) {
            DrawingInfo info = mRemovedList.remove(size - 1);
            mDrawingList.add(info);
            mCanEraser = true;
            reDraw();
            if (mCallback != null) {
                mCallback.onUndoRedoStatusChanged();
            }
        }
    }

    public void undo() {
        int size = mDrawingList == null ? 0 : mDrawingList.size();
        if (size > 0) {
            DrawingInfo info = mDrawingList.remove(size - 1);
            if (mRemovedList == null) {
                mRemovedList = new ArrayList<>(MAX_CACHE_STEP);
            }
            if (size == 1) {
                mCanEraser = false;
            }
            mRemovedList.add(info);
            reDraw();
            if (mCallback != null) {
                mCallback.onUndoRedoStatusChanged();
            }
        }
    }

    public void clear() {
        if (mBufferBitmap != null) {
            if (mDrawingList != null) {
                mDrawingList.clear();
            }
            if (mRemovedList != null) {
                mRemovedList.clear();
            }
            mCanEraser = false;
            mBufferBitmap.eraseColor(Color.TRANSPARENT);
            invalidate();
            if (mCallback != null) {
                mCallback.onUndoRedoStatusChanged();
            }
        }
    }

    public Bitmap buildBitmap() {
        Bitmap bm = getDrawingCache();
        Bitmap result = Bitmap.createBitmap(bm);
        destroyDrawingCache();
        return result;
    }

    private void saveDrawingPath(){
        if (mDrawingList == null) {
            mDrawingList = new ArrayList<>(MAX_CACHE_STEP);
        } else if (mDrawingList.size() == MAX_CACHE_STEP) {
            mDrawingList.remove(0);
        }
        Path cachePath = new Path(mPath);
        Paint cachePaint = new Paint(mPaint);
        PathDrawingInfo info = new PathDrawingInfo();
        info.path = cachePath;
        info.paint = cachePaint;
        mDrawingList.add(info);
        mCanEraser = true;
        if (mCallback != null) {
            mCallback.onUndoRedoStatusChanged();
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBufferBitmap != null) {
            canvas.drawBitmap(mBufferBitmap, 0, 0, null);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getAction() & MotionEvent.ACTION_MASK;
        final float x = event.getX();
        final float y = event.getY();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mLastX = x;
                mLastY = y;
                if (mPath == null) {
                    mPath = new Path();
                }
                mPath.moveTo(x,y);
                break;
            case MotionEvent.ACTION_MOVE:
                //这里终点设为两点的中心点的目的在于使绘制的曲线更平滑,如果终点直接设置为x,y,效果和lineto是一样的,实际是折线效果
                mPath.quadTo(mLastX, mLastY, (x + mLastX) / 2, (y + mLastY) / 2);
                if (mBufferBitmap == null) {
                    initBuffer();
                }
                if (mMode == Mode.ERASER && !mCanEraser) {
                    break;
                }
                mBufferCanvas.drawPath(mPath,mPaint);
                invalidate();
                mLastX = x;
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:
                if (mMode == Mode.DRAW || mCanEraser) {
                    saveDrawingPath();
                }
                mPath.reset();
                break;
        }
        return true;
    }
}

原理分析

总的来讲,思路其实很简单:

  • 接收到move事件后,在屏幕上画出相应的轨迹;
  • 撤销功能:在画轨迹时,记录每一步的轨迹和画笔属性,每次撤销时把最后一步删除,然后重绘;
  • 反撤销功能:撤销时把撤销的轨迹和画笔属性保存在另一个列表里,反撤销时从这个列表里取出来放到记录绘制信息的列表里,然后重绘;
  • 橡皮擦功能:这里主要应用到android的图象混合(Xfermode)知识,后面会对其进行讲解;
  • 清除功能:这个非常简单,清除屏上的像素记录即可;
  • 绘制:考虑到性能问题,这里使用了双缓冲绘图技术。

android的图象混合(Xfermode)

图象混合本质上用一句话解释就是:

按照某种算法将画布上你想要绘制的区域的每个像素的ARGB和你将要在这个区域绘制的ARGB进行组合变换。

举个例子,
我现在有个自定义View, 背景画成绿色的,
我再在上面绘制一个蓝色的圆,在不设置Xfermode(不进行图像混合)的情况下效果是这样的:

代码:

@Override
    protected void onDraw(Canvas canvas) {

        canvas.drawColor(Color.GREEN);

        canvas.drawCircle(getWidth() / 2, getHeight() / 2, 300, mPaint);
    }

现在我改一下代码,给Paint设置一个Xfermode,

 public MyView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        mPaint.setColor(Color.BLUE);
        mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
    }

    @Override
    protected void onDraw(Canvas canvas) {

        canvas.drawColor(Color.GREEN);

        canvas.drawCircle(getWidth() / 2, getHeight() / 2, 300, mPaint);
    }

效果是这样的:


为什么设置Xfermode后圆会变成黑色呢?代码里并没有设置Paint的颜色为黑色呀!

原因是,设置Xfermode为new PorterDuffXfermode(PorterDuff.Mode.CLEAR)后,按照这个混合模式的算法,canvas上圆绘制的区域的所有像素点ARGB全部被置0了,
因此实际上圆绘制区域是透明的,显示为黑色是因为activity的window背景色是黑色的。

Xfermode有三个子类,AvoidXfermode,PixelXorXfermode,PorterDuffXfermode,前两个在API 16上已经过时了,现在最常用的是PorterDuffXfermode,目前支持18种图像混合算法,分别产生不同的混合效果,做橡皮擦功能用到了CLEAR算法,其他算法的效果大家有兴趣可以参考google官方的介绍,api demo里也有相关的例子。
需要注意的是,Xfermode的某些算法不支持硬件加速,例如PorterDuffXfermode的DARKEN,LIGHTEN以及OVERLAY是不支持硬件加速的。具体参见android developer文档(需要梯子fk):hardware-accel.html#unsupported


android双缓冲绘图技术##

在理解android的双缓冲绘图概念之前,我们先想一想,何谓缓冲?
所谓缓冲,简单地说就是将多个将要执行的独立的任务集结起来,一起提交。
打个比方,现实生活中,你现在要将很多砖从A处搬到B处,原始的方法是徒手一次搬几块,这就是没有使用“缓存”的方法。你也可以用一辆拖车,先把砖搬到拖车上,再把拖车拉到B处,这就是使用了“缓存”的方法。

每个canvas都有对应的一个bitmap,绘图的过程实际上就是往这个bitmap上写入ARGB信息,然后把这些信息交给GPU进行显示。这里面其实已经包含了一次缓冲的过程。

所以,讲到这里,双缓冲的概念我想你已经明白了。没错,绘图时的双缓冲其实就是再增加一个canvas,把想要绘制的内容先绘制到这个增加的canvas对应的bitmap上,写完后再把这个bitmap的ARGB信息一次提交给上下文的canvas去绘制。双缓冲技术在绘制数据量较大时在性能上有明显的提升,画板程序之所以用到了双缓存,也是基于提高绘制效率的考虑。
关于双缓冲技术更详细的分析,可以参考我的另一篇博文:

android双缓冲绘图技术分析

转载请说明出处:http://www.jianshu.com/p/548d2799fd6e

android
Web note ad 1