文章

Android 进阶 - Canvas 高级绘制

Canvas 是 Android 系统提供的较为底层的绘图 API,常用于自定义 View,在 Wear OS 中更是表盘绘制的必备。Canvas 提供了 drawXXX() 系列方法,配合画笔属性可以画出诸多图形,这些 API 命名清晰,基本可以顾名思义。

既然标题叫「高级绘制」,对于那些比较基本的简单的方法这里就不赘述了。

Paint

路径效果

比如我们要绘制一个仪表盘:

不难看出由三部分组成:边框、刻度、指针。其中边框与指针都可以比较轻松地画出来,但刻度缺比较头疼。有一个粗暴的办法:自己用三角函数计算每一格的起止坐标,套一个 for 循环绘制,但这样费时费力而且效率也比较低。

Paint.pathEffect 专门用于这种需求,它有点类似 PS 中的笔刷,只需给出一个样式,它就能按照 path 的形状和指定的间隔不断重复。显然,我们的刻度是一个矩形,所以只需定义一个矩形的 Path(无需考虑旋转),然后创建 PathDashPathEffect 就行了。

val markShapePath = Path().apply {
  addRect(0f, 0f, MARK_WIDTH, MARK_LENGTH, Path.Direction.CCW)
}
val pathEffect = PathDashPathEffect(
  markShapePath,
  (pathMeasure.length - MARK_WIDTH) / (MARK_COUNT - 1),
  0f,
  PathDashPathEffect.Style.ROTATE // 设为自动旋转
)
paint.pathEffect = pathEffect
canvas.drawPath(path, paint)

比较坑的一点是 PathDashPathEffectadvancephase 参数 Google 居然能写反 -.-

PathEffect 有许多子类,按需使用。

Xfermode

Xfermode 读作 Transfermode,这是老外的一种迷之缩写

目前 Android 上的 Xfermode 只剩一种就是 PorterDuffXfermode,用于定义两个颜色叠加时如何混合。官方文档的示例图完美解释了各个用法。(Destination image 是已有的图像,Source image 是要画上去的图像)

Xfermode 不止识别 Canvas 的内容,也识别下面所有的 View 或其他东西。因此为了避免这些干扰,通常要把 OffscreenBuffer 与 Xfermode 一起使用。

// 效果:圆形头像
private val XFERMODE = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
override fun onDraw(canvas: Canvas) {
  val count = canvas.saveLayer(bounds, null) // 创建 bounds 大小的 OffScreenBuffer
  canvas.drawOval(0f, 0f, IMAGE_WIDTH, IMAGE_WIDTH, paint)
  paint.xfermode = XFERMODE // 使用 SRC_IN 混合模式
  canvas.drawBitmap(getAvatar(IMAGE_WIDTH.toInt()), 0f, 0f, paint)
  paint.xfermode = null
  canvas.restoreToCount(count)
}

注意离屏缓冲非常消耗资源,因此通常只创建所需大小的缓冲层,而不要创建整个 View 那么大。

效果不对?

Xfermode 只处理画笔画出来的地方,官方文档上的例子是两个相同大小的图片混合(包括透明部分),而不是两个图形混合。要想得到正确的结果,需要创建两个 bitmap 分别画上两个形状,再把这两个 bitmap 整体混合。

如果只混合两个形状,则只有 SourceImage 有像素的那一部分会被处理,若 SourceImage 完全覆盖 DestinationImage 则看不出影响,否则会出现下面情况:

Path

绘制方向与内外判断

我们知道图形可以有填充,而填充的前提是需要搞清楚「哪一部分才算是图形的内部」。对于单个封闭图形答案显而易见,但若有多个图形互相交错,我们恰好又需要其中的部分不用填充的时候,就需要费一点心机了。

在 Android 上任意一点是否属于图形内部的算法是:从该点向任意方向画一条射线,根据射线与图形边缘的交点个数,以及边缘穿越射线的方向来判断。具体来讲,它有两个影响因素:

  • 绘制边缘的方向(Path 绘制图形时的 Path.Direction 参数)
  • 判断策略(Path.FillType 属性)

可以证明,从该点向任意方向画射线得到的最终结果是一致的。

Path.Direction 有两个值:

  • CW ClockWise,即顺时针
  • CCW Counter-ClockWise,即逆时针

Path.FillType 有四个值分为两组,带有 INVERSE 前缀的就是对结果取反。

