Android 自定义 View 实现实时白板绘制及多端绘制同步

 · 34907 word(s) · 40 mins read

first_show

本篇主要介绍最近实现的一个多端白板绘制的功能,适用于多终端实时绘制路径,并能互相同步数据,实现一个多端同步白板。主要涉及到自定义 View,数据传输的封装,异步数据处理,底层数据同步等。从需求分析到最终完成产品。

目标

首先让我们来理一下这个需求的主要功能点,也就是这个需求需要做些什么内容:

  • 可以实时绘制的白板控件
  • 修改画笔粗细,画笔颜色,以及橡皮擦,清屏等
  • 撤销与反撤销
  • 多屏绘制以及炒作记录
  • 白板绘制区域的缩放及平移
  • 本地绘制以及操作实时同步到当前频道的其他白板
  • 语言通话
  • ……

分析

通过对需求的整理,粗略分析一下功能点,然后再就各个点展开详细分析,发现我们这个功能有一下几个难点:

  1. 白板绘制控件
  2. 数据及指令封装
  3. 底层数据传输
  4. 效率与性能

1. 白板绘制

白板绘制即须有提供一个操作友好的可绘制的画布控件,可以在画布上进行线条绘制、橡皮擦涂改、绘制撤销等功能。这种功能可以通过自定义 View 来实现,也较为简单。除了自定义 View 来实现外,还有一个方案,那就是自定义 SurfaceView,其实自定义 SurfaceView 也属于自定义 View 的一种,只不过 SurfaceView 会使用双缓冲机制,可以在异步线程进行 UI 绘制,可以使绘制更流畅,两种实现还是有很大的区别的。后面会详细介绍两种实现方式。

2. 数据及指令封装

考虑到需要进行实时同步多端的绘制与操作,那就避免不了要对数据结构的确定。保证不同的端在发送数据与接收数据使用的相同的数据协议,这样才能准确地还原发送端的指令,这里的指令包含划线、缩放、移动、撤销、反撤销和清屏等。所以必须先确定好有哪些指令和数据需要传递,要如何封装这些数据和指令。几个需要考虑的问题即可选方案如下:

  1. 用什么数据结构?

    自定义数据结构、xml、json、protobuf?

  2. 需要哪些字段?

    指令类型、指令内容、指令时间?

  3. 线条路径如何表示?

    一连串点的坐标?起始点结束点的路径?

  4. 用什么库来编码与解码数据包?

    xml:DOM、SAX、PULL json:Gson、FastJson、Jackson protobuf:protobuf

以上这些可选方案基本都可以完成功能,但是最优选择还是需要仔细考虑的。后面会详细给需要考虑的问题点、选择依据。

3. 底层数据传输

假设我们数据包已经封装好了,现在本地每产生一个数据包,就要发送这个数据包到当前频道的其他用户,以通知对方最新的消息。那么接下来这个数据的传输需要考虑到哪些问题呢?

  1. 考虑到实时传输数据,可以使用 socket 长连接,通过服务器做一个中转。
  2. 对于线条绘制可以使用 tcp 或 udp,因为绘制对于可靠性要求并不是特别高(需要考虑特点场景)
  3. 对于其他一些指令,则要求可靠性高,如清屏,移动,缩放,丢包会有很大的影响
  4. 对于可靠性要求严格的还需要在业务层上确保可靠性,如添加 ACK 机制
  5. 处理多个频道(channel)消息互不干扰
  6. 处理断线重连、消息发送状态通知等
  7. 是否可以使用第三方成熟解决方案?ok!

通过以上一番分析,发现可以通过一些成熟第三方长连接 sdk 来完成这个最复杂的任务。可选的方案也很多,比如网易云信Agora 声网。目前我选的是声网,当然你也可以视情况选择其他解决方案。接下来我们只需要处理上层逻辑和业务即可,底层数据传输我们全部交给声网来处理。

4. 效率与性能

因为我们是需要实时绘制和数据传输,数据传输之前还要进行装包,接收端收到数据包之后还需要解包,再根据数据包中指令进行处理对应的操作。所以我们需要注意的是在效率和性能上如何做到最优,最大程度避免使用过程中出现明显的卡顿,发热等现象。

那么我们有哪些地方需要研究一下呢?

  • 数据封装尽量最简,死磕基本数据类型,能用 byte 不用 int
  • 数据结构使用最优,建议使用 protobuf,几种数据封装对比
  • 数据传输尽量进行压缩,如 Gzip,效果会很明显
  • UI 绘制使用 SurfaceView 异步线程绘制,甚至只当有绘制数据修改时再去重绘
  • 缓存本地数据包,达到一定量,或者按一定时间间隔(如有数据)再统一发送数据包,避免过于频繁发送数据包

开始

数据封装

  1. 首先将指令类型确定下来,指令类型即整个模块可以交互的操作
 public interface ActionStep {
    byte START = 1;//画笔路径开始
    byte MOVE = 2;//画笔路径移动
    byte END = 3;//单条路径绘制结束
    byte REVOKE = 4;//撤销
    byte REDO = 5;//反撤销
    byte CLEAR_SELF = 6;//清屏
    byte CLEAR_ACK = 7;//清屏指令确定
    byte TRANSLATE = 8;//画布平移
    byte SCALE = 9;//画布缩放
    byte PREVIOUS = 10;//上一页画布
    byte NEXT = 11;//下一页画布
}

各个指令的作用注释里面已经讲的很清楚了,不再赘述。

  1. 封装最小数据包结构
public class Transaction implements Serializable, Cloneable{

