04-Canvas绘图基础详解

一、前言

Android中,如果我们想绘制复杂的自定义View或游戏,我们就需要熟悉绘图API。Android通过Canvas类暴露了很多drawXXX方法,我们可以通过这些方法绘制各种各样的图形。Canvas绘图有三个基本要素:Canvas绘图坐标系以及Paint。Canvas是画布,我们通过Canvas的各种drawXXX方法将图形绘制到Canvas上面,在drawXXX方法中我们需要传入要绘制的图形的坐标形状,还要传入一个画笔Paint。drawXXX方法以及传入其中的坐标决定了要绘制的图形的形状,比如drawCircle方法,用来绘制圆形,需要我们传入圆心的x和y坐标,以及圆的半径。drawXXX方法中传入的画笔Paint决定了绘制的图形的一些外观,比如是绘制的图形的颜色,再比如是绘制圆面还是圆的轮廓线等。

二、Canvas的常用操作速查表

操作类型 相关API 备注
绘制颜色 drawColor, drawRGB, drawARGB 使用单一颜色填充整个画布
绘制基本形状 drawPoint, drawPoints, drawLine, drawLines, drawRect, drawRoundRect, drawOval, drawCircle, drawArc 依次为 点、线、矩形、圆角矩形、椭圆、圆、圆弧
绘制图片 drawBitmap, drawPicture 绘制位图和图片
绘制文本 drawText, drawPosText, drawTextOnPath 依次为 绘制文字、绘制文字时指定每个文字位置、根据路径绘制文字
绘制路径 drawPath 绘制路径,绘制贝塞尔曲线时也需要用到该函数
顶点操作 drawVertices, drawBitmapMesh 通过对顶点操作可以使图像形变,drawVertices直接对画布作用、 drawBitmapMesh只对绘制的Bitmap作用
画布剪裁 clipPath, clipRect 设置画布的显示区域
画布快照 save, restore, saveLayerXxx, restoreToCount, getSaveCount 依次为 保存当前状态、 回滚到上一次保存的状态、 保存图层状态、 回滚到指定状态、 获取保存次数
画布变换 translate, scale, rotate, skew 依次为 位移、缩放、 旋转、错切
Matrix(矩阵) getMatrix, setMatrix, concat 实际画布的位移,缩放等操作的都是图像矩阵Matrix,只不过Matrix比较难以理解和使用,故封装了一些常用的方法。

PS: Canvas常用方法在上面表格中已经全部列出了,当然还存在一些其他的方法未列出,具体可以参考官方文档 Canvas

三、Canvas绘图基础详解

1. Canvas坐标系与绘图坐标系

  • Canvas坐标系: Canvas坐标系指的是Canvas本身的坐标系,Canvas坐标系有且只有一个,且是唯一不变的,其坐标原点在View的左上角,从坐标原点向右为x轴的正半轴,从坐标原点向下为y轴的正半轴。

  • 绘图坐标系: CanvasdrawXXX方法中传入的各种坐标指的都是绘图坐标系中的坐标,而非Canvas坐标系中的坐标。默认情况下,绘图坐标系与Canvas坐标系完全重合,即初始状况下,绘图坐标系的坐标原点也在View的左上角,从原点向右为x轴正半轴,从原点向下为y轴正半轴。但不同于Canvas坐标系,绘图坐标系并不是一成不变的,可以通过调用Canvastranslate方法平移坐标系,可以通过Canvasrotate方法旋转坐标系,还可以通过Canvasscale方法缩放坐标系,而且需要注意的是,translaterotatescale的操作都是基于当前绘图坐标系的,而不是基于Canvas坐标系,一旦通过以上方法对坐标系进行了操作之后,当前绘图坐标系就变化了,以后绘图都是基于更新的绘图坐标系了。也就是说,真正对我们绘图有用的是绘图坐标系而非Canvas坐标系。

为了更好的理解绘图坐标系,请看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

    /**
     * 绘制坐标系
     */
    private fun drawAxis(canvas: Canvas) {
        val canvasWidth = canvas.width.toFloat()
        val canvasHeight= canvas.height.toFloat()
        mPaint.setColor(Color.GREEN)
        mPaint.strokeWidth = 8f
        //第一次绘制坐标轴(默认情况下的绘图坐标系)
        canvas.drawLine(0f,0f,canvasWidth,0f,mPaint)//绘制x轴
        canvas.drawLine(0f,0f,0f,canvasHeight,mPaint)//绘制Y轴
        //绘制一个矩形
        mPaint.setColor(Color.BLUE)
        val rect = RectF(100F, 100F, 200F, 200F)
        canvas.drawRect(rect,mPaint)

        //对坐标系平移后,第二次绘制坐标轴
        canvas.translate(300f,300f)
        mPaint.setColor(Color.RED)
        canvas.drawLine(0f,0f,canvasWidth,0f,mPaint)//绘制x轴
        canvas.drawLine(0f,0f,0f,canvasHeight,mPaint)//绘制Y轴
        //绘制一个矩形
        mPaint.setColor(Color.BLUE)
        canvas.drawRect(rect,mPaint)

        //对坐标系旋转,第三次绘制坐标轴
        canvas.rotate(30f)
        mPaint.setColor(Color.BLACK)
        canvas.drawLine(0f,0f,canvasWidth,0f,mPaint)//绘制x轴
        canvas.drawLine(0f,0f,0f,canvasHeight,mPaint)//绘制Y轴
        //绘制一个矩形
        mPaint.setColor(Color.BLUE)
        canvas.drawRect(rect,mPaint)
    }

界面如下图所示:

绘制坐标系

图中黑色坐标代表View的坐标系,红色的坐标代表绘图的坐标系。

第一次绘制坐标轴时(绿色),可以看到,默认情况下View的坐标系和绘图的坐标系重合

第二次绘制坐标轴时(红色),可以看到绘图坐标系的坐标原点被移动到(300,300),而View的坐标系没有改变,绘制内容是基于绘图坐标系来绘制的。

第三次绘制坐标轴时(黑色),将绘图的坐标系顺时针旋转了30°,View的坐标系还是没有改变,绘制内容是基于绘图坐标系来绘制的。

从上我们可以知道,在绘制内容时是根据绘图的坐标系来绘制的,我们对Canvas的操作只是改变了绘图坐标系跟View的坐标系无关。

2. 画布操作

画布的操作可以让我们绘制出更多的效果,这里要注意一点,就是画布Canvas的概念,虽然翻译为画布,其实它是绘制的规则,真正绘制是在屏幕上,所以当画布平移、裁剪等操作只针对于画布来说,对其View的大小和位置没有影响。

Q:为什么要有画布操作?

A:画布操作可以帮助我们用更加容易理解的方式制作图形。

例如: 从坐标原点为起点,绘制一个长度为20dp,与水平线夹角为30度的线段怎么做?

按照我们通常的想法(被常年训练出来的数学思维),就是先使用三角函数计算出线段结束点的坐标,然后调用drawLine即可。

然而这是否是被固有思维禁锢了?

假设我们先绘制一个长度为20dp的水平线,然后将这条水平线旋转30度,则最终看起来效果是相同的,而且不用进行三角函数计算,这样是否更加简单了一点呢?

合理的使用画布操作可以帮助你用更容易理解的方式创作你想要的效果,这也是画布操作存在的原因。

2.1 平移(translate)

translate是坐标系的移动,可以为图形绘制选择一个合适的坐标系。 请注意,位移是基于当前位置移动,而不是每次基于屏幕左上角的(0,0)点移动,如下:

1
2
3
4
5
6
7
8
9
10
       // 在坐标原点绘制一个黑色圆形
        mPaint.style = Paint.Style.FILL
        mPaint.setColor(Color.BLACK)
        canvas.translate(200f,200f)
        canvas.drawCircle(0f,0f,100f,mPaint)

        // 在坐标原点绘制一个蓝色圆形
        mPaint.setColor(Color.BLUE)
        canvas.translate(200f,200f)
        canvas.drawCircle(0f,0f,100f,mPaint)