FillType.WINDING

这是默认策略。WINDING 策略下,每次图形边缘从右至左穿过射线将计数器 +1,反过来穿越则 -1。最终如果计数器不是 0 就是图形内部,否则算作图形外部。

如下面的代码与结果:

path.reset()
path.addCircle(w / 2f, h / 2f, R, Path.Direction.CW)
path.addRect(w / 2f - R, h / 2f, w / 2f + R, h / 2f + 2 * R, Path.Direction.CCW)
path.fillType = Path.FillType.WINDING

红色的圆是顺时针画的,蓝色的矩形是逆时针画的。从绿色点任意画一条射线,计数器的结果都不可能为 0,因此视为内部点,应该填充。而从两个图形交汇的部分绘制一条射线,正好分别与两个方向相反的边缘交汇,计数器结果为 0,因此视为外部点,不会填充。

FillType.EVEN_ODD

EVEN_ODD 策略下绘图的方向就无所谓了。依然是从一点向任意方向画射线,只要和图形边缘相交,无论方向如何,均递增计数器。最终计数器值为奇数则视为内部点,否则视为外部点。

如下面的代码与结果:

path.reset()
path.addCircle(w / 2f, h / 2f, R, Path.Direction.CW)
path.addRect(w / 2f - R, h / 2f, w / 2f + R, h / 2f + 2 * R, Path.Direction.CCW)
path.fillType = Path.FillType.EVEN_ODD

测量

Path 不仅可以画出任意形状,也支持对路径进行测量,例如:总长度、每一位置的切角等。例如在上面仪表盘的 demo 中,为了计算刻度的间隔,我们需要圆弧的总长度。本来这个圆弧可以用 paint.drawArc() 直接画,但为了计算长度,还是要创建一个 Path:

val path = Path()
path.addArc(
  w / 2f - RADIUS,
  h / 2f - RADIUS,
  w / 2f + RADIUS,
  h / 2f + RADIUS,
  90 + OPEN_ANGLE / 2f,
  360 - OPEN_ANGLE,
)
val pathMeasure = PathMeasure(path, false)
pathMeasure.length // path 的总长度

文字测量

使用 Canvas 绘制时,通常要给出一个或几个坐标。对于一个坐标的东西,究竟应该对齐到哪里呢?不妨把这个点叫做「锚点」。比如绘制 Bitmap 时锚点是位图的左上角。对于文字来讲,这个锚点就比较调皮了。

默认情况下,文字的锚点在 Baseline 的最左侧。锚点的水平位置可以通过 Paint.textAlign 轻松更改,不像垂直位置,恶心死人...

垂直居中

所以怎么实现真正的垂直居中? 那先来了解一下文字的排版。

  • BASELINE: 是文字的基准线。
  • ascent/descent: 限制大部分字符的范围。在 Android 中这两个属性分别表示为相对于 BASELINE 的位移。因为 Android 坐标系 Y 轴向下,因此 ascent 总是负值,descent 总是正值。
  • top/bottom: 限制所有字形的范围。任何字形,包括特殊的符合也不会超过这个范围。在 Android 中这两个属性也表现为相对与 BASELINE 的位移。

静态文本

Paint.getTextBounds() 方法可以测量文字的像素边界。它假设使用默认锚点在 (0,0) 处绘制文字,返回这段文本实际像素区域(即不考虑各种参考线),如下图:

使用这种测量结果来修正文字位置可以实现最「精准」的垂直居中,只需计算一下偏移:

图中 a 的值实际就是 TextBounds.top(负数),因此偏移量为 bounds.top-(bounds.top-bounds.bottom)/2 化简一下就是 (bounds.top + bounds.bottom)/2,那么最终的结果为:

paint.getTextBounds("aaaa", 0, 4, bounds)
// 这里的 top/bottom 与上面的排版参考线无关
canvas.drawText("aaaa", width / 2f, height / 2f - (bounds.top + bounds.bottom) / 2f, paint)

效果如下(红色矩形是 TextBounds 测量的结果,居中绘制了):

这种方法只适合静态文本。不同字符高度不同,例如 mmmy,因为后者高一点,按照这个算法居中,后者整体要更偏上。 这样一来在文字变化时会上下跳动。我们总不能给用户解释说:「因为精准,所以跳动」吧 🐶

动态文本