    private long timestamp = 0;//数据时间戳
    private byte step = ActionStep.START;
    private float x = 0.0f;
    private float y = 0.0f;
    private byte color = 0;//0,1,2:三种颜色
    private int size = 5;//画笔大小

    public Transaction() {
    }

    public Transaction(long timestamp, byte step, float x, float y) {
        this.timestamp = timestamp;
        this.step = step;
        this.x = x;
        this.y = y;
    }

    public Transaction(long timestamp, byte step, float x, float y, byte size, byte color {
        this(timestamp, step, x, y);
        this.size = size;
        this.color = color;
    }
    
    private void make(byte step, float x, float y) {
        this.timestamp = System.currentTimeMillis();
        this.step = step;
        this.x = x;
        this.y = y;
    }

    public Transaction makeStartTransaction(float x, float y, int size, byte color) {
        make(ActionStep.START, x, y);
        this.size = size;
        this.color = color;
        return this;
    }

    public Transaction makeMoveTransaction(float x, float y, int size, byte color) {
        make(ActionStep.MOVE, x, y);
        this.size = size;
        this.color = color;
        return this;
    }

    public Transaction makeEndTransaction(float x, float y, int size, byte color) {
        make(ActionStep.END, x, y);
        this.size = size;
        this.color = color;
        return this;
    }

    public Transaction makeRevokeTransaction() {
        make(ActionStep.REVOKE, 0.0f, 0.0f);
        return this;
    }

    public Transaction makeRedoTransaction() {
        make(ActionStep.REDO, 0.0f, 0.0f);
        return this;
    }

    public Transaction makeClearSelfTransaction() {
        make(ActionStep.CLEAR_SELF, 0.0f, 0.0f);
        return this;
    }

    public Transaction makeClearAckTransaction() {
        make(ActionStep.CLEAR_ACK, 0.0f, 0.0f);
        return this;
    }

    public Transaction makeTranslateTransaction(float tx, float ty) {
        make(ActionStep.TRANSLATE, tx, ty);
        return this;
    }

    public Transaction makeScaleTransaction(float sx, float sy) {
        make(ActionStep.SCALE, sx, sy);
        return this;
    }

    public Transaction makePreviousTransaction(int index) {
        make(ActionStep.SCALE, 0.f, 0.f);
        return this;
    }

    public Transaction makeNextTransaction(int index) {
        make(ActionStep.SCALE, 0.f, 0.f);
        return this;
    }

    @JSONField(serialize = false)
    public boolean isPaint() {
        return !isRevoke() && !isClearSelf() && !isClearAck();
    }

    @JSONField(serialize = false)
    public boolean isRevoke() {
        return step == ActionStep.REVOKE;
    }

    @JSONField(serialize = false)
    public boolean isRedo() {
        return step == ActionStep.REDO;
    }

    @JSONField(serialize = false)
    public boolean isNext() {
        return step == ActionStep.NEXT;
    }

    @JSONField(serialize = false)
    public boolean isPrevious() {
        return step == ActionStep.PREVIOUS;
    }

    @JSONField(serialize = false)
    public boolean isClearSelf() {
        return step == ActionStep.CLEAR_SELF;
    }

    @JSONField(serialize = false)
    public boolean isClearAck() {
        return step == ActionStep.CLEAR_ACK;
    }
    
    @Override
    public Transaction clone() {
        Transaction transaction = null;
        try {
            transaction = (Transaction) super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
            transaction = new Transaction();
        }
        return transaction;
    }
    
    public interface ActionStep {
        byte START = 1;
        byte MOVE = 2;
        byte END = 3;
        byte REVOKE = 4;
        byte REDO = 5;
        byte CLEAR_SELF = 6;
        byte CLEAR_ACK = 7;
        byte TRANSLATE = 8;
        byte SCALE = 9;
        byte PREVIOUS = 10;
        byte NEXT = 11;
    }
}

PS:部分代码已省略

可以看到指令类型ActionStep作为数据包结构内部类的形式存在,也能体现出软件设计中的单一职责原则。需要注意的几个点:

  • 时间戳timestamp主要用来记录时间发生的时间,别小瞧这个字段,作用很大,如果整个绘制过程需要录制下来,日后回放整个过程,那就离不开这个时间戳,将每个指令发生时间记录下来,回放的时候根据时间播放对应的指令即可。
  • color用的是byte存储,正常的颜色如白色0xffffffff是 32 位,byte只能存下 16 位,肯定存不下,故此处我们约定大于传输,先约定好颜色的对应关系,如 0 对应白色。
  • makeXXXTransaction系列方法是封装单个Transaction对象,详情后面会介绍。因为每次创建该对象都要重新赋值各字段,所以这里重写了clone方法,避免了每次new Transaction()带来的性能损失。newclone做了个简单对比,clone 更优。单位纳秒,如下:
new_cost:880220
Transaction{timestamp=11, step=1, x=3.0, y=4.0, color=0, size=5}
clone_cost2:33550
Transaction{timestamp=11, step=1, x=3.0, y=4.0, color=0, size=5}
  • 目前使用的是json来包装数据包,使用fastjson来处理解析工作,为了避免fastjson将一些方法识别成字段,打包进json中去,在不需要传输的方法上添加注解:@JSONField(serialize = false)告诉fastjson该方法不做解析。如若不加则在json文本中会出现isPaint等字段,这些是不需要传输给远端的。

白板绘制

白板绘制是一个重点,也是一个主要的功能,是直接呈现给用户看的,不管底层如何实现,要确保用户在使用过程中不会出现卡顿不流畅的现象。包括 UI 绘制,图形封装,逻辑处理等步骤。

DoodleView 白板

我们的白板控件叫 DoodleView,白板是一个自定义 View,承载着路径绘制,画布缩放与平移,最重要的一点是手势的识别与路径绘制。以下有两种方案,笔者采取的是第二种方案。

1、自定义 View

直接继承 View 类,然后重写onDraw()方法,处理各种手势,再针对不同手势处理不同事件,为了降低开发难度可以使用系统提供的GestureDetectorScaleGestureDetector来简化手势处理。

在需要路径重新绘制时候,调用invalidate()postInvalidate()来重新绘制界面。

方案总结,此种方案较为简单,不操作白板时不需要重新绘制界面,但是当快速操作白板,比如快速画线时,甚至是当前频道有多个用户同时绘制,那么久需要频繁重绘 UI,而且这些重绘工作发生在主线程,也就是 UI 线程,很容易导致卡顿现象。

2、自定义 SurfaceView

直接继承 SurfaceView 类,或者 TextureView 类,重写surfaceCreatedsurfaceChangedsurfaceDestroyedonTouchEvent等方法,在surfaceCreated方法中开启一个子线程,每个时间间隔之后重绘白板,这样做的好处有2点:

  1. 在子线程绘制,不影响主线程的用户交互,不易出现 ANR。
  2. 定时重绘 UI,不用关心在什么地方要显示地调用invalidate()来更新 UI,只需要将所有绘制数据准备好即可。

重写onTouchEvent方法,将事件分发给GestureDetectorScaleGestureDetector来处理。然后再将当前指令(指令包括划线,撤销反撤销,橡皮擦,清屏,翻页等操作)保存在一个通道(channel)中,等待下一个时间片到来时,会将通道内可见路径重新绘制一次,即可在白板呈现出刚画的线条了。此处通道是抽取出来绘制路径的封装,方便管理路径,它有点想一个状态机,尤其是在处理撤销与反撤销的指令时尤为重要。

最后将路径数据交由TransactionManager来处理,将数据打包,找到合适时机发送至频道。TransactionManager也是一个较为重要的模块,主要负责数据的装包和解包,以及寻找合适的时机将待发送的数据包发送出去。

详细实现

  1. 统一的操作接口

     /**
      * Author   :hymane
      * Email    :[email protected]
      * Create at 2018/7/20
      * Description: 白板操作统一接口
      */
     public interface IDoodleCallback {
         void undo();
         boolean canUndo();
         void redo();
         boolean canRedo();
         void clear();
         void clean();
         void previous();
         boolean canPrevious();
         void go(int index);
         void next();
         boolean canNext();
         void translate(float dx, float dy);
         void scale(float sx, float sy, float focusX, float focusY);
         void setPaintType(ActionType type);
         void setPaintSize(int size);
         void setPaintColor(@ColorInt int color);
         void end();
     }
    
  2. 白板 DoodleView 控件

     /**
      * Author   :hymane
      * Email    :[email protected]
      * Create at 2018/7/20
      * Description:
      */
     public class DoodleView extends SurfaceView implements SurfaceHolder.Callback, IDoodleCallback, Runnable, TransactionObserver, LifecycleObserver {
         private static final String TAG = DoodleView.class.getSimpleName();
        
         /******************************常量*************************************/
         private static final float MAX_SCALE = 6f;
         private static final float MIN_SCALE = 0.1f;
         private static final int DEFAULT_BACKGROUND_COLOR = Color.WHITE;
        
         /******************************参数*************************************/
        
         private long mDrawDelayTime = 0;
         private int mCanvasBackgroundColor = DEFAULT_BACKGROUND_COLOR;
            
         private long mCurrentDrawDelayTime = mDrawDelayTime;
         protected Handler mPhotoHandler = new Handler();
         protected Easing mEasing = new Cubic();
        
         //SurfaceHolder
         private SurfaceHolder mSurfaceHolder;
         private IDoodleCallback mDoodleCallback;
         private DoodleChannel mDoodleSelfChannel;//本地绘制通道
         private DoodleChannel mDoodleRemoteChannel;//远程回放通道
           // 数据发送管理器
         private TransactionManager mTransactionManager;
        
         //画笔
         private Paint mClearPaint;
        
         //全局画板手势数据
         private float mTranslateX = 0.0f;//x位移
         private float mTranslateY = 0.0f;//y位移
         private float mCurrentScale = 1.0f;//当前scale
        
         //手势事件
         private ScaleGestureDetector mScaleDetector;
         private GestureDetector mGestureDetector;
         private GestureDetector.OnGestureListener mGestureListener;
         private ScaleGestureDetector.OnScaleGestureListener mScaleListener;
         //是否可滑动
         private boolean mScrollEnabled = true;
         //是否支持双击操作
         private boolean mDoubleTapEnabled = true;
         //是否支持缩放
         private boolean mScaleEnabled = true;
         private int mDoubleTapDirection;
         private boolean onMoving;
         private boolean shouldFling;
         private boolean mIsDrawing = false;
         private byte colorIndex = 0;
         public DoodleView(Context context) {
             this(context, null);
         }
        
         public DoodleView(Context context, AttributeSet attrs) {
             this(context, attrs, 0);
         }
        
         public DoodleView(Context context, AttributeSet attrs, int defStyleAttr) {
             super(context, attrs, defStyleAttr);
             init();
         }
        