效果如下:

画布平移

我们首先将坐标系移动一段距离绘制一个圆形,之后再移动一段距离绘制一个圆形,两次移动是可叠加的

2.2 缩放(scale)

缩放提供了两个方法,如下:

1
2
3
 public void scale (float sx, float sy)

 public final void scale (float sx, float sy, float px, float py)

这两个方法中前两个参数是相同的分别为x轴和y轴的缩放比例。而第二种方法比前一种多了两个参数,用来控制缩放中心位置的。

缩放比例(sx,sy)取值范围详解:

取值范围(n) 说明
(-∞, -1) 先根据缩放中心放大n倍,再根据中心轴进行翻转
-1 根据缩放中心轴进行翻转
(-1, 0) 先根据缩放中心缩小到n,再根据中心轴进行翻转
0 不会显示,若sx为0,则宽度为0,不会显示,sy同理
(0, 1) 根据缩放中心缩小到n
1 没有变化
(1, +∞) 根据缩放中心放大n倍

如果在缩放时稍微注意一下就会发现缩放的中心默认为坐标原点,而缩放中心轴就是坐标轴,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    val canvasWidth = canvas.width.toFloat()
    val canvasHeight= canvas.height.toFloat()
    //画布原点平移到中心
    canvas.translate(canvasWidth/2,canvasHeight/2)
    //绘制坐标系
    mPaint.style = Paint.Style.STROKE
    mPaint.setColor(Color.RED)
    mPaint.strokeWidth = 4f
    canvas.drawLine(-canvasWidth/2,0f,canvasWidth/2,0f,mPaint)//x轴
    canvas.drawLine(0f,-canvasHeight/2,0f,canvasHeight/2,mPaint)//y轴


    val rect = RectF(0F, 0F, 400F, 400F)    // 矩形区域
    mPaint.setColor(Color.BLACK)
    canvas.drawRect(rect, mPaint)       //绘制黑色矩形

    canvas.scale(0.5f,0.5f)      // 画布缩放

    mPaint.setColor(Color.BLUE)
    canvas.drawRect(rect, mPaint)      //再绘制蓝色矩形

(为了更加直观,我添加了一个坐标系,可以比较明显的看出,缩放中心就是坐标原点)

画布缩放1

接下来我们使用第二种方法让缩放中心位置稍微改变一下,如下:

1
2
3
4
5
6
7
8
    val rect = RectF(0F, 0F, 400F, 400F)    // 矩形区域
    mPaint.setColor(Color.BLACK)
    canvas.drawRect(rect, mPaint)       //绘制黑色矩形

    canvas.scale(0.5f,0.5f,200f,0f)   // 画布缩放  <-- 缩放中心向右偏移了200个单位

    mPaint.setColor(Color.BLUE)
    canvas.drawRect(rect, mPaint)      //再绘制蓝色矩形

画布缩放2

前面两个示例缩放的数值都是正数,按照表格中的说明,当缩放比例为负数的时候会根据缩放中心轴进行翻转,下面我们就来实验一下:

1
    canvas.scale(-0.5f,-0.5f)         // 画布缩放

画布缩放3

为了效果明显,我对两个矩形中几个重要的点进行了标注,具有相同字母标注的点是一一对应的。

由于本次未对缩放中心进行偏移,所有默认的缩放中心就是坐标原点,中心轴就是x轴和y轴

本次缩放可以看做是先根据缩放中心(坐标原点)缩放到原来的0.5倍,然后分别按照x轴和y轴进行翻转

1
    canvas.scale(-0.5f,-0.5f,200f,0f)  // 画布缩放  <-- 缩放中心向右偏移了200个单位

画布缩放4

本次对缩放中心点坐标进行了偏移,故中心轴也向右偏移了。注意翻转是根据缩放中心轴进行的

PS:和位移(translate)一样,缩放也是可以叠加的。

1
2
    canvas.scale(0.5f,0.5f)
    canvas.scale(0.5f,0.1f)

调用两次缩放则 x轴实际缩放为0.5x0.5=0.25 y轴实际缩放为0.5x0.1=0.05

下面我们利用这一特性制作一个有趣的图形。

1
2
3
4
5
6
7
8
9
10
11
12
13
    val canvasWidth = canvas.width.toFloat()
    val canvasHeight= canvas.height.toFloat()
    //画布原点平移到中心
    canvas.translate(canvasWidth/2,canvasHeight/2)
    mPaint.style = Paint.Style.STROKE
    mPaint.setColor(Color.BLACK)
    mPaint.strokeWidth = 20f
    val rect = RectF(-400f, -400f, 400f, 400f) // 矩形区域

    for (i in 0..20) {
        canvas.scale(0.9f, 0.9f)
        canvas.drawRect(rect, mPaint)
    }

画布缩放demo

2.3 旋转(rotate)

旋转提供了两种方法:

1
2
3
    public void rotate (float degrees)
  
    public final void rotate (float degrees, float px, float py)

和缩放一样,第二种方法多出来的两个参数依旧是控制旋转中心点的。

默认的旋转中心依旧是坐标原点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    val canvasWidth = canvas.width.toFloat()
    val canvasHeight= canvas.height.toFloat()
    //画布原点平移到中心
    canvas.translate(canvasWidth/2,canvasHeight/2)
    //绘制坐标系
    mPaint.style = Paint.Style.STROKE
    mPaint.setColor(Color.RED)
    mPaint.strokeWidth = 4f
    canvas.drawLine(-canvasWidth/2,0f,canvasWidth/2,0f,mPaint)//x轴
    canvas.drawLine(0f,-canvasHeight/2,0f,canvasHeight/2,mPaint)//y轴

    val rect = RectF(0F, 0F, 200F, 200F)
    mPaint.setColor(Color.BLACK)
    canvas.drawRect(rect, mPaint)   //绘制黑色矩形
    //顺时针旋转45°
    canvas.rotate(45f)

    //绘制旋转后的绿色坐标轴
    mPaint.setColor(Color.GREEN)
    canvas.drawLine(-canvasWidth/2,0f,canvasWidth/2,0f,mPaint)//x轴
    canvas.drawLine(0f,-canvasHeight/2,0f,canvasHeight/2,mPaint)//y轴

    mPaint.setColor(Color.BLUE)
    canvas.drawRect(rect, mPaint)   //绘制蓝色矩形

画布旋转1

旋转前canvas是红色坐标系,旋转后canvas是绿色坐标系,可以看到canvas沿着旋转中心顺时针旋转了45度。

改变旋转中心位置:

1
    canvas.rotate(45f,100f,0f)  // 旋转45度 <-- 旋转中心向右偏移100个单位

画布旋转2

可以看到canvas是基于旋转中心旋转的。

PS:同样,旋转也是可以叠加的。

1
2
    canvas.rotate(180)
    canvas.rotate(20)

调用两次旋转,则实际的旋转角度为180+20=200度。

为了演示这一个效果,我们来做一个刻度盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    val canvasWidth = canvas.width.toFloat()
    val canvasHeight= canvas.height.toFloat()
    //画布原点平移到中心
    canvas.translate(canvasWidth/2,canvasHeight/2)
    mPaint.style = Paint.Style.STROKE
    mPaint.strokeWidth = 10f
    mPaint.setColor(Color.BLACK)
    canvas.drawCircle(0f,0f,400f,mPaint) //绘制表盘

    mPaint.strokeWidth = 4f
    val textPaint = TextPaint().apply {
        textSize = 18f
        isAntiAlias = true
        textAlign = Paint.Align.CENTER
    }
    for (i in 0..360 step 30) {
        canvas.drawLine(0f,-380f,0f,-400f,mPaint)   //绘制刻度
        if (i != 0) {
            canvas.save()
            canvas.rotate(-i.toFloat(),0f,-360f)    //逆时针旋转数字对应的角度 使数字全部正向
            canvas.drawText("${i/30}",0f,-360f,textPaint)   //绘制数字
            canvas.restore()
        }
        canvas.rotate(30f)
    }