既然动态文本不可能做到像素级的垂直居中,那退而求其次。现在需要找一个固定的边框,对于任意字符它的高度都不会改变,并且它还应该容纳大部分字符且尽可能少的留有上下空白。—— 显然,ascent/descent 是不错的选择。算法类似:

val fontMetrics = Paint.FontMetrics()
paint.getFontMetrics(fontMetrics)
canvas.drawText(s, width / 2f, height / 2f - (fontMetrics.ascent + fontMetrics.descent) / 2f, paint)

这种算法对于比较”低“的字符,比如 qqqq 会显得偏下,但总体来讲是居中的,且不会上下跳动。效果如下(红色矩形是 TextBounds 测量的结果,蓝色矩形是字形真正的大小,可以看出从蓝色矩形的角度来讲字体是居中的):

贴边

以紧贴左上角为例。

精准贴边

通常情况下我们不喜欢精准贴边,这样视觉效果并不好。

和精准居中类似,通过测量文字边框添加偏移可以实现。仅适用于静态文本。

val s = "abc"
paint.getTextBounds(s, 0, s.length, bounds)
canvas.drawText(s, -bounds.left.toFloat(), -bounds.top.toFloat(), paint)

效果如下:

不够精准?

有同学可能问,为啥左边和上面似乎还留了 1px 的空白?确实,getTextBounds() 并没有那么精准,有几个可能的原因:

  • 抗锯齿处理。抗锯齿实质是模糊羽化边缘,让它看起来没有锯齿。结果就是图形的边缘不再那么清晰,看起来空白的地方也许有淡淡的颜色,若是直接切掉会产生字符显示不全的感觉。
  • 字体文件本身也不是 100% 精准,位图字体暂且不论,即使是矢量字体也会有计算的精度问题。对于系统来讲,它认为那道空隙是有颜色的。

这个问题无法解决,除非硬编码修正。

Paint.textAlign = Paint.Align.LEFT 不能贴左吗?

不完全能。 字体本身就带有左右间隙,这个间隙的大小与设置的字体大小有关。当字号很大时,只是自然间隙就已经非常明显,明显到似乎没有贴边。因此只有 TextBounds 才能实现像素上的贴边而不考虑字体影响。

动态文本贴边

和居中一样,动态文本垂直方向无法实现精准贴边,否则视觉上会跳跃。我们需要选择一个固定的参考线视为文字顶边。通常有两个选择:

  • FontMetrics.top 是最保险的选择,不会导致字体显示不全。但大部分文字实际没有那么高,可能视觉上顶部缝隙比较大。
  • FontMetrics.ascent 是另一个选择。视觉上缝隙更小,但少部分很高的文字会被截断。

要根据实际项目的场景来选择参考线,这里以 ascent 为例:

val s = "abc"
paint.getFontMetrics(fontMetrics)
canvas.drawText(s, 0f, -fontMetrics.ascent, paint)

蓝色矩形才是一个字形真正的大小,所以已经贴边了。

动态贴边方式无法调整横向位置,随着字号的增大缝隙也会变大。如果必要,可以在水平方向搭配使用 getTextBounds(),随着内容的变化,水平方向的长度本来就会变化,所以不会产生不适感。

环绕图片

对于文本换行,不少同学知道可以用 StaticLayout 实现。那如果要文字环绕图片呢?需求如下图所示,简单起见我们先让图片贴边,对于居中的情况原理是一样的,只是计算更麻烦一点。

需求效果

Android 没有封装好的布局类给我们使用,现在得一行一行测量、一行一行绘制。

Paint.breakText() 方法可以测量给定字符串与最大宽度,最多可以放下几个字符,返回字符个数。它可选地接受一个 float[] 参数,用于返回实际占用的宽度(宽度可能用不完,但剩下的不够再放一个字符)。

为什么要使用数组来返回数据?

因为 java 中 float 不是引用传递,所以才包装成一个数组,实际上只有第一个元素有意义。

同时不要忘记 drawText() 的锚点是 Baseline,要想顶对齐要手动计算一下。两者结合起来,可以顺利地画出第一行:

// 绘制第一行
val count = paint.breakText(text, 0, text.length, true, maxWidth, null)
canvas.drawText(text, 0, count, 0f, -fontMatrix.top, paint)

把上面的代码放进一个循环,每一行从上一行截断的位置再次测量、绘制,就可以实现类似 StaticLayout 的效果。至于行距,Paint.fontSpacing 可以给我们最佳数值。

