前言
Xfermode
其实是 "Transfermode
" 的简称,字面意思是 "转换模式"。在 Android 系统中,它是一种图像混合模式,用于控制两个图层在绘制时像素的混合方式。
我们通过一个示例来讲解它。
绘制圆形头像
我们来完成绘制圆形头像:首先绘制一个圆形,然后使用 Xfermode
,再绘制一个方形头像,从而实现圆形头像的裁剪。
准备工作:
-
创建
AvatarView
,继承自View
。 -
在
activity_main
布局中使用这个AvatarView
。 -
准备一张图片文件 (avatar.jpg)
准备工作完成后,我们开始实现。
首先绘制头像,代码如下:
kotlin
class AvatarView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
companion object {
val IMAGE_WIDTH = 150f.px
val IMAGE_LEFT = 50f.px
val IMAGE_TOP = 50f.px
}
override fun onDraw(canvas: Canvas) {
// 绘制头像
canvas.drawBitmap(getAvatar(IMAGE_WIDTH.toInt()), IMAGE_LEFT, IMAGE_TOP, paint)
}
// 获取按指定宽度缩放后的头像 Bitmap
private fun getAvatar(targetWidth: Int): Bitmap {
val options = BitmapFactory.Options()
// 先只读取图片尺寸信息,不加载实际图片到内存
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
// 设置为false,开始真正加载图片
options.inJustDecodeBounds = false
// 设置图片的密度属性,用于自动缩放
options.inDensity = options.outWidth
options.inTargetDensity = targetWidth
return BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
}
}
运行效果:
然后在绘制头像之前,绘制一个圆形,中间使用 Xfermode
。
虽然
drawOval()
方法是用于画椭圆的,但也可以画圆形,只要给它的矩形区域是正方形。
diff
override fun onDraw(canvas: Canvas) {
+ // 绘制圆形
+ canvas.drawOval(
+ IMAGE_LEFT,
+ IMAGE_TOP,
+ IMAGE_LEFT + IMAGE_WIDTH,
+ IMAGE_TOP + IMAGE_WIDTH,
+ paint
+ )
+ // TODO 使用 Xfermode
// 绘制头像
canvas.drawBitmap(getAvatar(IMAGE_WIDTH.toInt()), IMAGE_LEFT, IMAGE_TOP, paint)
+ paint.xfermode = null
}
那么 Xfermode
应该使用什么呢?
Xfermode
现在只剩下了一种,其余的已被废弃。剩下的这种叫 PorterDuffXfermode
,具体的规则可以查看官方文档。
其中先绘制的叫作 Destination image (目标图像),后绘制的叫作 Source image (源图像)。对于我们当前的需求来说,应该使用 SRC_IN
(Source In) 模式,因为它会只在目标图像和源图像相交的区域,绘制源图像的内容。
在代码中使用:
kotlin
companion object {
...
// 对象最好不要在 onDraw 方法中创建
val XFERMODE = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}
override fun onDraw(canvas: Canvas) {
// 绘制圆形
canvas.drawOval(
IMAGE_LEFT,
IMAGE_TOP,
IMAGE_LEFT + IMAGE_WIDTH,
IMAGE_TOP + IMAGE_WIDTH,
paint
)
// 设置混合模式
paint.xfermode = XFERMODE
// 绘制头像
canvas.drawBitmap(getAvatar(IMAGE_WIDTH.toInt()), IMAGE_LEFT, IMAGE_TOP, paint)
// 避免影响后续绘制
paint.xfermode = null
}
运行效果:
发现并没有效果,这是因为此时 Xfermode
的目标图像并不只是我们刚刚绘制的圆形,而是 Canvas
上已有的全部内容,这其中就包括了 View
的不透明背景。
所以我们需要让圆形和头像绘制在透明、独立的画布上,这可以通过离屏缓冲(Off-screen Buffer)来实现。它能创建一块隔离的绘制层(绘制区域),我们可以在这块层上进行混合操作,完成后,再将结果贴回到原本的画布上。
创建的区域要尽可能小,因为离屏缓冲非常消耗资源。
代码如下:
kotlin
companion object {
...
// 离屏缓冲矩形区域
val bounds = RectF(IMAGE_LEFT, IMAGE_TOP, IMAGE_LEFT + IMAGE_WIDTH, IMAGE_TOP + IMAGE_WIDTH)
}
override fun onDraw(canvas: Canvas) {
// 创建新的绘制层,后续的绘制操作都会发生在这个层上
val count = canvas.saveLayer(bounds, null)
// 绘制圆形
canvas.drawOval(
IMAGE_LEFT,
IMAGE_TOP,
IMAGE_LEFT + IMAGE_WIDTH,
IMAGE_TOP + IMAGE_WIDTH,
paint
)
// 设置混合模式
paint.xfermode = XFERMODE
// 绘制头像
canvas.drawBitmap(getAvatar(IMAGE_WIDTH.toInt()), IMAGE_LEFT, IMAGE_TOP, paint)
paint.xfermode = null
// 合并绘制结果到View的Canvas,恢复Canvas状态
canvas.restoreToCount(count)
}
此时的运行效果:
还原官方示例
另外,很多人尝试复现官方示例时,会发现结果和官方文档的不同。我们现在就来尝试复现,并解开这个疑惑。
创建一个 XfermodeView
,继承自 View
,在布局中使用。我们先尝试,直接在离屏缓冲中绘制一个圆形和正方形,看看结果如何:
kotlin
class XfermodeView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
companion object {
// 圆的左边界
val CIRCLE_LEFT = 200f.px
// 圆的上边界
val CIRCLE_TOP = 50f.px
// 圆的半径
val CIRCLE_RADIUS = 50f.px
// 正方形的左边界
val SQUARE_LEFT = 150f.px
// 正方形的上边界
val SQUARE_TOP = 100f.px
// 正方形的边长
val SQUARE_SIZE = 100f.px
// 绘制层的边界
val BOUNDS = RectF(
min(CIRCLE_LEFT, SQUARE_LEFT),
min(CIRCLE_TOP, SQUARE_TOP),
max(CIRCLE_LEFT + 2 * CIRCLE_RADIUS, SQUARE_LEFT + SQUARE_SIZE),
max(CIRCLE_TOP + 2 * CIRCLE_RADIUS, SQUARE_TOP + SQUARE_SIZE)
)
// 混合模式
val XFERMODE = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}
override fun onDraw(canvas: Canvas) {
// 保存绘制层
val count = canvas.saveLayer(BOUNDS, null)
// 绘制圆形
val color = paint.color
paint.color = Color.parseColor("#e91e63")
canvas.drawOval(
CIRCLE_LEFT,
CIRCLE_TOP,
CIRCLE_LEFT + 2 * CIRCLE_RADIUS,
CIRCLE_TOP + 2 * CIRCLE_RADIUS,
paint
)
paint.xfermode = XFERMODE
// 绘制正方形
paint.color = Color.parseColor("#2196f3")
canvas.drawRect(
SQUARE_LEFT,
SQUARE_TOP,
SQUARE_LEFT + SQUARE_SIZE,
SQUARE_TOP + SQUARE_SIZE,
paint
)
paint.color = color
paint.xfermode = null
// 恢复绘制层
canvas.restoreToCount(count)
}
}
运行效果:
你会发现在 SRC_IN
模式下,结果和官方文档展示的并不同。你换别的模式,也会不同,那为什么会这样?
仔细看官方文档的示例,你会发现源图像和目标图像不只是圆和正方形,还包括了后面的透明背景。
所以根本原因在于,Xfermode
进行混合计算的范围,是由后绘制的图像范围决定的。我们在绘制正方形时,混合计算只发生在了这个正方形的边界内部。对于圆形来说,只有四分之一的扇形(相交的部分)参与了计算,而剩下的四分之三区域并没有参与,所以就原样保留了,所以导致了结果和预期不符。
要复现官方效果,就要让源图像和目标图像的绘制范围完全重合才行。
现在,我们使用 drawBitmap()
来还原官方示例,代码如下:
kotlin
class XfermodeView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
companion object {
// 圆的左边界
val CIRCLE_LEFT = 200f.px
// 圆的上边界
val CIRCLE_TOP = 50f.px
// 圆的半径
val CIRCLE_RADIUS = 50f.px
// 正方形的左边界
val SQUARE_LEFT = 150f.px
// 正方形的上边界
val SQUARE_TOP = 100f.px
// 正方形的边长
val SQUARE_SIZE = 100f.px
// 绘制层的边界
val BOUNDS = RectF(
min(CIRCLE_LEFT, SQUARE_LEFT),
min(CIRCLE_TOP, SQUARE_TOP),
max(CIRCLE_LEFT + 2 * CIRCLE_RADIUS, SQUARE_LEFT + SQUARE_SIZE),
max(CIRCLE_TOP + 2 * CIRCLE_RADIUS, SQUARE_TOP + SQUARE_SIZE)
)
// 混合模式
val XFERMODE = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}
private val circleBitmap: Bitmap by lazy {
// 创建一个和绘制区域一样大的空白Bitmap
Bitmap.createBitmap(
BOUNDS.width().toInt(),
BOUNDS.height().toInt(),
Bitmap.Config.ARGB_8888
).also {
val canvas = Canvas(it)
val localPaint =
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#e91e63") }
// 坐标要相对Bitmap的左上角(0,0)
canvas.drawOval(
CIRCLE_LEFT - BOUNDS.left,
CIRCLE_TOP - BOUNDS.top,
CIRCLE_LEFT + 2 * CIRCLE_RADIUS - BOUNDS.left,
CIRCLE_TOP + 2 * CIRCLE_RADIUS - BOUNDS.top,
localPaint
)
}
}
private val squareBitmap: Bitmap by lazy {
Bitmap.createBitmap(
BOUNDS.width().toInt(),
BOUNDS.height().toInt(),
Bitmap.Config.ARGB_8888
).also {
val canvas = Canvas(it)
val localPaint =
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#2196f3") }
canvas.drawRect(
SQUARE_LEFT - BOUNDS.left,
SQUARE_TOP - BOUNDS.top,
SQUARE_LEFT + SQUARE_SIZE - BOUNDS.left,
SQUARE_TOP + SQUARE_SIZE - BOUNDS.top,
localPaint
)
}
}
override fun onDraw(canvas: Canvas) {
// 保存绘制层
val count = canvas.saveLayer(BOUNDS, null)
// 绘制圆形Bitmap
canvas.drawBitmap(circleBitmap, BOUNDS.left, BOUNDS.top, paint)
paint.xfermode = XFERMODE
// 绘制正方形Bitmap
canvas.drawBitmap(squareBitmap, BOUNDS.left, BOUNDS.top, paint)
paint.xfermode = null
canvas.restoreToCount(count)
}
}
运行效果:
可以看到,成功得出了蓝色扇形。