画布旋转demo

2.4 错切(skew)

skew这里翻译为错切,错切是特殊类型的线性变换。

错切只提供了一种方法:

1
    public void skew (float sx, float sy)

参数含义:

  • float sx:将画布在x方向上倾斜相应的角度,sx倾斜角度的tan值.
  • float sy:将画布在y轴方向上倾斜相应的角度,sy为倾斜角度的tan值.

变换后的点(X,Y)和变换前的点(x,y)映射关系:

1
2
    X = x + sx * y
    Y = sy * x + y

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    val canvasWidth = canvas.width.toFloat()
    val canvasHeight= canvas.height.toFloat()
    //画布原点平移到中心
    canvas.translate(canvasWidth/2,canvasHeight/2)
    //绘制坐标系
    mPaint.style = Paint.Style.STROKE
    mPaint.setColor(Color.RED)
    mPaint.strokeWidth = 4f
    canvas.drawLine(-canvasWidth/2,0f,canvasWidth/2,0f,mPaint)//x轴
    canvas.drawLine(0f,-canvasHeight/2,0f,canvasHeight/2,mPaint)//y轴

    val rect = RectF(0F, 0F, 200F, 200F)
    mPaint.setColor(Color.BLACK)
    canvas.drawRect(rect, mPaint)   //绘制黑色矩形
    //画布往X方向倾斜45度
    canvas.skew(1f,0f)  // 水平错切 <- 45度

    mPaint.setColor(Color.BLUE)
    canvas.drawRect(rect, mPaint)   //绘制蓝色矩形

画布错切

通过上面说到的变化前后点位映射关系可以算出错切前的B(200,200),错且后的坐标为B’(400,200)。

3. 画布快照

3.1 快照(save)和回滚(restore)

Q: 为什么存在快照与回滚

A:画布的操作是不可逆的,而且很多画布操作会影响后续的步骤,例如第一个例子,两个圆形都是在坐标原点绘制的,而因为坐标系的移动绘制出来的实际位置不同。所以会对画布的一些状态进行保存和回滚。

与之相关的API:

相关API 简介
save 把当前的画布的状态进行保存,然后放入特定的栈中
saveLayerXxx 新建一个图层,并放入特定的栈中
restore 把栈中最顶层的画布状态取出来,并按照这个状态恢复当前的画布
restoreToCount 弹出指定位置及其以上所有的状态,并按照指定位置的状态进行恢复
getSaveCount 获取栈中内容的数量(即保存次数)

下面对其中的一些概念和方法进行分析:

  1. 画布状态:当前画布经过的一些列操作。
  2. 状态栈:存放画布状态和图层的栈,后进先出。

画布状态栈

  1. 画布的构成:由多个图层构成:

图层

所以有如下结论:

  • 在画布上操作 = 在图层上操作。
  • 如无设置,绘制操作和画布操作默认是在默认图层上进行。
  • 在通常情况下,使用默认图层可以满足需求;若需要绘制复杂的内容(比如地图),则需要使用更多的图层。
  • 最终显示的结果 = 所有图层叠在一起的效果。

SaveFlags

数据类型 名称 简介
int ALL_SAVE_FLAG 默认,保存全部状态
int CLIP_SAVE_FLAG 保存剪辑区
int CLIP_TO_LAYER_SAVE_FLAG 剪裁区作为图层保存
int FULL_COLOR_LAYER_SAVE_FLAG 保存图层的全部色彩通道
int HAS_ALPHA_LAYER_SAVE_FLAG 保存图层的alpha(不透明度)通道
int MATRIX_SAVE_FLAG 保存Matrix信息(translate, rotate, scale, skew)

save

save 有两种方法:

1
2
3
4
5
  // 保存全部状态
  public int save ()
  
  // 根据saveFlags参数保存一部分状态
  public int save (int saveFlags)

可以看到第二种方法比第一种多了一个saveFlags参数,使用这个参数可以只保存一部分状态,更加灵活,这个saveFlags参数具体可参考上面表格中的内容。

每调用一次save方法,都会在栈顶添加一条状态信息,以上面状态栈图片为例,再调用一次save则会在第5次上面载添加一条状态。

saveLayerXxx

saveLayerXxx有比较多的方法:

1
2
3
4
5
6
7
8
9
10
11
    // 无图层alpha(不透明度)通道
    public int saveLayer (RectF bounds, Paint paint)
    public int saveLayer (RectF bounds, Paint paint, int saveFlags)
    public int saveLayer (float left, float top, float right, float bottom, Paint paint)
    public int saveLayer (float left, float top, float right, float bottom, Paint paint, int saveFlags)

    // 有图层alpha(不透明度)通道
    public int saveLayerAlpha (RectF bounds, int alpha)
    public int saveLayerAlpha (RectF bounds, int alpha, int saveFlags)
    public int saveLayerAlpha (float left, float top, float right, float bottom, int alpha)
    public int saveLayerAlpha (float left, float top, float right, float bottom, int alpha, int saveFlags)

注意:saveLayerXxx方法会让你花费更多的时间去渲染图像(图层多了相互之间叠加会导致计算量成倍增长),使用前请谨慎,如果可能,尽量避免使用。

使用saveLayerXxx方法,也会将图层状态也放入状态栈中,同样使用restore方法进行恢复。

restore

状态回滚,就是从栈顶取出一个状态然后根据内容进行恢复。

同样以上面状态栈图片为例,调用一次restore方法则将状态栈中第5次取出,根据里面保存的状态进行状态恢复。

restoreToCount

弹出指定位置以及以上所有状态,并根据指定位置状态进行恢复。

以上面状态栈图片为例,如果调用restoreToCount(2) 则会弹出 2 3 4 5 的状态,并根据第2次保存的状态进行恢复。

getSaveCount

获取保存的次数,即状态栈中保存状态的数量,以上面状态栈图片为例,使用该函数的返回值为5。

不过请注意,该函数的最小返回值为1,即使弹出了所有的状态,返回值依旧为1,代表默认状态。

常用格式

虽然关于状态的保存和回滚啰嗦了不少,不过大多数情况下只需要记住下面的步骤就可以了:

1
2
3
   canvas.save()      //保存状态
   ...                //具体操作
   canvas.restore()   //回滚到之前的状态

这种方式也是最简单和最容易理解的使用方法。

4. 绘制颜色

文章开头讲到Canvas绘图有三个基本要素:Canvas绘图坐标系以及Paint,要想绘制内容,首先需要先创建一个画笔,Paint 类的几个最常用的方法如下:

1
2
3
4
5
6
7
8
    private val mPaint = Paint().apply {
        style = Paint.Style.STROKE  //设置绘制模式
        color = Color.BLACK //设置颜色
        strokeWidth = 4f    //设置线条宽度
        textSize = 18f      //设置文字大小
        isAntiAlias = true  //抗锯齿开关
        ...
    }

4.1 安卓支持的颜色模式

在讲绘制颜色前,我们先来了解下安卓支持的颜色模式:

颜色模式 备注
ARGB8888 四通道高精度(32位)
ARGB4444 四通道低精度(16位)
RGB565 屏幕默认模式(16位)
Alpha8 仅有透明通道(8位)

PS:其中字母表示通道类型,数值表示该类型用多少位二进制来描述。如ARGB8888则表示有四个通道(ARGB),每个对应的通道均用8位来描述。

注意:我们常用的是ARGB8888和ARGB4444,而在所有的安卓设备屏幕上默认的模式都是RGB565,请留意这一点。

以ARGB8888为例介绍颜色定义:

类型 解释 0(0x00) 255(0xff)
A(Alpha) 透明度 透明 不透明
R(Red) 红色 无色 红色
G(Green) 绿色 无色 绿色
B(Blue) 蓝色 无色 蓝色

其中 A R G B 的取值范围均为0~255(即16进制的0x00~0xff)

A 从0x00到0xff表示从透明到不透明。

RGB 从0x00到0xff表示颜色从浅到深。