// 实现 StaticLayout 效果
var start = 0
var verticalOffset = -fontMatrix.top
while (start < text.length) {
  val count = paint.breakText(text, start, text.length, true, maxWidth, null)
  canvas.drawText(text, start, start + count, 0f, verticalOffset, paint)
  start += count
  verticalOffset += paint.fontSpacing
}

StaticLayout 效果

最后,只要判断每一行是否与图片重叠,重叠则调整 maxWidth 的大小就好了。经过前面的介绍,我们已经知道文字的垂直边界分别为 top/bottom,我们的 verticalOffset 则代表 baseline 的位置。综合一下这三个参数,就可以计算是否与图片在同一行,最终实现需求。

var start = 0
var verticalOffset = -fontMatrix.top
while (start < text.length) {
  val maxWidth = if (verticalOffset + fontMatrix.top > IMAGE_TOP + IMAGE_SIZE ||
                     verticalOffset + fontMatrix.bottom < IMAGE_TOP
                    ) {
    width.toFloat() // 文字顶部在图片底边的下面 || 文字底部在图片顶边上面 -> 文字与图片没有重叠
  } else {
    width - IMAGE_SIZE
  }

  val count = paint.breakText(text, start, text.length, true, maxWidth, null)
  canvas.drawText(text, start, start + count, 0f, verticalOffset, paint)
  start += count
  verticalOffset += paint.fontSpacing
}

这个实现方式没有考虑单词截断,这是另一个话题了。

Canvas

裁切

Canvas 提供了几个 clipXxx() 方法,可以裁剪出一个区域,之后只有在这个区域内进行的绘制才有效。裁切也可以实现类似 PS 蒙版的效果(之前我们用 Xfermode 实现的),但尽量别这么干,因为 Canvas 裁切会有明显的锯齿。

Canvas 裁切不是 Paint,没有抗锯齿功能,所以只建议用于矩形。

裁切影响坐标系! 比如我们在最中间 (w/2, h/2) 裁切出了一矩形,那么后续的绘制必须画在中间,否则就在裁切范围之外了,不会显示。

变换

二维变换

Canvas 变换影响坐标系! 例如 Canvas.translate(w/2f,h/2f) 之后,新的 (0,0) 就是原来的中点的位置。

除了使用 Canvas 自带的方法,Andoird 也提供了 Matrix 用于变换,它的好处是有一系列 postXxx() 方法,这允许我们以「坐标系不变、对象本身在变换」的思维写出正确的代码。

比如我们想把图片画在中间,并绕图片中点旋转 45 度(即在屏幕中间画个菱形的图片),直接使用 Canvas 写法如下:

canvas.translate((width - IMAGE_SIZE) / 2f, (height - IMAGE_SIZE) / 2f)
canvas.rotate(45f, IMAGE_SIZE/2f, IMAGE_SIZE/2f) // 坐标系变了,计算中点的时候小心
canvas.drawBitmap(bitmap, 0f, 0f, null)

如果不想考虑坐标系的问题,可以“倒着”写:

canvas.rotate(45f, width/2f, height/2f) // 3.视为坐标系没变,计算中点
canvas.translate((width - IMAGE_SIZE) / 2f, (height - IMAGE_SIZE) / 2f) // 2.把它移动到屏幕中间
canvas.drawBitmap(bitmap, 0f, 0f, null) // 1.先在原点画图片
// 从这里开始向上看 ↑

如此一来思考起来比较轻松,可惜代码要倒着写,此时就可以使用 Matrix

// 从这里开始向下看 ↓
private val trans = Matrix()
trans.apply {
  reset()
  postTranslate((width - IMAGE_SIZE) / 2f, (height - IMAGE_SIZE) / 2f) // 1.把它移动到屏幕中间
  postRotate(45f, width / 2f, height / 2f) // 2.视为坐标系没变,计算中点,旋转
}
canvas.setMatrix(trans)
canvas.drawBitmap(bitmap, 0f, 0f, null)

这样既能思考起来简单,也能按直觉顺序写代码。

Tips: 正着思考所有变换都应用于坐标系;倒着思考所有变换都应用于图形,坐标系不变。

三维变换

翻转

三维变换需要用到 android.graphics.Camera 类。例如我们的需求是:把图片绘制在中间,并且绕 x 轴旋转。即:图片上部嵌到屏幕里,下部分突出屏幕。