         /***
          * 初始化view
          */
         private void init() {
             mSurfaceHolder = this.getHolder();
             mSurfaceHolder.addCallback(this);
             setZOrderOnTop(true);
             setZOrderMediaOverlay(true);
             //设置画布  背景透明
             mSurfaceHolder.setFormat(PixelFormat.TRANSLUCENT);
             //设置一些参数方便后面绘图
             this.setFocusable(true);
             this.setKeepScreenOn(true);
             this.setFocusableInTouchMode(true);
             //其他
             mClearPaint = new Paint();
             mClearPaint.setColor(Color.WHITE);
        
             //手势
             mGestureListener = new GestureListener();
             mScaleListener = new ScaleListener();
             mScaleDetector = new ScaleGestureDetector(getContext(), mScaleListener);
             mGestureDetector = new GestureDetector(getContext(), mGestureListener, null, true);
         }
        
         /***
          * 1. 初始化白板,数据包发送器
          */
         public void initDoodle(LifecycleOwner owner, String channelId) {
             owner.getLifecycle().addObserver(this);
             this.mTransactionManager = new TransactionManager(channelId, getContext());
             this.mTransactionManager.registerTransactionObserver(this);
         }
        
         /***
          * 2. 初始化channel,本地绘制通道
          */
         public void initChannel(DoodleChannel selfChannel, DoodleChannel remoteChannel) {
             if (selfChannel == null) {
                 throw (new NullPointerException("doodleChannel can not be null."));
             }
             this.mDoodleSelfChannel = selfChannel;
             this.mTranslateX = selfChannel.getTranslateX();
             this.mTranslateY = selfChannel.getTranslateY();
             this.mCurrentScale = selfChannel.getCurrentScale();
             this.mDoodleRemoteChannel = remoteChannel == null ? new DoodleChannel() : remoteChannel;
         }
        
         @Override
         public void surfaceCreated(SurfaceHolder surfaceHolder) {
             mIsDrawing = true;
             new Thread(this).start();
         }
        
         @Override
         public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height) {
         }
        
         @Override
         public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
             synchronized (this) {
                 mIsDrawing = false;
             }
         }
        
         @Override
         public boolean onTouchEvent(MotionEvent event) {
             if (!isEnabled()) {
                 return false;
             }
             mScaleDetector.onTouchEvent(event);
        
             if (!mScaleDetector.isInProgress()) {
                 mGestureDetector.onTouchEvent(event);
             }
        
             int action = event.getAction();
             switch (action & MotionEvent.ACTION_MASK) {
                 case MotionEvent.ACTION_DOWN:
                     break;
                 case MotionEvent.ACTION_UP:
                     return onUp(event);
                 case MotionEvent.ACTION_POINTER_UP:
                     break;
                 case MotionEvent.ACTION_POINTER_DOWN:
                     break;
        
             }
             return true;
         }
        
         /**
          * IDoodleCallback
          * 退出涂鸦板时调用
          * 清理数据
          */
         @Override
         public void end() {
             if (mTransactionManager != null) {
                 mTransactionManager.end();
             }
         }
        
         private float getReviseX(float input) {
             return input / mCurrentScale - mTranslateX;
         }
        
         private float getReviseY(float input) {
             return (input / mCurrentScale - mTranslateY);
         }
        
         /********************************手势确定***********************************/
         protected boolean onDown(MotionEvent e) {
             //...
             return true;
         }
        
         protected boolean onUp(MotionEvent e) {
             //...
             return true;
         }
        
         protected boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
             //...
             return true;
         }
        
         protected boolean onTranslate(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
             //...
             return true;
         }
        
         protected boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
             //...
             return false;
         }
        
         protected boolean onSingleTapUp(MotionEvent e) {
             log("onSingleTapUp");
             return true;
         }
        
         protected boolean onSingleTapConfirmed(MotionEvent e) {
             //...
             return true;
         }
        
         protected boolean onScale(float sx, float sy, float focusX, float focusY) {
             //...
             return true;
         }
        
         private void log(String msg) {
             Log.d(TAG, "log: " + msg);
         }
        
         /***
          * 子线程绘制
          */
         @Override
         public void run() {
             while (mIsDrawing) {
                 synchronized (this) {
                     reDraw();
                 }
                 if (mCurrentDrawDelayTime != 0) {
                     //本处使用的是定时(不准确的定时)刷新,可改成按需刷新,
                     //也就是需要刷新页面的时候再刷新
                     try {
                         Thread.sleep(mCurrentDrawDelayTime);
                     } catch (InterruptedException e) {
                         e.printStackTrace();
                     }
                 }
             }
         }
        
         /***
          * 重绘
          */
         protected void reDraw() {
             Canvas canvas = mSurfaceHolder.lockCanvas();
             if (canvas != null) {
                 canvas.save();
                 canvas.drawColor(mCanvasBackgroundColor);
                 canvas.scale(mCurrentScale, mCurrentScale);
                 canvas.translate(mTranslateX, mTranslateY);
                 //绘制历史action
                 for (int i = 0; i <= mDoodleSelfChannel.index(); i++) {
                     if (i >= mDoodleSelfChannel.actions.size()) {
                         break;
                     }
                     mDoodleSelfChannel.actions.get(i).onDraw(canvas);
                 }
                 //绘制当前action
                 if (mDoodleSelfChannel.action != null) {
                     mDoodleSelfChannel.action.onDraw(canvas);
                 }
        
                 for (int i = 0; i <= mDoodleRemoteChannel.index(); i++) {
                     if (i >= mDoodleRemoteChannel.actions.size()) {
                         break;
                     }
                     mDoodleRemoteChannel.actions.get(i).onDraw(canvas);
                 }
        
                 if (mDoodleRemoteChannel.action != null) {
                     mDoodleRemoteChannel.action.onDraw(canvas);
                 }
                 canvas.restore();
                 //绘制当前action
                 mSurfaceHolder.unlockCanvasAndPost(canvas);
             }
         }
        
         /***
          * 数据包事务处理
          * 已将接收到的远程数据解压并整理成事务单元
          * 将事务单元转至可绘制action
          * @param transactions
          */
         @Override
         public synchronized void onTransaction(List<Transaction> transactions) {
             //...-->onMultiTransactionsDraw
         }
        
         private void onMultiTransactionsDraw(List<Transaction> transactions) {
             //...
         }
        
         /********************************生命周期***********************************/
        
         /********************* Lifecycle ***************************/
         @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
         public void onResume() {
             log("Lifecycle:onResume");
             mCurrentDrawDelayTime = mDrawDelayTime;
         }
        
         @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
         public void onPause() {
             log("Lifecycle:onPause");
             mCurrentDrawDelayTime = Integer.MAX_VALUE;
         }
        
         @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
         public void onDestroy() {
             log("Lifecycle:onDestroy");
             mIsDrawing = false;
             end();
         }
        
         /********************************手势处理***********************************/
         /***
          * GestureListener
          * 委托的手势监听
          */
         private class GestureListener extends GestureDetector.SimpleOnGestureListener {
             //...
         }
         /***
          * 缩放
          */
         private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
             //...
         }
        
         //移动
         public void scrollBy(float distanceX, float distanceY, final double durationMs) {
             //...
         }
     }
    