当RGB全取最小值(0或0x000000)时颜色为黑色,全取最大值(255或0xffffff)时颜色为白色

4.2 几种创建或使用颜色的方式

1.java中定义颜色

1
  int color = Color.GRAY;     //灰色

由于Color类提供的颜色仅为有限的几个,通常还是用ARGB值进行表示。

1
2
3
  int color = Color.argb(127, 255, 0, 0);   //半透明红色
  
  int color = 0xaaff0000;                   //带有透明度的红色

2.在xml文件中定义颜色

在/res/values/color.xml 文件中如下定义:

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="red">#ff0000</color>
    <color name="green">#00ff00</color>
</resources>

详解: 在以上xml文件中定义了两个颜色,红色和蓝色,是没有alpha(透明)通道的。

定义颜色以‘#’开头,后面跟十六进制的值,有如下几种定义方式:

1
2
3
4
5
  #f00            //低精度 - 不带透明通道红色
  #af00           //低精度 - 带透明通道红色
  
  #ff0000         //高精度 - 不带透明通道红色
  #aaff0000       //高精度 - 带透明通道红色

3.在java文件中引用xml中定义的颜色:

1
2
3
  int color = getResources().getColor(R.color.mycolor);
  
  int color = getColor(R.color.myColor);    //API 23 及以上支持该方法

4.在xml文件(layout或style)中引用或者创建颜色

1
2
3
4
    <!--在style文件中引用-->
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="colorPrimary">@color/red</item>
    </style>
1
2
3
  android:background="@color/red"     //引用在/res/values/color.xml 中定义的颜色
  
  android:background="#ff0000"        //创建并使用颜色

4.3 颜色混合模式(Alpha通道相关)

通过前面介绍我们知道颜色一般都是四个通道(ARGB)的,其中(RGB)控制的是颜色,而A(Alpha)控制的是透明度。

因为我们的显示屏是没法透明的,因此最终显示在屏幕上的颜色里可以认为没有Alpha通道。Alpha通道主要在两个图像混合的时候生效。

默认情况下,当一个颜色绘制到Canvas上时的混合模式是这样计算的:

(RGB通道) 最终颜色 = 绘制的颜色 + (1 - 绘制颜色的透明度) × Canvas上的原有颜色。

注意:

1.这里我们一般把每个通道的取值从0(0x00)到255(0xff)映射到0到1的浮点数表示。

2.这里等式右边的“绘制的颜色”、“Canvas上的原有颜色”都是经过预乘了自己的Alpha通道的值。如绘制颜色:0x88ffffff,那么参与运算时的每个颜色通道的值不是1.0,而是(1.0 * 0.5333 = 0.5333)。 (其中0.5333 = 0x88/0xff)

使用这种方式的混合,就会造成后绘制的内容以半透明的方式叠在上面的视觉效果。

其实还可以有不同的混合模式供我们选择,用Paint.setXfermode,指定不同的PorterDuff.Mode,这个在我们后续讲paint的时候再细说,可以查看官方文档了解。

4.2 绘制颜色

相关API是drawColor,可以在整个绘制区域统一涂上指定的颜色,因为它没有指定范围,所以范围就是Canvas所绘制的范围。

一般用来绘制背景,或者绘制一个遮盖:

1
    canvas.drawColor(Color.BLUE)    //绘制蓝色

绘制颜色

5. 绘制基本形状

5.1 绘制点(drawPoint)

点的大小可以通过 paint.setStrokeWidth(width) 来设置;点的形状可以通过 paint.setStrokeCap(cap) 来设置:ROUND 画出来是圆形的点,SQUAREBUTT 画出来是方形的点。

注:Paint.setStrokeCap(cap) 可以设置点的形状,但这个方法并不是专门用来设置点的形状的,而是一个设置线条端点形状的方法。端点有圆头 (ROUND)、平头 (BUTT) 和方头 (SQUARE) 三种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.BLACK  //设置画笔颜色
    mPaint.strokeWidth = 20f    //设置画笔宽度为20px

    canvas.drawPoint(200f, 200f, mPaint)//在坐标(200,200)位置绘制一个点

    mPaint.strokeCap = Paint.Cap.ROUND  //绘制圆点

    canvas.drawPoints(
        //绘制一组点,坐标位置由float数组指定
        floatArrayOf(
            500f, 500f,
            500f, 600f,
            500f, 700f
        ), mPaint
    )

    canvas.drawPoints(
        //绘制一组点,坐标位置由float数组指定
        floatArrayOf(
            500f, 500f,
            500f, 600f,
            500f, 700f
        ), mPaint
    )

    mPaint.color = Color.RED
    canvas.drawPoints(
        //绘制一组点,坐标位置由float数组指定
        floatArrayOf(
            600f, 500f,
            600f, 600f,
            600f, 700f
        ),
        2,//跳过前两个数即600,500
        4,//一共绘制4个数(2个点)
        mPaint
    )

绘制点

5.2 绘制线(drawLine)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.BLACK  //设置画笔颜色
    mPaint.strokeWidth = 20f    //设置画笔宽度为20px

    //以(400,400)为起点,(700,700)为终点绘制一条直线
    canvas.drawLine(400f,400f,700f,700f,mPaint)

    //批量绘制
    canvas.drawLines(
        floatArrayOf(
            100f,800f,400f,800f,
            100f,900f,400f,900f
        ),mPaint
    )

    mPaint.color = Color.RED    //画笔设置为红色
    mPaint.strokeCap = Paint.Cap.ROUND  //端点设置为圆头
    canvas.drawLines(
        floatArrayOf(
            100f,800f,400f,800f,
            100f,900f,400f,900f,
            100f,1000f,400f,1000f,
        ),
        8,//跳过8个数
        4,//绘制4个数(一条线)
        mPaint
    )

绘制线

5.3 绘制矩形(drawRect)

我们都知道,确定一个矩形最少需要四个数据,就是对角线的两个点的坐标值,这里一般采用左上角和右下角的两个点的坐标

关于绘制矩形,Canvas提供了三种重载方法,第一种就是提供四个数值(矩形左上角和右下角两个点的坐标)来确定一个矩形进行绘制。 其余两种是先将矩形封装为Rect或RectF(实际上仍然是用两个坐标点来确定的矩形),然后传递给Canvas绘制,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.BLACK  //设置画笔颜色
    mPaint.strokeWidth = 20f    //设置画笔宽度为20px

    //第一种
    canvas.drawRect(100f,100f,600f,400f,mPaint)

    //第二种
    canvas.drawRect(
        Rect(100,500,600,800),
        mPaint
    )

    //第三种
    canvas.drawRect(
        RectF(100f,900f,600f,1200f),
        mPaint
    )

    mPaint.style = Paint.Style.STROKE //设置描边模式
    canvas.drawRect(100f,1300f,600f,1600f,mPaint)

绘制矩形

5.4 绘制圆角矩形(drawRoundRect)

绘制圆角矩形也提供了两种重载方式,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.BLACK  //设置画笔颜色
    mPaint.strokeWidth = 20f    //设置画笔宽度为20px

    //第一种 left, top, right, bottom 是四条边的坐标,rx 和 ry 是圆角的横向半径和纵向半径
    canvas.drawRoundRect(100f,100f,600f,400f,30f,30f,mPaint)

    //第二种
    canvas.drawRoundRect(
        RectF(100f,500f,600f,800f),
        30f,30f,mPaint)

    mPaint.style = Paint.Style.STROKE //设置描边模式
    canvas.drawRoundRect(100f,900f,600f,1200f,30f,30f,mPaint)

绘制圆角矩形

下面简单解析一下圆角矩形多出来的 rxry 参数的意思,如下图所示:

红线标注的 rxry 就是两个半径,也就是相比绘制矩形多出来的那两个参数,这里圆角矩形的角实际上不是一个正圆的圆弧,而是椭圆的圆弧,这里的两个参数实际上是椭圆的两个半径,实际上在rx为宽度的一半,ry为高度的一半时,我们绘制出来的圆角矩形刚好就是一个椭圆。

