Xfermode 的简单使用

不是很难的知识点,但还是花了点时间才理解。

简单用例

Xfermode(transfer mode)是 Android 开发中用于实现图形混合效果的一种技术,于android.graphics包中的PorterDuffXfermode类中实现,主要用于处理位图(Bitmap)和绘制操作(如绘制图形、文字等)之间的像素混合。Xfermode 基于 Porter-Duff 混合算法,该算法是由 Thomas Porter 和 Tom Duff 于 1984 年提出的一种图形混合算法,可以实现多种不同的混合模式。

Xfermode 提供了 16 种不同的混合模式,全部都在PorterDuff.Mode枚举中定义,包括:

  • CLEAR:清除所有像素,即结果为透明。
  • SRC:显示源图像,覆盖目标图像。
  • DST:显示目标图像,忽略源图像。
  • SRC_OVER:源图像覆盖在目标图像之上。
  • DST_OVER:目标图像覆盖在源图像之上。
  • SRC_IN:只显示源图像和目标图像相交的部分。
  • DST_IN:只显示目标图像和源图像相交的部分。
  • SRC_OUT:只显示源图像和目标图像不相交的部分。
  • DST_OUT:只显示目标图像和源图像不相交的部分。
  • SRC_ATOP:源图像覆盖在目标图像相交的部分,保留目标图像不相交的部分。
  • DST_ATOP:目标图像覆盖在源图像相交的部分,保留源图像不相交的部分。
  • XOR:显示源图像和目标图像不相交的部分,忽略相交部分。
  • DARKEN:显示源图像和目标图像中较暗的像素。
  • LIGHTEN:显示源图像和目标图像中较亮的像素。
  • MULTIPLY:源图像和目标图像的颜色值相乘,结果为更暗的颜色。
  • SCREEN:源图像和目标图像的颜色值进行反向相乘,结果为更亮的颜色。

更详细的介绍可以查看官方文档:https://developer.android.com/reference/android/graphics/PorterDuff.Mode

要使用 Xfermode,需要创建一个 PorterDuffXfermode 对象,并将其赋值给一个 Paint 对象的xfermode属性。例如:

1
2
3
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val mode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
paint.xfermode = mode

然后在绘制时,使用带有该 Paint 对象的 Canvas 绘制方法,就可以实现相应的混合效果。不过需要注意的是,在使用 Xfermode 时,通常需要用到离屏缓冲。利用离屏缓冲,可以将某个 View 或图形对象在屏幕外部的缓冲区进行绘制,然后再将绘制结果显示到屏幕上。离屏绘制可以用于实现各种高级图形效果,如阴影、圆角、蒙版等。接下来看一个将椭圆和某张矩形图片混合的用例(可以理解为矩形的头像被裁切成圆形展示):

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val picWidth = 200f.px // 图片宽度
private lateinit var bounds: RectF // 延迟初始化一个矩形区域,用来画椭圆,和割出离屏缓冲的区域
private val mode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) // 定义 Xfermode

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

val count = canvas.saveLayer(bounds, null) // 保存当前图层并返回一个代表当前图层的 ID
canvas.drawOval(bounds, paint) // 画椭圆
paint.xfermode = mode // 设置 Xfermode

// 在屏幕中央获取并绘制出已准备好的图片
// R.drawable.dedsec_logo 为准备好的图片
canvas.drawBitmap(
betterGetBitmap(picWidth.toInt(), R.drawable.dedsec_logo),
width / 2f - picWidth / 2f,
height / 2f - picWidth / 2f,
paint
)

paint.xfermode = null // 恢复 Xfermode
canvas.restoreToCount(count) // 复原图层
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 计算涉及到屏幕的宽高,所以需要在 View 的大小改变时进行该操作
bounds = RectF(
width / 2f - picWidth / 2f,
height / 2f - picWidth / 2f,
width / 2f + picWidth / 2f,
height / 2f + picWidth / 2f
)
}

/**
* 根据需求加载图片,以节省资源提高性能
* @param requireWidth 需要返回的图片的宽度
* @param picId 需要读取的图片
*/
private fun betterGetBitmap(requireWidth: Int, picId: Int): Bitmap {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true // 只获取图片的宽度和高度信息,而不加载整个图片
BitmapFactory.decodeResource(resources, picId, this) // 读取宽度和高度
inJustDecodeBounds = false // 置为 false ,以便后续加载整个图片
inDensity = outWidth // 设置读取到的图片的密度
inTargetDensity = requireWidth // 设置目标密度
}
return BitmapFactory.decodeResource(resources, picId, options) // 按照目标重新读取图片并返回
}