代码有点多,这里就没有全部贴出来,只贴了主要的代码。主要实现前面也已经介绍过,这里有一个mDrawDelayTime来控制刷新 UI 的时间间隔,可以调整绘制频率,建议 frame/30ms。

  • DoodleChannel 为绘制通道保存绘制的记录,此处有两个通道,一个是远程绘制记录,一个是本地记录,将其区分开,然后依次重回这两个通道,但是要考虑一个问题,就是两个通道的覆盖绘制问题。
  • 周期性的延迟绘制使用的是 Handler 来处理延迟消息
  • 手势事件交给了ScaleGestureDetectorGestureDetector来处理,并重写OnGestureListenerOnScaleGestureListener监听,来处理具体逻辑。
  • SurfaceView 在处理背景时会有一些坑,比如闪屏和黑屏等问题,在初始化的时候上做以下设置,可以避免 SurfaceView 黑屏,以及挡住上层 UI 组件的问题。
    setZOrderOnTop(true);
    setZOrderMediaOverlay(true);
    
  • TransactionManager 是数据包管理器,前面也有介绍过,用来处理数据包的装包解包以及发送的工作,后面会详细介绍。
  • 橡皮擦用的是白色画笔简单代替,这样在配合两个通道进行重回时,会出现之前的绘制路径覆盖当前绘制路径的问题。考虑优化。
  • run方法里面使用的是sleep等待指定时间再次刷新,此种方式有一个弊端就是在 ui 未发生任何更改时毅然刷新,浪费性能,可改成按需更新,但是这样就要在各个需要更新地方埋点,执行刷新,较为复杂。另一点,此处的sleep方法延迟时间需要考虑肉眼60帧的流畅度,也就是16毫秒就需要刷新一帧,那么这里的延迟时间的设置就需要考虑到这一点。
  1. 绘制通道
/**
 * Author   :hymane
 * Email    :[email protected]
 * Create at 2018/7/20
 * Description: 白板图形绘制通道,记录每一次绘制
 */
public class DoodleChannel {
    private static final String TAG = DoodleChannel.class.getSimpleName();
    //多线程原子操作
    private AtomicInteger index = new AtomicInteger(-1);
//    public volatile int index = -1;//当前绘制记录id,-1表示为空
    //当前画笔相关
    public int mPaintColor = Color.BLACK;
    //历史本地绘制记录
    public List<Action> actions = new ArrayList<>();
    private Bitmap mImage;
    private ActionType mPaintType = ActionType.Path;
    //当前画笔类型,即action类型

    private float mTranslateX = 0.0f;//x位移
    private float mTranslateY = 0.0f;//y位移
    private float mCurrentScale = 1.0f;//当前scale

    //当前使活动的动作
    public Action action;
    private int mPaintSize = 10;

    /***
     * 添加当前action至绘制记录
     */
    public void addCurrentAction() {
        if (action == null || !action.isStarted()) {
            return;
        }
        clearOut();
        index.incrementAndGet();
//        index++;
        actions.add(action);
        action.setAdded(true);
        Log.d(TAG, "READ_addCurrentAction: " + action.toString());
    }


    /***
     * 添加当前action至绘制记录
     */
    public void addCurrentAction(int id) {
        if (action == null || !action.isStarted()) {
            return;
        }
        clearOut();
        index.incrementAndGet();
//        index++;
        actions.add(id, action);
        Log.d(TAG, "READ_addCurrentAction: " + action.toString());
    }

    /***
     * 获取当前绘制id
     * @return
     */
    public int index() {
        return index.get();
    }

    /***
     * 添加当前action至绘制记录
     */
    public void addAction(Action a) {
        if (action == null) {
            return;
        }
        clearOut();
        index.incrementAndGet();
//        index++;
        actions.add(a);
    }

    /***
     * 添加当前action至绘制记录
     */
    public void addAction(Action a, int id) {
        if (action == null) {
            return;
        }
        clearOut();
        index.incrementAndGet();
//        index++;
        actions.add(id, a);
    }

    private void clearOut() {
        int count = actions.size() - 1;
        for (int i = count; i >= index.get() + 1; i--) {
            actions.remove(i);
        }
    }

    public boolean undo() {
        if (canUndo()) {
            index.decrementAndGet();
//            index--;
            return true;
        }
        return false;
    }

