Canvas使用技巧

Canvas使用技巧


  安卓系统提供了许多方便开发者的组件,比如TextView和ImageView等,它们的组合可以迎合实现大部分一般生产环境的需求,无论是数据展示还是页面交互,只要合理运用这些原生组件或者一些通过重写原生组件达到拓展功能的第三方组件便可以实现要求。
  但是偶尔在编写应用时会遇到原生组件无论怎么组合都难以实现需求的情况,而且也不能用这个当理由去怼设计和产品人员,因为往往这样的设计对于业务功能的实现来说是很好的选择。
  那么系统原生组件帮不了,要是第三方组件也跟着熄火了的话,这需求怕是没法做了。针对这种情况,安卓提供了一个足够灵活但也足够复杂的解决方案,那就是Canvas。


Canvas简介

  Canvas直接翻译过来叫做“画布”,指的就是安卓组件View上面显示图像文字等的区域。每个View都会有自己的Canvas,它显示的东西也都画在Canvas上。当然了,这些Canvas本质上依然属于主线程显示,它们隶属于同一个UI展示层,唯有SurfaceView例外。
  要使用Canvas,最简单的办法就是继承View类并且在更新时获取自身的Canvas然后绘制所需的图像或者文字,代码如下

public class MyCanvas extends View {

    private Paint linePaint;
    private Paint textPaint;

    public MyCanvas(Context context) {
        super(context);
        initAttr();
    }

    public MyCanvas(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initAttr();
    }

    public MyCanvas(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initAttr();
    }

    private void initAttr() {
        linePaint = new Paint();
        linePaint.setColor(0xFF22FFCC);
        linePaint.setStyle(Paint.Style.STROKE);
        linePaint.setStrokeWidth(20);
        textPaint = new Paint();
        textPaint.setColor(0xFF111111);
        textPaint.setTextSize(64);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawColor(Color.WHITE);
        canvas.drawText("This is canvas test!", 12, 80, textPaint);
        canvas.drawLine(12f, 128f, 512f, 128f, linePaint);
        canvas.drawLine(512f, 128f, 512f, 512f, linePaint);
        canvas.drawLine(512f, 512f, 12f, 512f, linePaint);
        canvas.drawLine(12f, 512f, 12f, 128f, linePaint);
    }
}

  上述代码在空白的View上绘制了一个字符串和一个方框,只要将该自定义View放到布局中并运行即可看到效果。
  乍一看在View中绘制文字和图像似乎并没有什么大用,前者有TextView后者可以用资源图片,为何需要这么麻烦地手动绘制呢。
  但实际应用中,有些特殊的情况是需要用到Canvas的灵活绘制能力的,而且在特定的时候Canvas可能是唯一的选择。


Canvas应用场景——图表绘制

  对于纯粹的数据列表,安卓有很完善的展示方案,比如ListView或者GridView,ExpandableListView功能更加强大,对于纯数据展示需求毫无压力。
  但是如果需求里有图表要求怎么办?比如要求根据返回的数据画折线图,直方图乃至饼图等等。
  求助第三方组件是个很实际的解决方案,事实上在生产环境中确实应该尽可能使用第三方组件来实现一些特殊的需求,比如ChartEngine就是很不错的第三方图表绘制工具库,支持大量的图表类型,可以满足大部分图表需求。
  但是第三方也不总是万能的,有时候需求特殊到第三方都难以解决,或者说即便是第三方组件也需要很复杂的实现过程时,知道Canvas怎么用以及可以使用它来完成简单的绘制需求可能就会成为一个合适的解决方案。
  所以,试着了解一下如何使用Canvas来绘制简单的图表是有好处的。

public class MyChart {
    private Context _context;
    private Bitmap _chart; // 画布关联的图片
    private Canvas _canvas; // 画布引用
    private List<Integer> valueList; // 折线图取值点列表
    private List<Paint> circlePaints; // 取值点画笔列表
    private Paint linePaint; // 折线画笔
    private Paint shellPaint_top; // 上边框画笔
    private Paint shellPaint_bot; // 下边框画笔
    private int measuredWidth; // 预定图片宽度
    private int measuredHeight; // 预定图片高度

    private int[] circleColorList = {Color.RED, Color.GREEN, Color.BLUE};