/**
* 为 Float 类型的数据定义一个扩展属性,将这个值的 dp 单位转换为 px 并返回
*/
val Float.px
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics
)

这就是 Xfermode 的一个简单用法,注释很详细,除了要知道saveLayer()很消耗性能以外似乎没有什么需要记录的。代码运行后会将准备好的矩形图片和椭圆混合,并将结果显示在屏幕中央。

理解误区

在上面的用例中,使用到了一张图片和椭圆进行混合,并且图片和图形的坐标是一致的,同时图片的面积比图形大(椭圆成为了矩形的内切圆)。在这样的情况下,Xfermode 确实达到了理想中的效果。

接下来来看另一个用例,现在要在使用PorterDuff.Mode.SRC_IN的情况下,将一个矩形(正方形)和椭圆(圆形)进行混合,来达到官方文档中 SRC_IN 的效果:

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
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private lateinit var ovalBounds: RectF // 延迟初始化一个矩形区域,用来画椭圆
private lateinit var rectBounds: RectF // 延迟初始化一个矩形区域,用来画矩形
private val mode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) // 定义 Xfermode

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

val count = canvas.saveLayer(
width / 2f - 100f.px,
height / 2f - 50f.px,
width / 2f + 50f.px,
height / 2f + 100f.px,
paint
)
paint.color = Color.parseColor("#53868B") // 青色
canvas.drawOval(ovalBounds, paint) // 先画椭圆
paint.xfermode = mode
paint.color = Color.parseColor("#B22222") // 深红色
canvas.drawRect(rectBounds, paint) // 再画矩形
paint.xfermode = null
canvas.restoreToCount(count)
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 计算涉及到屏幕的宽高,所以需要在 View 的大小改变时进行该操作
ovalBounds = RectF(
width / 2f - 50f.px,
height / 2f - 50f.px,
width / 2f + 50f.px,
height / 2f + 50f.px
)
// 刻意将矩形的位置相对圆的位置进行一点偏移
rectBounds = RectF(
width / 2f - 100f.px,
height / 2f,
width / 2f,
height / 2f + 100f.px
)
}

上面的代码在不使用 Xfermode 的情况下会把两个图形完整地画出,同时圆的左下四分之一的部分将会与正方形重叠,也就是说,一切正常。然而在使用PorterDuff.Mode.SRC_IN模式后,却并不能得到官方文档中对应的 Source In 的效果,反而是得到了 Source Atop 的效果……具体原因个人理解如下:

  • 实际上,官方文档中用例所使用的并不是两个图形,而是两张大小相同、绘制位置也相同的图片(图片中包含了图形和透明区域)。
  • 混合结果与谁是 Source image 和谁是 Destination image 有关——在设置 Paint 对象的xfermode属性之前绘制的是 Destination image ,相应的,之后的就是 Source image 。

以 SRC_IN 为例,其官方解释为:

Keeps the source pixels that cover the destination pixels, discards the remaining source and destination pixels.

先来看上半句话,大致意思是保留源与目标重叠的像素——在这里就是圆的左下四分之一的部分(在结果中可以看到这一部分确实是属于矩形的深红色);然后是下半句话,抛弃源和目标剩下的像素。源剩下的像素被抛弃了好理解,因为矩形除了重叠的部分以外的都消失了,但是圆(目标)还剩下四分之三的像素保留在那里呢……所以这里只能是理解成非重叠的目标像素不在混合的计算范围内了。

这时候如果把canvas.drawOval(ovalBounds, paint)canvas.drawRect(rectBounds, paint)这两行代码调换一下位置,也就是先画矩形再画椭圆,就又会出现不一样的结果,这是因为它们之间源和目标的身份互换了。不过只要结合上面的理论,会发现这样的结果其实也还是说得通。

再来一个例子,假设在原来先画椭圆再画矩形的基础上,把canvas.drawRect(rectBounds, paint)改成canvas.drawRect(ovalBounds, paint),也就是将矩形绘制在和椭圆相同的位置,这时候圆又会成为这个矩形的内切圆,在SRC_IN的情况下,就又会得到一开始把矩形图片裁切成圆形的效果。

总的来说,图形混合最终的效果主要和以下 3 点有关:

  • Paint 对象的xfermode属性设置了什么值。
  • 两张图像哪张是 Destination image 哪张是 Source image 。
  • 两张图像的重叠部分。

Xfermode 的简单使用
http://example.com/post/Simple-usage-of-Xfermode/
发布于
2023年4月24日
更新于
2023年4月26日
许可协议