    public boolean redo() {
        if (canRedo()) {
            index.incrementAndGet();
//            index++;
            return true;
        }
        return false;
    }

    //检查index是否正常,可绘制
    public boolean checkIndex() {
        if (index.get() < 0) {
            return false;
        }
        if (index.get() >= actions.size()) {
            index.set(actions.size() - 1);
        }
        return true;
    }

    public boolean canUndo() {
        return index.get() >= 0 && !actions.isEmpty();
    }

    public boolean canRedo() {
        return index.get() < actions.size() - 1;
    }

    public void clear() {
        index.set(-1);
        actions.clear();
    }

    public boolean clear(int offset) {
        if (offset >= actions.size() || offset < -1) {
            return false;
        }
        index.set(offset);
        for (int i = offset; i < actions.size(); i++) {
            actions.remove(i);
        }
        return true;
    }
    //保存之前的画布缩放平移状态,保证在多画布切换时恢复现场
    public void save(float mTranslateX, float mTranslateY, float mCurrentScale) {
        this.mTranslateX = mTranslateX;
        this.mTranslateY = mTranslateY;
        this.mCurrentScale = mCurrentScale;
    }

    public void genAction(float x, float y) {
        action = genPaint(x, y);
    }

    public Action genPaint(float x, float y) {
        switch (mPaintType) {
            case Eraser:
                return new MyEraser(x, y, Color.WHITE, mPaintSize);
            case Image:
                return new MyImage(mImage, x, y, Color.WHITE, mPaintSize);
            case Path:
            default:
                return new MyPath(x, y, mPaintColor, mPaintSize);
        }
    }
}

我们已经知道了绘图通道用来保存各种绘制指令,所有绘制路径全部保存在actions数组中,并且有一个index指向当前最后一个可现实路径的坐标,这里主要用来处理撤销与反撤销工作。撤销与反撤销只需要在actions中移动这个index就可以了,当然需要严格判断这个index是否越界。

那么这个Action又是什么呢?是绘制的动作,或者说是绘制的类型。为了提高扩展性,我们日后可能会有更多绘制类型,比如绘制图片,绘制矩形,绘制直线甚至是绘制文本等。这里使用 Action进行派生出更多类型,而且我们将所有绘制工作全部交给绘制类型本身来处理,而不是在 DoodlView 中处理,先把职责分工好。

这样一想上面的actions就很好理解了。

Action 类

/**
 * Author   :hymane
 * Email    :[email protected]
 * Create at 2018/7/20
 * Description:
 */
public abstract class Action {
    protected float startX;//图型开始x坐标
    protected float startY;//图型开始y坐标
    protected float endX;//图型结束x坐标
    protected float endY;//图型结束x坐标
    protected int color;//图型颜色
    protected Paint mPaint;//画笔
    protected int size;//画笔宽度
    protected boolean smooth;//是否是平滑的画笔,贝塞尔path
    protected boolean isAdded;//是否已添加

    public Action() {
    }

    public Action(float x, float y) {
        this.startX = x;
        this.startY = y;
    }

    public Action(float x, float y, int color, int size) {
        this.startX = x;
        this.startY = y;
        this.endX = x;
        this.endY = y;
        this.color = color;
        this.size = size;
    }

    public boolean isPoint() {
        return startX == endX && startY == endY;
    }

    public abstract void onStart(float sx, float sy);

    public abstract void onMove(float mx, float my);

    public abstract void onDraw(Canvas canvas);

    /***
     * 清理数据,action不再使用时,做清理工作
     * 尤其是类似bitmap占用内存高的内容
     */
    public void clear() {

    }

    @Override
    public String toString() {
        return "Action{" +
                "startX=" + startX +
                ", startY=" + startY +
                ", endX=" + endX +
                ", endY=" + endY +
                ", size=" + size +
                '}';
    }
}

这里可以看到我们有一个onDraw的抽象方法,为了就是让子类去根据自身类型来绘制自己。比如图片就实现绘制图片的方法,路径就实现 path 路径的绘制防范。自己对自己才是最了解的,才知道自己要如何绘制。

  1. ActionType
/**
 * Author   :hymane
 * Email    :[email protected]
 * Create at 2018/7/24
 * Description:
 */
public enum ActionType {
    UnKnow(0),
    Point(1),
    Path(2),
    SmoothPath(3),
    Image(4),
    Line(5),
    Rect(6),
    Circle(7),
    FilledRect(8),
    FilledCircle(9),

    Eraser(10);

    private int value;

    ActionType(int value) {
        this.value = value;
    }

    public static ActionType typeOfValue(int value) {
        for (ActionType e : values()) {
            if (e.getValue() == value) {
                return e;
            }
        }
        return UnKnow;
    }

    public int getValue() {
        return value;
    }
}

ActionType 是一个枚举类型,全局限定了支持的绘制类型。

小小结

到这里白板绘制算告一段落,主要考虑的点是职责分工明确,而不是一味的丢给 DoodleView 来处理,虽然我们的白板就是 DoodleView,但是具体任务都是交给特定的组件来处理。

数据包管理(TransactionManager)

我们的绘制流程已经处理完毕,本地发生了绘制指令,需要将其同步到其他端,这里就需要数据包管理器来辅助处理。

先贴代码

/**
 * Author   :hymane
 * Email    :[email protected]
 * Create at 2018/7/27
 * Description: 事务数据包发送管理
 */