绘制圆角矩形rxry参数

5.5 绘制椭圆(drawOval)

绘制椭圆实际上就是绘制一个矩形的内切图形,所以只需要一个矩形作为参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.BLACK  //设置画笔颜色
    mPaint.strokeWidth = 20f    //设置画笔宽度为20px

    //第一种 left, top, right, bottom 是这个椭圆的左、上、右、下四个边界点的坐标
    canvas.drawOval(100f,100f,600f,400f,mPaint)

    //第二种
    canvas.drawOval(
        RectF(100f,500f,600f,800f),
        mPaint
    )

    mPaint.style = Paint.Style.STROKE //设置描边模式
    canvas.drawOval(100f,900f,600f,1200f,mPaint)

绘制椭圆

PS: 如果你传递进来的是一个长宽相等的矩形(即正方形),那么绘制出来的实际上就是一个圆

5.6 绘制圆(drawCircle)

1
2
3
4
5
6
7
8
9
10
    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.BLACK  //设置画笔颜色
    mPaint.strokeWidth = 20f    //设置画笔宽度为20px

    // 绘制一个圆心坐标在(500,500),半径为300 的圆。
    canvas.drawCircle(500f,500f,300f,mPaint)

    mPaint.style = Paint.Style.STROKE //设置描边模式
    //画笔为描边模式绘制出来的是一个圆环
    canvas.drawCircle(500f,1200f,300f,mPaint)

绘制圆

5.7 绘制圆弧(drawArc)

1
2
3
4
5
// 第一种
public void drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter, @NonNull Paint paint)
    
// 第二种
public void drawArc(float left, float top, float right, float bottom, float startAngle,float sweepAngle, boolean useCenter, @NonNull Paint paint)

drawArc() 是使用一个椭圆来描述弧形的。left, top, right, bottom 描述的是这个弧形所在的椭圆;startAngle 是弧形的起始角度(x 轴的正向,即正右的方向,是 0 度的位置;顺时针为正角度,逆时针为负角度),sweepAngle 是弧形划过的角度;useCenter 表示是否连接到圆心,如果不连接到圆心,就是弧形,如果连接到圆心,就是扇形。

1
2
3
4
5
6
7
8
9
    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.BLACK  //设置画笔颜色
    mPaint.strokeWidth = 20f    //设置画笔宽度为20px

    canvas.drawArc(200f, 100f, 800f, 500f, -90f, 90f, true, mPaint) // 绘制扇形
    canvas.drawArc(200f, 100f, 800f, 500f, 20f, 90f, false, mPaint) // 绘制弧形

    mPaint.style = Paint.Style.STROKE // 描边模式
    canvas.drawArc(200f, 100f, 800f, 500f, 180f, 60f, false, mPaint) // 绘制不封口的弧形

绘制圆弧

6. 绘制图片

6.1 drawBitmap

先预览一下drawBitmap的常用方法:

1
2
3
4
5
6
7
8
9
    // 第一种
    public void drawBitmap (Bitmap bitmap, Matrix matrix, Paint paint)
    
    // 第二种
    public void drawBitmap (Bitmap bitmap, float left, float top, Paint paint)
    
    // 第三种
    public void drawBitmap (Bitmap bitmap, Rect src, Rect dst, Paint paint)
    public void drawBitmap (Bitmap bitmap, Rect src, RectF dst, Paint paint)

第一种方法中后两个参数(matrix, paint)是在绘制的时候对图片进行一些改变,如何改变我们后面会讲到,如果只是需要将图片内容绘制出来只需要如下操作就可以了:

1
2
3
    val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.beauty)
    //第一种
    canvas.drawBitmap(bitmap,Matrix(),mPaint)

绘制bitmap1

第二种方法就是在绘制时指定了图片左上角的坐标(距离绘图坐标系坐标原点的距离):

1
2
3
    val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.beauty)
    //第二种
    canvas.drawBitmap(bitmap,200f,200f,mPaint)

绘制bitmap2

第三种方法比较有意思,这里有2个Rect参数,其中src表示需要被绘制Bitmap的区域,即从Bitmap上取出需要绘制的区域;而dst则表示显示的区域,如果src规定的绘制区域大于dst的区域,图片大小会被缩放。

1
2
3
4
    val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.beauty)
    val src = Rect(0,0,bitmap.width,bitmap.height/2)    //指定图片绘制区域(图片上半部分)
    val dst = Rect(200,200,200+bitmap.width,200+bitmap.height/2)    //指定图片在屏幕上显示的区域
    canvas.drawBitmap(bitmap,src,dst,mPaint)    //绘制Bitmap

绘制bitmap3

从上面可知,第三种方法可以绘制图片的一部分到画布上,这有什么用呢?

如果你看过某些游戏的资源文件,你可能会看到如下的图片(图片来自网络):

boom

用一张图片包含了大量的素材,在绘制的时候每次只截取一部分进行绘制,这样可以大大的减少素材数量,而且素材管理起来也很方便。

在某些时候我们需要制作一些炫酷的效果,这些效果因为太复杂了用代码很难实现或者渲染效率不高。这时候很多人就会想起帧动画,将动画分解成一张一张的图片然后使用帧动画制作出来,这种实现方式的确比较简单,但是一个动画效果的图片有十几到几十张,一个应用里面来几个这样炫酷的动画效果就会导致资源文件出现一大堆,想找其中的某一张资源图片简直就是灾难。但是把同一个动画效果的所有资源图片整理到一张图片上,会大大的减少资源文件数量,方便管理,同时也节省了图片文件头、文件结束块以及调色板等占用的空间。

我们以上面的爆炸效果图片利用drawBitmap第三种方法制作的一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    private val boomBitmap = BitmapFactory.decodeResource(resources, R.mipmap.boom)
    private var level = 0   //最开始处于爆炸的第0片段
    private var frame = 0

    private fun drawBitmapDemo(canvas: Canvas) {
        frame++
        if(level >= 14)return
        //每两帧绘制一次爆炸
        if (frame % 2 == 0) {
            //爆炸效果由14个片段组成 算出每个片段的宽度
            val segmentWidth = boomBitmap.width / 14
            val left = level * segmentWidth

            val src = Rect(left,0,left+segmentWidth,boomBitmap.height)
            val dst = Rect(300,300,300+segmentWidth,300+boomBitmap.height)
            canvas.drawBitmap(boomBitmap,src,dst,mPaint)
            level++
        }
        invalidate()
    }

boom2

6.2 drawPicture

绘制矢量图的内容,即绘制存储在矢量图里某个时刻Canvas绘制内容的操作。

这里就涉及到一个类叫做Picture,它的作用是存储某个时刻Canvas绘制内容的操作,然后在使用时就使用这个Picture即可,它相比于再次调用各种绘图API,会节省操作和时间。

PS:你可以把Picture看作是一个录制Canvas操作的录像机,录制完后我们可以通过Canvas将这段录制类容绘制出来。

了解了Picture的概念之后,我们再了解一下Picture的相关方法。

相关方法 简介
public int getWidth () 获取宽度
public int getHeight () 获取高度
public Canvas beginRecording (int width, int height) 开始录制 (返回一个Canvas,在Canvas中所有的绘制都会存储在Picture中)
public void endRecording () 结束录制
public void draw (Canvas canvas) 将Picture中内容绘制到Canvas中
public static Picture createFromStream (InputStream stream) (已废弃)通过输入流创建一个Picture
public void writeToStream (OutputStream stream) (已废弃)将Picture中内容写出到输出流中