    public MyChart(Context context, int cWidth, int cHeight) {
        _context = context;
        circlePaints = new ArrayList<>();
        _chart = Bitmap.createBitmap(cWidth, cHeight, Bitmap.Config.ARGB_8888);
        measuredWidth = cWidth;
        measuredHeight = cHeight;
        // 设置外框和网格绘制画笔
        shellPaint_top = new Paint();
        shellPaint_bot = new Paint();
        shellPaint_top.setColor(0xffdddddd);
        shellPaint_bot.setColor(0xffdddddd);
        shellPaint_top.setAntiAlias(true);
        shellPaint_top.setStyle(Paint.Style.STROKE);
        shellPaint_top.setStrokeWidth(8f);
        shellPaint_bot.setAntiAlias(true);
        shellPaint_bot.setStyle(Paint.Style.STROKE);
        shellPaint_bot.setStrokeWidth(8f);
    }

    public void initSetup(Integer cLine) {
        if (linePaint == null) {
            linePaint = new Paint();
        }
        // 设置绘制折线的画笔属性
        linePaint.setColor(cLine);
        linePaint.setAntiAlias(true);
        linePaint.setStyle(Paint.Style.STROKE);
        linePaint.setStrokeWidth(8);
        Paint cp;
        // 设置绘制取值点的画笔属性
        if (circlePaints == null) {
            circlePaints = new ArrayList<>();
            for (int i = 0; i < 3; i++) {
                cp = new Paint();
                cp.setColor(circleColorList[i]);
                cp.setAntiAlias(true);
                circlePaints.add(cp);
            }
        } else if (circlePaints.size() < 3) {
            circlePaints.clear();
            for (int i = 0; i < 3; i++) {
                cp = new Paint();
                cp.setColor(circleColorList[i]);
                cp.setAntiAlias(true);
                circlePaints.add(cp);
            }
        }
    }

    public void setValueList(List<Integer> vList) {
        valueList = vList;
    }

    public void drawChart(ImageView img) {
        if (_canvas == null) {
            _canvas = new Canvas(_chart);
        } else {
            // 清理画布并设置绘制模式
            Paint p = new Paint();
            p.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
            _canvas.drawPaint(p);
            p.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
        }
        int startX = UiTool.dpToPx(_context, 5);
        int startY = UiTool.dpToPx(_context, 5);
        int deltaX = (measuredWidth - UiTool.dpToPx(_context, 10)) / 7;
        int deltaY = UiTool.dpToPx(_context, 45);
        // ---------------绘制外框--------------
        _canvas.drawLine(startX, startY, measuredWidth, startY, shellPaint_top);
        _canvas.drawLine(measuredWidth, startY, measuredWidth, measuredHeight, shellPaint_top);
        _canvas.drawLine(startX, 0, startX, measuredHeight, shellPaint_bot);
        _canvas.drawLine(startX, measuredHeight - 2, measuredWidth, measuredHeight - 2, shellPaint_bot);
        // ---------------绘制网格--------------
        for (int i = 1; i < valueList.size() - 1; i++) {
            _canvas.drawLine(startX + deltaX * i, startY, startX + deltaX * i, measuredHeight, shellPaint_top);
        }
        for (int i = 0; i < 3; i++) {
            _canvas.drawLine(startX, startY + deltaY * i, measuredWidth, startY + deltaY * i, shellPaint_top);
        }
        // ---------------绘制折线--------------
        for (int i = 0; i < valueList.size(); i++) {
            if (i > 0) {
                _canvas.drawLine(startX + (i - 1) * deltaX, startY + valueList.get(i - 1) * deltaY, startX + i * deltaX, startY + valueList.get(i) * deltaY, linePaint);
            }
        }
        // ---------------绘制取值点--------------
        for (int i = 0; i < valueList.size(); i++) {
            _canvas.drawCircle(startX + i * deltaX, startY + valueList.get(i) * deltaY, UiTool.dpToPx(_context, 5), circlePaints.get(valueList.get(i)));
        }
        // 将绘制好的图片设置到ImageView
        img.setImageBitmap(_chart);
    }
}