public class TransactionManager {
    private static final String TAG = "TransactionManager";
    //发送绘制消息的时间周期,每隔 {TIMER_TASK_PERIOD} 毫秒,
    //扫描一次缓存 cache,如果有未发生绘制路径,则发送。建议周期不易设置过小
    private final int TIMER_TASK_PERIOD = 30;
    private int mTimeTaskPeriod = TIMER_TASK_PERIOD;
    private Transaction transaction;
    private String channelId;//频道id
    private Handler handler;
    private List<Transaction> cache = new ArrayList<>(1000);

    private Runnable timerTask = new Runnable() {
        @Override
        public void run() {
//            handler.removeCallbacks(timerTask);
            {
                if (cache.size() > 0) {
                    sendCacheTransaction();
                }
            }
            handler.postDelayed(timerTask, TIMER_TASK_PERIOD);
        }
    };

    public TransactionManager(String channelId, Context context) {
        this.channelId = channelId;
        this.handler = new Handler(context.getMainLooper());
        this.handler.postDelayed(timerTask, TIMER_TASK_PERIOD); // 立即开启定时器
        this.transaction = transaction.clone();
    }

    public TransactionManager(String channelId, Context context, int timePeriod) {
        this.channelId = channelId;
        this.handler = new Handler(context.getMainLooper());
        this.mTimeTaskPeriod = timePeriod;
        this.handler.postDelayed(timerTask, mTimeTaskPeriod); // 立即开启定时器
        this.transaction = transaction.clone();
    }

    public void registerTransactionObserver(TransactionObserver o) {
        TransactionCenter.getInstance().registerObserver(channelId, o);
    }

    public void sendStartTransaction(float x, float y, int size, byte color) {
        pack(transaction.clone().makeStartTransaction(x, y, size, color));
    }

    public void sendMoveTransaction(float x, float y, int size, byte color) {
        pack(transaction.clone().makeMoveTransaction(x, y, size, color));
    }

    public void sendEndTransaction(float x, float y, int size, byte color) {
        pack(transaction.clone().makeEndTransaction(x, y, size, color));
    }

    public void sendTranslateTransaction(float tx, float ty) {
        pack(transaction.clone().makeTranslateTransaction(tx, ty));
    }

    public void sendScaleTransaction(float sx, float sy) {
        pack(transaction.clone().makeScaleTransaction(sx, sy));
    }

    public void sendRevokeTransaction() {
        pack(transaction.clone().makeRevokeTransaction());
    }

    public void sendRedoTransaction() {
        pack(transaction.clone().makeRevokeTransaction());
    }

    public void sendClearTransaction() {
        pack(transaction.clone().makeClearSelfTransaction());
    }

    private void pack(Transaction t) {
        Log.d(TAG, "pack: " + t.toString());
        cache.add(t);
    }

    //每隔一定时间将本地未发送的数据包发送到房间
    private void sendCacheTransaction() {
        TransactionCenter.getInstance().sendToRemote(channelId, this.cache);
        Log.d(TAG, "sendCacheTransaction: size=" + this.cache.size());
        cache.clear();
    }

    /***
     * 结束
     * 清理数据
     */
    public void end() {
        this.handler.removeCallbacks(timerTask);
        TransactionCenter.getInstance().end();
    }

    public int getTimeTaskPeriod() {
        return mTimeTaskPeriod;
    }

    public void setTimeTaskPeriod(int mTimeTaskPeriod) {
        this.mTimeTaskPeriod = mTimeTaskPeriod;
    }
}

这里需要注意的点有:

  • 我们的数据包最小封装是 Transaction,之前也分析过。
  • 为了减少请求次数,我们添加了一缓存cache,是一个列表,存储尚未发生的数据包。
  • 数据管理器会每隔指定时间检查一下cache是否有待发送数据包,如果有则发,没有就等待。任务交由timerTask处理,时间间隔由TIMER_TASK_PERIOD确定。
  • sendXXXTransaction系列方法向外提供了向待发送缓存cache中添加数据包的入口。这里使用了transaction.clone()来优化创建对象的性能开销。
  • 装包操作,添加的数据包是 Java 对象,我们使用pack()方法将待发送的 Transaction 数据包添加到cache缓存中。
  • timerTask时间片到来时,通过sendCacheTransaction方法发送这些待发送数据包至远端。
  • 真正发送数据的任务交给了 TransactionCenter(数据包发送中心)来处理,其实实际发送还不是数据包发送中心来处理的,而是Agora,后面再讲。
  • java数据包装包成可传输的json字符串的工作也是交给 TransactionCenter 来处理,由 TransactionManager 处理也行,但是为了更加高效,我们让 TransactionManager 处理单个数据包,TransactionCenter 处理一次发送任务数据包集合,如果每次都装包单个数据包成为json字符串,这未免也太低效了。
  • 如果需要监听远端的数据包,那就需要注册一个监听(观察者)registerTransactionObserver(),实际的解析json发送数据包任务也是由 TransactionCenter 来处理。
  • 不再使用时记得清理数据end()。

数据包发送中心 – TransactionCenter

先上代码

/**
 * Author   :hymane
 * Email    :[email protected]
 * Create at 2018/7/27
 * Description: 数据包底层收发处理中心
 */
public class TransactionCenter {
    private final String TAG = "TransactionCenter";

    // sessionId to TransactionObserver
    //可能包含多个session
    private Map<String, TransactionObserver> observers = new HashMap<>(2);

    public static TransactionCenter getInstance() {
        return TransactionCenter.TransactionCenterHolder.instance;
    }

    public void registerObserver(String sessionId, TransactionObserver o) {
        this.observers.put(sessionId, o);
    }