drawPicture()方法在API23以上才会有硬件加速支持,如果在API23以下请关闭硬件加速,以免引起不必要的问题!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

    /**
     * 返回一个录制了内容的Picture
     */
    private fun recording(): Picture {
        // 创建Picture对象
        val picture = Picture()
        // 开始录制 (接收返回值Canvas)
        val canvas = picture.beginRecording(500, 500)

        // 创建一个画笔
        // 创建一个画笔
        val paint = Paint().apply {
            style = Paint.Style.FILL
            color = Color.BLUE
        }

        // 在Canvas中具体操作
        // 位移
//        canvas.translate(250f,250f)
        // 绘制一个圆
        canvas.drawCircle(0f,0f,100f,paint)
        //结束录制
        picture.endRecording()
        return picture
    }

    /**
     *  drawPicture
     */
    private fun drawPicture(canvas: Canvas) {
        val picture = recording()
        //1. 直接绘制
        canvas.translate(250f,250f)
        canvas.drawPicture(picture)

        //2. 绘制到目标矩形  可以看到绘制内容根据矩形区域被放大了一倍
        canvas.translate(250f,250f)
        canvas.drawPicture(
            picture,
            RectF(0f,0f,picture.width.toFloat()*2,picture.width.toFloat() * 2)
        )
    }

绘制picture

7. 绘制文本

Canvas 的文字绘制方法有四个:drawText()drawPosText()drawTextRun()drawTextOnPath(),(drawPosText()方法已被废弃,这里不做讨论)。

7.1 drawText

1
2
3
4
5
    //drawText有4个重载方法
    public void drawText (String text, float x, float y, Paint paint)
    public void drawText (String text, int start, int end, float x, float y, Paint paint)
    public void drawText (CharSequence text, int start, int end, float x, float y, Paint paint)
    public void drawText (char[] text, int index, int count, float x, float y, Paint paint)

下面我们来分别看一下它们的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    // 文本(要绘制的内容)
    val str = "abcdefghijk"
    mTextPaint.textSize = 40f
    // 参数分别为 (文本 基线x 基线y 画笔)
    canvas.drawText(str,200f,200f,mTextPaint)

    mPaint.style = Paint.Style.FILL //设置填充模式
    mPaint.color = Color.RED  //设置画笔颜色
    canvas.drawLine(200f,200f,800f,200f,mPaint)  //绘制基线

    mTextPaint.textSize = 60f
    // 参数分别为 (字符串 开始截取位置 结束截取位置 基线x 基线y 画笔)
    canvas.drawText(str,1,7,200f,400f,mTextPaint)
    canvas.drawLine(200f,400f,800f,400f,mPaint)  //绘制基线

    // 字符数组(要绘制的内容)
    val chars = "abcdefghijk".toCharArray()
    mTextPaint.textSize = 80f
    // 参数为 (字符数组 起始坐标 截取长度 基线x 基线y 画笔)
    canvas.drawText(chars,1,7,200f,600f,mTextPaint)
    canvas.drawLine(200f,600f,800f,600f,mPaint)  //绘制基线

绘制文本drawText

上面代码很简单,注释也很详细,这里我们说一下drawText方法中的 xy(途中红线起始点位置),我们之前讲过的其它的 Canvas.drawXXX() 方法,都是以左上角作为基准点的,为什么 drawText() 的基准点却是文字左下方?

众所周知,不同的语言和文字,每个字符的高度和上下位置都是不一样的。要让不同的文字并排显示的时候整体看起来稳当,需要让它们上下对齐。但这个对齐的方式,不能是简单的「底部对齐」或「顶部对齐」或「中间对齐」,而应该是一种类似于「重心对齐」的方式。

而这个用来让所有文字互相对齐的基准线,就是基线( baseline )。 drawText() 方法参数中的 y 值,就是指定的基线的位置(图中红色的线)。

7.2 drawTextRun

drawTextRun() 是在 API 23 新加入的方法。它和 drawText() 一样都是绘制文字,但加入了两项额外的设置:上下文和文字方向。用于辅助一些文字结构比较特殊的语言的绘制(例如阿拉伯语根据它相邻的两个文字的不同会拥有不同的形状),PS:这个方法对中国人没啥用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    val str = "ABCDEFG"
    // text:要绘制的文字
    // start:从那个字开始绘制
    // end:绘制到哪个字结束
    // contextStart:上下文的起始位置。
    // contextStart 需要小于等于 start
    // contextEnd:上下文的结束位置。contextEnd 需要大于等于 end
    // x:文字左边的坐标
    // y:文字的基线坐标
    // isRtl:是否是 RTL(Right-To-Left,从右向左)
    
    //从左到右
    canvas.drawTextRun(str,2,4,0,6,200f,200f,false,mTextPaint)
    //从右到左
    canvas.drawTextRun(str,2,4,0,6,200f,400f,true,mTextPaint)

绘制文本drawTextRun

7.3 drawTextOnPath

这里涉及到Path类的使用,后面细说,简单来说就是指定一条path,然后在该path上绘制文字,前面我们所有绘制的文字都是按照X/Y坐标轴基线来绘制的,具体方法很简单drawTextOnPath(String text, Path path, float hOffset, float vOffset, Paint paint),测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
    //创建path对象
    val path = Path().apply {
        //移动到点(200,200)
        moveTo(200f,200f)
        //设置path轨迹 3次贝塞尔曲线
        cubicTo(300f,500f,400f,300f,500f,500f)
    }
    mPaint.color = Color.RED
    //绘制path
    canvas.drawPath(path,mPaint)
    //根据path绘制文本 其中hOffset和vOffset它们是文字相对于 Path 的水平偏移量和竖直偏移量
    canvas.drawTextOnPath("在path上绘制文本",path,50f,0f,mTextPaint)

绘制文本drawTextOnPath

8. 绘制路径(drawPath)

前面讲了 Canvas 所有的简单图形的绘制。除了简单图形的绘制, Canvas 还可以使用 drawPath(Path path, Paint paint) 来根据自定义路径绘制自定义图形。

绘制路径比较复杂点,涉及到Path类的使用。Path的定义就是路径,即无数个点连起来的线,作用就是用于描述路径,可以直接描述直线、二次曲线、三次曲线等。Path有俩类方法,一类是直接描述路径,一类是辅助的设置或计算。下面我们来看下 Path 中常用的方法:

作用 相关方法 备注
移动起点 moveTo 移动下一次操作的起点位置
设置终点 setLastPoint 重置当前path中最后一个点位置,如果在绘制之前调用,效果和moveTo相同
连接直线 lineTo 添加上一个点到当前点之间的直线到Path
闭合路径 close 连接第一个点连接到最后一个点,形成一个闭合区域
添加内容 addRect, addRoundRect, addOval, addCircle, addPath, addArc, arcTo 添加(矩形, 圆角矩形, 椭圆, 圆, 路径, 圆弧) 到当前Path (注意addArc和arcTo的区别)
是否为空 isEmpty 判断Path是否为空
是否为矩形 isRect 判断path是否是一个矩形
替换路径 set 用新的路径替换到当前路径所有内容
偏移路径 offset 对当前路径之前的操作进行偏移(不会影响之后的操作)
贝塞尔曲线 quadTo, cubicTo 分别为二次和三次贝塞尔曲线的方法
rXxx方法 rMoveTo, rLineTo, rQuadTo, rCubicTo 不带r的方法是基于原点的坐标系(偏移量), rXxx方法是基于当前点坐标系(偏移量)
填充模式 setFillType, getFillType, isInverseFillType, toggleInverseFillType 设置,获取,判断和切换填充模式
提示方法 incReserve 提示Path还有多少个点等待加入(这个方法貌似会让Path优化存储结构)
布尔操作(API19) op 对两个Path进行布尔运算(即取交集、并集等操作)
计算边界 computeBounds 计算Path的边界
重置路径 reset, rewind 清除Path中的内容
reset不保留内部数据结构,但会保留FillType.
rewind会保留内部的数据结构,但不保留FillType
矩阵操作 transform 矩阵变换

8.1 画直线lineTo()、rLineTo()

lineTo 就是从当前位置向目标位置画一条直线,默认起点是画布原点。还有就是 rLineTo 方法,这个方法中的坐标是当前位置的相对坐标,其中r就是relatively的意思,来看下面测试代码:

1
2
3
4
5
6
7
8
    mPaint.color = Color.BLACK
    val path = Path().apply {
        lineTo(200f,200f)   //从原点连线到点(200,200)
        lineTo(200f,0f)     //从点(200,200)连线到点(200,0)

        rLineTo(200f,200f)//以点(200,0)为原点 连线到点(200,200)
    }
    canvas.drawPath(path,mPaint)

绘制路径lineTo

8.2 moveTo()、rMoveTo()

可以看到我们前面画线都是以当前位置作为起点,而不能指定起点,这里可以通过moveTo()或者rMoveTo()方法来改变当前画笔要绘制的绘制:

1
2
3
4
5
6
7
8
9
10
11
12
    val path = Path().apply {
        lineTo(200f,200f) //从原点连线到点(200,200)

        moveTo(200f,-0f)  //移动到点(200,0)

        lineTo(200f,200f) //从点(200,200)连线到点(200,0)

        rMoveTo(200f,0f)  //以点(200,200)为原点 移动到点(200,0)

        rLineTo(0f,-200f) //以点(200,0)为原点 连线到点(0,-200)
    }
    canvas.drawPath(path,mPaint)

绘制路径moveTo

8.3 画曲线quadTo()、cubicTo()

方法预览:

1
2
3
4
 public void quadTo(float x1, float y1, float x2, float y2)

 public void cubicTo(float x1, float y1, float x2, float y2,
                        float x3, float y3)

quadTo()cubicTo() 分别用于绘制二阶贝塞尔曲线和三阶贝塞尔曲线。贝塞尔曲线是几何上的一种曲线。它通过起点、控制点和终点来描述一条曲线,主要用于计算机图形学。后面我们会单独讲贝塞尔曲线,这儿只简单的看一下api的用法:

1
2
3
4
5
6
7
    val path = Path().apply {
        //从原点绘制贝塞尔曲线,前后2个坐标分别是控制点,和终点
        quadTo(100f,100f,200f,0f)
        //以终点为相对原点,再绘制
        rQuadTo(100f,-100f,200f,0f)
    }
    canvas.drawPath(path,mPaint)

绘制路径quadTo

cubicTo()这个和绘制二阶贝塞尔曲线类似,只不过参数会多一个控制点,这里不再赘述。

8.4 画弧形arcTo()、addArc()

方法预览:

1
2
3
4
5
public void arcTo(@NonNull RectF oval, float startAngle, float sweepAngle)
public void arcTo(@NonNull RectF oval, float startAngle, float sweepAngle,
                      boolean forceMoveTo)

public void addArc(@NonNull RectF oval, float startAngle, float sweepAngle)

可以看到上面方法参数基本一样,只是arcTo有一个重载方法多了个forceMoveTo参数,而多出来的这个 forceMoveTo参数的意思是,绘制是要「抬一下笔移动过去」,还是「直接拖着笔过去」,区别在于是否留下移动的痕迹。

1
2
3
4
5
6
7
8
9
10
11
    //绘制圆弧
    val path = Path().apply {
        lineTo(100f, 100f)
        arcTo(
            RectF(100f, 100f, 300f, 300f),
            -90f, 90f, true
        ) // 强制移动到弧形起点(无痕迹)

//            addArc(RectF(100f, 100f, 300f, 300f), -90f, 90f)// <-- 和上面一句作用等价
    }
    canvas.drawPath(path, mPaint)

绘制路径arcTo

上面arcTo的最后一个参数设置为false的效果:

绘制路径arcTo2

可以看到addArc()其实只是一个直接使用了forceMoveTo = true的简化版arcTo()

8.5 闭合路径close()

close方法用于连接当前最后一个点和最初的一个点(如果两个点不重合的话),最终形成一个封闭的图形。

1
2
3
4
5
6
    val path = Path().apply {
        lineTo(200f,200f)   //从原点连线到点(200,200)
        lineTo(200f,0f)     //从点(200,200)连线到点(200,0)

        close() //闭合路径
    }

绘制路径close

很明显,两个lineTo分别代表第1和第2条线,而close在此处的作用就算连接了B(200,0)点和原点O之间的第3条线,使之形成一个封闭的图形。

PS:close的作用是封闭路径,与连接当前最后一个点和第一个点并不等价。如果连接了最后一个点和第一个点仍然无法形成封闭图形,则close什么 也不做。另外,不是所有的子图形都需要使用 close()来封闭。当需要填充图形时(即 Paint.StyleFILLFILL_AND_STROKE),Path 会自动封闭子图形。

8.6 画子图形addXxx()

方法预览:

1
2
3
4
5
6
7
8
9
10
    // 圆形
    public void addCircle (float x, float y, float radius, Path.Direction dir)
    // 椭圆
    public void addOval (RectF oval, Path.Direction dir)
    // 矩形
    public void addRect (float left, float top, float right, float bottom, Path.Direction dir)
    public void addRect (RectF rect, Path.Direction dir)
    // 圆角矩形
    public void addRoundRect (RectF rect, float[] radii, Path.Direction dir)
    public void addRoundRect (RectF rect, float rx, float ry, Path.Direction dir)

这一类就是在path中添加一个基本形状,基本形状部分和前面canvas所讲的绘制基本形状并无太大差别,可以添加圆、椭圆、矩形和圆角矩形等,本次只将其中不同的部分摘出来详细讲解一下。

仔细观察上面的几个方法,无一例外,在最后都多了一个 Path.Direction 参数,这个参数代表的是路径方向。 Path.Direction 是一个枚举,里面定义了两种路径方向:顺时针 (CW clockwise) 和逆时针 (CCW counter-clockwise)。

这两种方向有什么影响呢?

  • 1.在添加图形时确定闭合顺序(各个点的记录顺序)
  • 2.对图形的渲染结果有影响(是判断图形渲染的重要条件)

闭合顺序影响:

1
2
3
4
    val path = Path().apply {
        addRect(-200f,-200f,200f,200f,Path.Direction.CW) //添加一个矩形
    }
    canvas.drawPath(path, mPaint)

绘制路径addRect01

上面添加了一个矩形,我们通过 setLastPoint 方法来改变最后一个点的位置,分别看下顺时针方向和逆时针方向有什么区别。

首先是顺时针的路径方向:

1
2
3
4
5
    val path = Path().apply {
        addRect(-200f,-200f,200f,200f,Path.Direction.CW) //添加一个矩形 路径顺时针方向
        setLastPoint(-300f,300f) //更改最后一个点(D点)位置
    }
    canvas.drawPath(path, mPaint)

绘制路径addRect02

逆时针方向:

1
2
3
4
5
    val path = Path().apply {
        addRect(-200f,-200f,200f,200f,Path.Direction.CCW) //添加一个矩形 路径逆时针方向
        setLastPoint(-300f,300f) //更改最后一个点(D点)位置
    }
    canvas.drawPath(path, mPaint)

绘制路径addRect03

通过上面的例子可以明确的看到顺时针方向和逆时针方向点的位置不同。图形在实际记录中就是记录各个的点,对于一个图形来说肯定有多个点,既然有这么多的点,肯定就需要一个先后顺序,这里顺时针和逆时针就是用来确定记录这些点的顺序的。

渲染结果影响

setFillType()

8.7 设置填充类型setFillType()

我们在上面了解到,Paint有三种样式,“描边” “填充” 以及 “描边加填充”,这里将要讲的就是在Paint设置为后两种样式时不同的填充模式对图形渲染效果的影响

我们要给一个图形内部填充颜色,首先需要分清哪一部分是外部,哪一部分是内部,机器不像我们人那么聪明,机器是如何判断内外呢?

PS:此处所有的图形均为封闭图形,不包括图形不封闭这种情况。

方法 判定条件 解释
奇偶规则 奇数表示在图形内,偶数表示在图形外 从任意位置p作一条射线, 若与该射线相交的图形边的数目为奇数,则p是图形内部点,否则是外部点。
非零环绕数规则 若环绕数为0表示在图形外,非零表示在图形内 首先使图形的边变为矢量。将环绕数初始化为零。再从任意位置p作一条射线。当从p点沿射线方向移动时,对在每个方向上穿过射线的边计数,每当图形的边从右到左穿过射线时,环绕数加1,从左到右时,环绕数减1。处理完图形的所有相关边之后,若环绕数为非零,则p为内部点,否则,p是外部点。

