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)
比较坑的一点是
PathDashPathEffect
的advance
与phase
参数 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 测量的结果,居中绘制了):
这种方法只适合静态文本。不同字符高度不同,例如 mm
与 my
,因为后者高一点,按照这个算法居中,后者整体要更偏上。 这样一来在文字变化时会上下跳动。我们总不能给用户解释说:「因为精准,所以跳动」吧 🐶
动态文本
既然动态文本不可能做到像素级的垂直居中,那退而求其次。现在需要找一个固定的边框,对于任意字符它的高度都不会改变,并且它还应该容纳大部分字符且尽可能少的留有上下空白。—— 显然,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
}
最后,只要判断每一行是否与图片重叠,重叠则调整 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)
// 从这里开始向上看 ↑ ------------------------------------
}
如果动态修改翻页的角度就可以实现动画了。