Android 自定义 View:彻底搞懂 Xfermode 与官方文档陷阱

前言

Xfermode 其实是 "Transfermode" 的简称,字面意思是 "转换模式"。在 Android 系统中,它是一种图像混合模式,用于控制两个图层在绘制时像素的混合方式。

我们通过一个示例来讲解它。

绘制圆形头像

我们来完成绘制圆形头像:首先绘制一个圆形,然后使用 Xfermode,再绘制一个方形头像,从而实现圆形头像的裁剪。

准备工作:

  1. 创建 AvatarView,继承自 View

  2. activity_main 布局中使用这个 AvatarView

  3. 准备一张图片文件 (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)
    }
}

运行效果:

可以看到,成功得出了蓝色扇形。

相关推荐
alexhilton28 分钟前
运行时着色器实战:实现元球(Metaballs)动效
android·kotlin·android jetpack
從南走到北1 小时前
JAVA国际版东郊到家同城按摩服务美容美发私教到店服务系统源码支持Android+IOS+H5
android·java·开发语言·ios·微信·微信小程序·小程序
观熵3 小时前
Android 相机系统全景架构图解
android·数码相机·架构·camera·影像
Huntto4 小时前
在Android中使用libpng
android
_小马快跑_6 小时前
从VSync心跳到SurfaceFlinger合成:拆解 Choreographer与Display刷新流程
android
_小马快跑_6 小时前
Android | 视图渲染:从invalidate()到屏幕刷新的链路解析
android
Monkey-旭9 小时前
Android 定位技术全解析:从基础实现到精准优化
android·java·kotlin·地图·定位
树獭非懒10 小时前
Android 媒体篇|吃透 MediaSession 与 MediaController
android·架构
一起搞IT吧12 小时前
高通Camx hal进程CSLAcquireDeviceHW crash问题分析一:CAM-ICP FW response timeout导致
android·图像处理·数码相机