接下来我们先了解一下两种判断方法是如何工作的。

1. 奇偶规则(Even-Odd Rule)

这一个比较简单,也容易理解,直接用一个简单示例来说明。

绘制路径-填充模式-奇偶规则

在上图中有一个四边形,我们选取了三个点来判断这些点是否在图形内部。

P1: 从P1发出一条射线,发现图形与该射线相交边数为0,偶数,故P1点在图形外部。
P2: 从P2发出一条射线,发现图形与该射线相交边数为1,奇数,故P2点在图形内部。
P3: 从P3发出一条射线,发现图形与该射线相交边数为2,偶数,故P3点在图形外部。

2. 非零环绕数规则(Non-Zero Winding Number Rule)

从上面内容我们了解到,在给Path中添加图形时需要指定图形的添加方式,是用顺时针还是逆时针,另外我们不论是使用lineToquadTocubicTo还是其他连接线的方法,都是从一个点连接到另一个点,换言之,Path中任何线段都是有方向性的,这是使用非零环绕数规则的基础。

我们依旧用一个简单的例子来说明非零环绕数规则的用法:

PS: 注意图形中线段的方向性!

绘制路径-填充模式-非零环绕数规则

P1: 从P1点发出一条射线,沿射线方向移动,并没有与边相交点部分,环绕数为0,故P1在图形外边。
P2: 从P2点发出一条射线,沿射线方向移动,与图形点左侧边相交,该边从左到右穿过射线,环绕数-1,最终环绕数为-1,故P2在图形内部。
P3: 从P3点发出一条射线,沿射线方向移动,在第一个交点处,底边从右到左穿过射线,环绕数+1,在第二个交点处,右侧边从左到右穿过射线,环绕数-1,最终环绕数为0,故P3在图形外部。

填充模式的分类

填充模式有四种,是封装在Path类中的一个枚举:

模式 简介
EVEN_ODD 奇偶规则
WINDING 非零环绕数规则
INVERSE_EVEN_ODD 反奇偶规则
INVERSE_WINDING 反非零环绕数规则

其中后面的两个带有 INVERSE_ 前缀的,只是前两个的反色版本,所以只要把前两个,即 EVEN_ODDWINDING,搞明白就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    mPaint.style = Paint.Style.FILL //设置画笔为填充模式
    val path = Path().apply {
        fillType = Path.FillType.EVEN_ODD//设置填充类型为奇偶规则
//          fillType = Path.FillType.WINDING//设置填充类型为非零环绕规则

        //画一个五角星
        moveTo(0f,-130f)

        lineTo(-70f,110f)
        lineTo(110f,-30f)
        lineTo(-110f,-30f)
        lineTo(70f,110f)
        lineTo(0f,-130f)
    }
    canvas.drawPath(path, mPaint)

奇偶规则效果:

绘制路径-填充类型-奇偶规则demo

非零环绕规则效果:

绘制路径-填充类型-非零环绕规则demo

上面我们已经讲过这两种规则下判断图形内部的方式(内部才需要填充),通常情况下这两种方法的判断结果是相同的,但是相交图形则不一定,例如上面的例子和下图:

相交图形的奇偶和非零规则填充模式

前面讲添加子图形时说过 Path.Direction 参数中顺时针和逆时针会对图形的渲染结果有影响,从上面的图中可以出来,相交图形填充模式为 WINDING 时,图形边的方向会对非零奇偶环绕数规则填充结果有影响,继而会影响渲染的结果。

8.8 布尔操作op()

布尔操作是两个Path之间的运算,主要作用是用一些简单的图形通过一些规则合成一些相对比较复杂,或难以直接得到的图形。与我们中学所学的集合操作非常像,只要知道集合操作中的交集,并集,差集等操作,那么理解布尔操作也是很容易的。

方法预览:

1
2
3
4
5
6
7
8
9
    //方法预览
    boolean op (Path path, Path.Op op)
    boolean op (Path path1, Path path2, Path.Op op)

    //方法使用
    // 对 path1 和 path2 执行布尔运算,运算方式由第二个参数指定,运算结果存入到path1中。
    path1.op(path2, Path.Op.DIFFERENCE); 
    // 对 path1 和 path2 执行布尔运算,运算方式由第三个参数指定,运算结果存入到path3中。
    path3.op(path1, path2, Path.Op.DIFFERENCE)

Path的布尔运算有五种逻辑,如下:

逻辑名称 类比 说明 示意图
DIFFERENCE 差集 Path1中减去Path2后剩下的部分 DIFFERENCE
REVERSE_DIFFERENCE 差集 Path2中减去Path1后剩下的部分 REVERSE_DIFFERENCE
INTERSECT 交集 Path1与Path2相交的部分 INTERSECT
UNION 并集 包含全部Path1和Path2 UNION
XOR 异或 包含Path1与Path2但不包括两者相交的部分 XOR

布尔运算示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
    val x = 80f
    val r = 100f
    val pathOpResult  = Path()
    val path1 = Path()
    val path2 = Path()

    mPaint.style = Paint.Style.FILL
    canvas.translate(250f,0f)

    path1.addCircle(-x, 0f, r, Path.Direction.CW)
    path2.addCircle(x, 0f, r, Path.Direction.CW)

    pathOpResult.op(path1,path2, Path.Op.DIFFERENCE)
    canvas.translate(0f, 200f)
    canvas.drawText("DIFFERENCE", 240f,0f,mTextPaint)
    canvas.drawPath(pathOpResult,mPaint)

    pathOpResult.op(path1,path2, Path.Op.REVERSE_DIFFERENCE)
    canvas.translate(0f, 300f)
    canvas.drawText("REVERSE_DIFFERENCE", 240f,0f,mTextPaint)
    canvas.drawPath(pathOpResult,mPaint)

    pathOpResult.op(path1,path2, Path.Op.INTERSECT)
    canvas.translate(0f, 300f)
    canvas.drawText("INTERSECT", 240f,0f,mTextPaint)
    canvas.drawPath(pathOpResult,mPaint)

    pathOpResult.op(path1,path2, Path.Op.UNION)
    canvas.translate(0f, 300f)
    canvas.drawText("UNION", 240f,0f,mTextPaint)
    canvas.drawPath(pathOpResult,mPaint)

    pathOpResult.op(path1,path2, Path.Op.XOR)
    canvas.translate(0f, 300f)
    canvas.drawText("XOR", 240f,0f,mTextPaint)
    canvas.drawPath(pathOpResult,mPaint)

绘制路径op

8.9 边界计算computeBounds()

方法预览:

1
2
    //bounds: 测量结果会放入这个矩形 exact:参数已经废弃 随意填写
    void computeBounds (RectF bounds, boolean exact)

计算path边界的一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
    mPaint.style = Paint.Style.FILL
    val rect = RectF() // 存放测量结果的矩形
    val path = Path().apply {
        addCircle(0f,0f,100f,Path.Direction.CW)
        addCircle(90f,0f,100f,Path.Direction.CW)
        computeBounds(rect,true)    // 测量Path
    }
    canvas.drawPath(path,mPaint)   // 绘制Path

    mPaint.style = Paint.Style.STROKE
    mPaint.color = Color.RED
    canvas.drawRect(rect,mPaint)    // 绘制边界

绘制路径computeBounds

8.10 重置路径reset()、rewind()

重置Path有两个方法,分别是reset和rewind,两者区别主要有以下两点:

方法 是否保留FillType设置 是否保留原有数据结构
reset
rewind
  • reset 会清除path上所有直线、曲线并且不保留数据结构,但不会清除FillType。
  • rewind 会清除path上所有直线、曲线并且保留数据结构,以便更快的重用,同时也会清除FillType设置。

四、总结

canvas绘图基础就讲到这里了接下来我们讲一下paint