// 然后只需要在主线程中调用几个方法即可
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    MyChart chart = new MyChart(this, 500, 300);
    ImageView image = (ImageView) findViewById(R.id.mainCom);
    chart.initSetup(0xFF333333);
    List<Integer> val = new ArrayList<>();
    for(int i = 0; i < 8; i++) {
        val.add(i % 3);
    }
    chart.setValueList(val);
    chart.drawChart(image);
}

  上面的例子展示了Canvas的另外一种用法,那就是直接在空白的Bitmap对象上绘制图形,效果和在View上绘制没有太大的区别,反而在遇到尺寸很大的图形时容易因为Bitmap产生内存问题,因此要使用何种方式应该具体情况具体分析。
  可以看得出来Canvas绘制图表确实十分麻烦,画笔设置,坐标计算等等都必须手动完成;但显而易见的是这样的做法比第三方图表库灵活得多,因为整个绘图过程都处于开发者的控制之下,也没有第三方图表库那么复杂的机制,对于一些简单却不寻常的需求适应性要比通用的第三方库好得多。

Canvas应用场景——弹幕覆盖

  弹幕是个常见于网络视频播放应用中的需求,其实现方法多种多样,也有专门的第三方库用来制作支持弹幕的视频播放器,比如开源安卓视频弹幕框架,封装了哔哩哔哩动画的弹幕库。
  在生产环境下这样的弹幕库当然是提倡使用的,但是如果希望定制化开发针对性的弹幕界面,也需要对弹幕的实现有比较完善的控制机制时,第三方弹幕库就并不总是那么适合了。
  因此要适当了解关于弹幕的实现方法,作者习惯将弹幕的实现分为两个层级,第一层级是View层,也就是通过在FrameLayout中覆盖TextView并且使用属性动画让弹幕运动起来这样的实现方法。不得不说这样的方案最为简单,本质上它和正常的应用没什么区别,就是将弹幕放到了界面上并且手动控制其显示,隐藏和运动;相对应的,这种方案的效率低到让人无法接受,或许几十条弹幕同时出现并且运动就能让界面响应不过来,十分影响使用。
  第二层是Canvas层,这一层相对第一层要复杂一些,效率适中,实际上开源的第三方弹幕库本质上都是使用的Canvas渲染文字的方式,只不过开源库会有更加完善的线程,时间,位置,颜色等等参数的控制,也会有各种各样的优化机制尽可能让效率变好,能适应更多的同屏弹幕。
  作为学习的示例,下面的代码就是利用Canvas实现的弹幕覆盖,比第一层的做法复杂,但是又比那些封装好的开源弹幕库简单,虽然对于弹幕的渲染能力不够灵活,作为针对特定需求的弹幕覆盖组件还是可堪一用的。

public class ComplexSubtitleView extends View {

    private SubtitleWrapper subtitleWrapper; // 弹幕包裹类
    private SubtitleThread subtitleThread; // 弹幕绘制线程
    private Paint rectPaint;
    private Random random;
    private int windowWidthNum;
    private int windowHeightNum;
    private int subtitleStatus;

    private Paint defaultPaint;
    private final String defaultStr = "当前没有弹幕可以显示";

    private final int STATUS_NO_SUB = 0;
    private final int STATUS_WITH_SUB = 1;
    private final int STATUS_HIDE_SUB = 2;
    private final int STATUS_STOP_SUB = 3;

    private final int DEFAULT_THREAD_INTERVAL = 10;
    private final int DEFAULT_TEXT_SHIFT = 3;
    private final int DEFAULT_PAINTER_COUNT = 16;

    // 新弹幕插入,就地做裁剪方便展示(根据需求不同可以设置裁剪长度,也可以不裁剪)
    public void insertSubtitle(String subtitle) {
        if (subtitleWrapper != null) {
            if (subtitle.length() > 20) {
                subtitle = subtitle.substring(0, 20) + "...";
            }
            subtitleWrapper.insertSubtitle(subtitle);
        }
    }

    public ComplexSubtitleView(Context context) {
        super(context);
        initAttr();
    }