在 Camera 的视角下,坐标系有一点变动:

  • Y 轴向上是正方向
  • 新增的 Z 轴向里是正方向
  • Camera 位于 z 轴的负半轴

实现代码(倒着写):

val camera = Camera().apply {
  rotateX(30f) // 操作 Camera 的时候使用三维坐标系
}
canvas.translate((width- IMAGE_SIZE)/2f,(height- IMAGE_SIZE)/2f) // 5.移到屏幕中间
canvas.translate(IMAGE_SIZE / 2f, IMAGE_SIZE / 2f) // 4.恢复图片位置
camera.applyToCanvas(canvas) // 3.翻转
canvas.translate(-IMAGE_SIZE / 2f, -IMAGE_SIZE / 2f) // 2.把图片中心与原点对齐
// 操作 Canvas 的时候依然使用图形坐标系
canvas.drawBitmap(bitmap, 0f, 0f, null) // 1.在原点画一个图片
// 从这里开始向上看 ↑

效果:

为什么要先把图片中心与原点对齐?

Camera 默认在 z 轴上,而我们的图片画在 x 轴正方向上,即在 Camera 的右侧。这样进行三维变换后图片右下角与左下角和Camera 距离不相等,投射到二维平面就表现为右下角被拉长。如图:

强度修正

有时我们也许会觉得强度太大了,就好像整个翻转后的图片要盖在脸上,更讨厌的是这个效果不是固定的,越是像素密度大的手机越明显。这个视觉效果主要由虚拟的 Camera 与手机屏幕平面的距离决定,距离越近效果越明显,视觉冲击越强。

Camera.setLocation() 方法可以调整相机的位置,其中 z 的值就是与屏幕的距离了,默认值大约是 -8。这个值的单位是「英寸」,它来自 Android 的底层绘图库 Skia。英寸与像素的关系大约是 1in=72px。现在我们必须使用屏幕密度来计算这个值,才能达到类似 dp 的效果 —— 在不同屏幕上有相同的观感。幸运的是 resources.displayMetrics.density 可以轻松取到屏幕密度的缩放因子。接下来自己多试试就能找到合适的值。

private val camera = Camera().apply {
  rotateX(30f)
  setLocation(0f, 0f, -4 * resources.displayMetrics.density)
}

综合-对角线翻页效果

折角效果

翻页效果要求图片一半正常显示,另一半做出翻转效果,这需要 Canvas 裁切与三维变换配合使用。而且这个刁钻的需求偏偏要对角线翻页。Canvas 大部分操作都不支持斜着进行,需要旋转 Canvas 本身来达到目的。

// IMAGE_DIAGONAL = 图片对角线长度
private val camera = Camera().apply {
  rotateX(60f)
  setLocation(0f, 0f, -4 * resources.displayMetrics.density)
}

override fun onDraw(canvas: Canvas) {
  // 左上部分
  canvas.save()
  canvas.translate(width / 2f, height / 2f)
  canvas.rotate(-45f, 0f, 0f)
  canvas.clipRect(
    -IMAGE_DIAGONAL / 2f,
    -IMAGE_DIAGONAL / 2,
    IMAGE_DIAGONAL / 2,
    0f
  ) // 只保留上半部分
  canvas.rotate(45f, 0f, 0f)
  canvas.drawBitmap(bitmap, -IMAGE_SIZE / 2f, -IMAGE_SIZE / 2f, null)
  // 从这里开始向上看 ↑ ------------------------------------
  canvas.restore()

  // 右下部分
  canvas.translate(width / 2f, height / 2f) // 移动到屏幕中间
  canvas.rotate(-45f, 0f, 0f) // 转回正方形,此时就是沿对角线折叠了
  camera.applyToCanvas(canvas)
  canvas.clipRect(
    -IMAGE_DIAGONAL / 2f,
    0f,
    IMAGE_DIAGONAL / 2,
    IMAGE_DIAGONAL / 2
  ) // 只保留菱形的下半部分
  canvas.rotate(45f, 0f, 0f) // 旋转后是原点为中点的菱形
  canvas.drawBitmap(bitmap, -IMAGE_SIZE / 2f, -IMAGE_SIZE / 2f, null)
  // 从这里开始向上看 ↑ ------------------------------------
}

如果动态修改翻页的角度就可以实现动画了。