    /**
     * #1 数据发送
     */
    public void sendToRemote(String channelId, List<Transaction> transactions) {
        if (transactions == null || transactions.isEmpty()) {
            return;
        }

        //发送画笔轨迹消息
        String data = pack(transactions);
        Log.d(TAG, "sendToRemote: channelId:" + channelId + " data" + data);
        AgoraUtils.messageChannelSend(channelId, data);
    }

    /***
     * 压包,将本地数据压缩成网络传输数据
     * @param transactions
     * @return
     */
    private String pack(List<Transaction> transactions) {
        return JSON.toJSONString(transactions);
    }

    /***
     * 前:通信收到string字符数据
     * #2 单次数据包接收
     * @param sessionId
     * @param data 收到远端发送过来的 string 字符数据,
     *             包含画板同步数据,为 {@link Transaction} 封装类型
     */
    public void onReceive(String sessionId, String data) {
        if (observers.containsKey(sessionId)) {
            observers.get(sessionId).onTransaction(unpack(data));
        }
    }

    /***
     * 数据解包,将远端传过来的数据解压缩成数据单元
     * @param data 封装数据格式的字符串 see {@link Transaction}
     * @return 数据单元列表
     */
    private List<Transaction> unpack(String data) {
        if (TextUtils.isEmpty(data)) {
            return null;
        }
        return JSON.parseArray(data, Transaction.class);
    }

    /***
     * 清理缓存
     */
    public void end() {
        this.observers.clear();
    }

    /***
     * 单例
     */
    private static class TransactionCenterHolder {
        public static final TransactionCenter instance = new TransactionCenter();
    }

}

简单解释:

  • observers为对通道到来的数据感兴趣的观察者,有数据 TransactionCenter 会通知这些观察者,当然你可以添加多个observers
  • TransactionCenter 是一个单例的
  • 此处也有一个pack方法,方法是将待发送的 cache 数据包集合一并装包成json字符串,即某个时间片内所有的操作一起打包发送
  • 最后通过Agora向该频道发送字符串流数据
//data 即数据集合的 json 字符串
AgoraUtils.messageChannelSend(channelId, data);

那么,接收到数据如何处理呢?

    /***
     * 前:通信收到string字符数据
     * #2 单次数据包接收
     * @param sessionId
     * @param data 收到远端发送过来的 string 字符数据,
     *             包含画板同步数据,为 {@link Transaction} 封装类型
     */
    public void onReceive(String sessionId, String data) {
        if (observers.containsKey(sessionId)) {
            observers.get(sessionId).onTransaction(unpack(data));
        }
    }

    /***
     * 数据解包,将远端传过来的数据解压缩成数据单元
     * @param data 封装数据格式的字符串 see {@link Transaction}
     * @return 数据单元列表
     */
    private List<Transaction> unpack(String data) {
        if (TextUtils.isEmpty(data)) {
            return null;
        }
        return JSON.parseArray(data, Transaction.class);
    }

和发送数据包一样,只是这里是一个反操作,是对数据解包的过程。解包出来的Transaction对象即可进行本地返回操作。

观察者

/**
 * Author   :hymane
 * Email    :[email protected]
 * Create at 2018/7/27
 * Description: 事务接收回调
 */
public interface TransactionObserver {
    void onTransaction(List<Transaction> transactions);
}

json 与 protobuf 优化

本例使用的是 json,前面效率与性能一节我们提到 json 效率和 protobuf 效率的对比,很明显 protobuf 比 json 效率要高不少,那么如果需要更换也很简单,修改本类中的packunpack方法即可,换成 protobuf 来装包与解包即可,其他的地方几乎不需要做任何修改。

PS:其中Agora部分就不介绍了,官网有很多案例介绍。这里贴一下Agroa各方法调用生命周期图。

小小结

本小节主要是介绍了数据包的管理以及发送,还是遵循单一原则理念,将任务分发给特定的类来处理,而不是一股脑堆一起。从数据包处理,整合到最终的json数据的发送,一层一层向下推进。

如何使用

  1. 新建一个 Activity 或者 Fragment
  2. 布局中添加 DoodleView
  3. 新建绘制通道列表,用来实现多页白板绘制任务

     private List<DoodleChannel> mDoodleChannels;
    
  4. 初始化白板

      /**
          * 初始化 DoodleView
          */
         private void initDoodleView() {
             mAccount = Md5Utils.md5(Constant.User.getUserName());
    
             Log.d(TAG, "initDoodleView: " + mAccount);
             //初始化 Agora
             AgoraUtils.setMsgCallback(mMsgCallback);
             AgoraUtils.getAgoraAPI().channelJoin(mChannelId);
             //先initDoodle后initChannel
             //内部调用registerTransactionObserver实现监听
             mDoodleView.initDoodle(this, mChannelId);
             mDoodleView.setDrawDelayTime(30);
             initDoodleChannel();
         }
    
         /***
          * 加载白板通道
          * page:当前为白板第几页
          */
         private void initDoodleChannel() {
             mDoodleView.initChannel(mDoodleChannels.get(page), null);
             mDoodleView.setCanvasBackgroundColor(Color.WHITE);
         }
    
  5. 接收到数据时调用

     TransactionCenter.getInstance().onReceive(mChannelId, msg);
    
  6. 结束白板时调用mDoodleView.end();

总结

从本次实践中,学到了不少东西,从需求分析到确定选型,再到方案的优化,以及最后的实现。在分析问题中学到了解决问题的方法,开发中也完全不是实现就够了,更重要的如何实现达到最优。虽然现在还有一些已知的问题没有解决,比如多通道绘制覆盖路径问题,画布缩放中心问题等,有待更加深入的处理。

如果本文对你有帮助,不妨给博主一点鼓励