    public ComplexSubtitleView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initAttr();
    }

    private void initAttr() {
        subtitleWrapper = new SubtitleWrapper();
        subtitleWrapper.setPainterCount(DEFAULT_PAINTER_COUNT); //设置有多少画笔同时绘制弹幕,一般而言等同于同屏弹幕数量
        // 获取窗体大小
        Rect rect = new Rect();
        getWindowVisibleDisplayFrame(rect);
        windowWidthNum = rect.width();
        windowHeightNum = UiTool.dpToPx(getContext(), 192);
        subtitleWrapper.setWindowWidth(windowWidthNum);
        // 指定随机种子,随机数用于让弹幕交错滚动
        random = new Random(System.currentTimeMillis());
        subtitleStatus = STATUS_NO_SUB; // 初始化弹幕状态为没有弹幕
        // 设置默认画笔
        defaultPaint = new Paint();
        defaultPaint.setColor(0xffffffff);
        defaultPaint.setTextSize(UiTool.dpToPx(getContext(), 34));
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (checkNoneSign()) {
            // 没有弹幕就在屏幕中间显示提示语
            float defaultWidth = defaultPaint.measureText(defaultStr);
            float defaultX = (windowWidthNum / 2) - (defaultWidth / 2);
            float defaultY = (windowHeightNum / 2) + 24;
            Log.i("SUBTITLE_DEFAULT", "window area - w: " + windowWidthNum + " h: " + "" + windowHeightNum + " String position - x: " + "" + defaultX + " y: " + defaultY + ".");
            canvas.drawText(defaultStr, defaultX, defaultY, defaultPaint);
        } else {
            // 有弹幕则刷新弹幕位置
            subtitleWrapper.updateAllSubtitle(canvas);
            if (subtitleThread == null) { // 初次进入要启动弹幕绘制线程
                subtitleThread = new SubtitleThread();
                subtitleThread.start();
            }
        }
    }

    // 检查是否有弹幕可以展示
    private boolean checkNoneSign() {
        return subtitleStatus == STATUS_NO_SUB;
    }

    // 检查弹幕运动是否停止
    private boolean checkStopSign() {
        return subtitleStatus == STATUS_STOP_SUB;
    }

    // 检查弹幕是否隐藏
    private boolean checkHideSign() {
        return subtitleStatus == STATUS_HIDE_SUB;
    }

    // 停止弹幕运动
    public void setSubtitleStop() {
        subtitleStatus = STATUS_STOP_SUB;
        postInvalidate();
    }

    // 设置弹幕隐藏
    public void setSubtitleHide() {
        subtitleStatus = STATUS_HIDE_SUB;
        postInvalidate();
    }

    // 设置弹幕显示
    public void setSubtitleVisible() {
        subtitleStatus = STATUS_WITH_SUB;
        postInvalidate();
    }

    // 设置当前没有弹幕
    public void setSubtitleNone() {
        subtitleStatus = STATUS_NO_SUB;
        postInvalidate();
    }

    // 弹幕绘制线程,实际上是一个定时器,按照固定的频率刷新当前View
    private class SubtitleThread extends Thread {
        @Override
        public void run() {
            while (!checkStopSign()) {
                postInvalidate();
                try {
                    Thread.sleep(DEFAULT_THREAD_INTERVAL);
                } catch (InterruptedException ex) {
                    Log.d("THREAD_EXCEPTION", "ComplexSubtitleView: Animation thread interrupted!");
                }
            }
        }
    }

    // 弹幕包裹类,封装了弹幕内容,绘制画笔以及其他相关信息数据
    private class SubtitleWrapper {
        // 弹幕内容队列,按照先进先出的规则发射
        private volatile Queue<String> subtitleContents;
        // 弹幕绘制画笔,每条弹幕使用一个画笔
        private List<SubtitlePainter> painterList;
        // 弹幕标志散列,用于判断某条弹幕是否依然可见
        private SparseBooleanArray flagSparseArray;
        private int windowWidth; // 弹幕显示窗体宽度
        private int painterCount; // 画笔数量

        public SubtitleWrapper() {
            subtitleContents = new ConcurrentLinkedQueue<String>();
            painterList = new ArrayList<SubtitlePainter>();
            flagSparseArray = new SparseBooleanArray();
            buildPainterFlagMap();
        }

        public void setPainterCount(int painterCount) {
            this.painterCount = painterCount;
            buildPainterList();
        }

        public void setWindowWidth(int windowWidth) {
            this.windowWidth = windowWidth;
        }

        public void insertSubtitle(String subtitle) {
            subtitleContents.offer(subtitle);
        }

        // 将所有的弹幕刷新一次
        public void updateAllSubtitle(Canvas canvas) {
            for (int i = 0; i < painterCount; i++) {
                SubtitlePainter sPainter = painterList.get(i);
                if (!sPainter.isActive()) {
                    int position = checkForAvailableLine();
                    if (position != -1 && !isQueueEmpty()) {
                        // 为新发射的弹幕设置画笔,初始位置加入随机参数使得弹幕出现时机有先后
                        sPainter.setPositionX(windowWidth + (random.nextInt(4) + 4) * UiTool.dpToPx(getContext(), 17));
                        sPainter.setPositionY(position);
                        sPainter.setContent(subtitleContents.poll());
                        sPainter.setWindowWidth(windowWidth);
                        flagSparseArray.put(position, false);
                        sPainter.setFlagSparseArray(flagSparseArray);
                        sPainter.activate();
                    }
                } else {
                    // 不是新发射的弹幕则将其平移一定距离
                    sPainter.shiftSubtitle(DEFAULT_TEXT_SHIFT);
                    if (!checkHideSign()) {
                        sPainter.drawSubtitle(canvas);
                    }
                }
            }
        }

        public boolean isQueueEmpty() {
            return subtitleContents.isEmpty();
        }

        public boolean isAnyPainterActive() {
            for (int i = 0; i < painterCount; i++) {
                if (painterList.get(i).isActive()) {
                    return true;
                }
            }
            return false;
        }

        private int checkForAvailableLine() {
            for (int i = 0; i < flagSparseArray.size(); i++) {
                int key = flagSparseArray.keyAt(i);
                if (flagSparseArray.get(key)) {
                    return key;
                }
            }
            return -1;
        }

        private void buildPainterList() {
            SubtitlePainter sPainter = null;
            for (int i = 0; i < painterCount; i++) {
                sPainter = new SubtitlePainter();
                sPainter.setFlagSparseArray(flagSparseArray);
                painterList.add(sPainter);
            }
        }

        private void buildPainterFlagMap() {
            for (int i = 0; i < 6; i++) {
                flagSparseArray.put((i + 1) * UiTool.dpToPx(getContext(), 20),
                        true);
            }
        }
    }

    // 弹幕画笔封装类
    private class SubtitlePainter {
        // 弹幕标志散列引用,方便判定自身弹幕是否已经离开屏幕
        private SparseBooleanArray flagSparseArray;

        private Paint paint;
        private int windowWidth;
        private int positionX;
        private int positionY;
        private String content;
        private float contentWidth; // 弹幕内容长度,单位为像素
        private boolean isFullyVisible; // 弹幕文字是否全部展示到了界面上
        private boolean isActive; // 当前弹幕是否处于激活状态(非激活状态的画笔会被回收重用)

        public SubtitlePainter() {
            paint = new Paint();
            paint.setColor(0xffffffff);
            paint.setTextSize(UiTool.dpToPx(getContext(), 17));
            isActive = false;
            isFullyVisible = false;
        }

        public void setFlagSparseArray(SparseBooleanArray flagSparseArray) {
            this.flagSparseArray = flagSparseArray;
        }

        public void setPaint(Paint paint) {
            this.paint = paint;
        }

        public void setWindowWidth(int windowWidth) {
            this.windowWidth = windowWidth;
        }

        public void setPositionX(int positionX) {
            this.positionX = positionX;
        }

        public void setPositionY(int positionY) {
            this.positionY = positionY;
        }

        public void setContent(String content) {
            this.content = content;
            contentWidth = paint.measureText(content);
        }

        public void activate() {
            isActive = true;
        }

        public void deactivate() {
            isActive = false;
            isFullyVisible = false;
        }

        public boolean isActive() {
            return isActive;
        }

        // 将弹幕位置偏移delta个像素
        public void shiftSubtitle(int delta) {
            positionX -= delta;
            if (!isFullyVisible && positionX < windowWidth - contentWidth - 30) {
                flagSparseArray.put(positionY, true);
                isFullyVisible = true;
            } else if (!isFullyVisible) {
                flagSparseArray.put(positionY, false);
            }
            if (positionX < -contentWidth) {
                deactivate();
            }
        }

        // 在指定Canvas上绘制弹幕
        public void drawSubtitle(Canvas canvas) {
            canvas.drawText(content, positionX, positionY, paint);
        }
    }
}

  使用时只需要将这个自定义View写入布局中,然后为它设置状态以及添加弹幕即可。限于所用的渲染方式,弹幕不能重叠在一起,只能分开一条一条出现。


  以上列举了两种关于直接操作Canvas来完成展示工作的例子,而Canvas的能力远不止这么一些,它的灵活性高到几乎可以做任何事情,只要有足够好的算法和结构。
  关于Canvas的API文档,安卓源代码文档或者一些网络文章都有非常详细的讲解,比如Android中Canvas绘图基础详解这篇博客。

猜你喜欢

转载自blog.csdn.net/soul900524/article/details/78963688