Xfermode踩坑小结
Xfermode踩坑小结

Xfermode踩坑小结

Xfermode is the base class for objects that are called to implement custom “transfer-modes” in the drawing pipeline. The static function Create(Modes) can be called to return an instance of any of the predefined subclasses as specified in the Modes enum. When an Xfermode is assigned to an Paint, then objects drawn with that paint have the xfermode applied.

Xfermode最初有三个子类,除了目前我们常用的 PorterDuffXfermode 其他两个都已经作古了。

PorterDuffXfermode可以指定两张图形如何进行混合,借此我们可以实现一些特殊的绘图效果。官方有个很经典的示例图, 我经常是需要用时才找出来看一看。

官方APIDemos

官方文档错了?

然而在最近做需求时,我发现自己做出来的效果和官方图上不一致。网上检索,有人说官方的示意图错了,并给出了自己绘制的demo。有模有样有代码,让人信服。

网友的ApiDemos

但官方的文档真的错了吗?如果错了,为什么一直没有改呢?我直觉感觉这种事情概率比较低,所以还是得找到双方的代码才能断案。

官方的示例在一个叫做API Demos的APP中,代码在一个叫做 Xfermodes.java 的文件中,它没有很复杂的依赖关系,只要一并把它的父类 GraphicsActivity.java 一并拷贝过来就能工作。建议可以拷贝到自己的demo工程中,方便需要时查阅。

ApiDemos我们的运行结果

看上去也没有错误。接下来看看双方的代码,看看问题出在哪里。

官方代码(等效)

private fun makeDst(width: Int, height: Int): Bitmap {  
    val paint = Paint().also {  
        it.color = 0xFFFFCC44.toInt()  
    }  
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)  
    val canvas = Canvas(bitmap)  
    canvas.drawCircle(width / 2f, height / 2f, min(width, height) / 4f, paint)  
    return bitmap  
}  

private fun makeSrc(width: Int, height: Int): Bitmap {  
    val paint = Paint().also {   
        it.color = 0xFF66AAFF.toInt()  
    }  
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)  
    val canvas = Canvas(bitmap)  
    canvas.drawRect(width / 2f, height / 2f, width.toFloat(), height.toFloat(), paint)  
    return bitmap  
}  

override fun draw(canvas: Canvas) {  
    mPaint.xfermode = null  
    val width = bounds.width()  
    val height = bounds.height()  
    makeDst(width, height).also {  
        canvas.drawBitmap(it, 0f, 0f, mPaint)  
    }  
    mPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER)  
    makeSrc(width, height).also {  
        canvas.drawBitmap(it, 0f, 0f, mPaint)  
    }  
}

网友代码(等效)

private fun makeDst(width: Int, height: Int): Bitmap {  
    val paint = Paint().also {  
        it.color = 0xFFFFCC44.toInt()  
    }  
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)  
    val canvas = Canvas(bitmap)  
    canvas.drawCircle(width / 2f, height / 2f, min(width, height) / 2f, paint)  
    return bitmap  
}  

private fun makeSrc(width: Int, height: Int): Bitmap {  
    val paint = Paint().also {  
        it.color = 0xFF66AAFF.toInt()  
    }  
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)  
    val canvas = Canvas(bitmap)  
    canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)  
    return bitmap  
}  

override fun draw(canvas: Canvas) {  
    mPaint.xfermode = null  
    val width = bounds.width()  
    val height = bounds.height()  
    makeDst(width / 2, height / 2).also {  
        canvas.drawBitmap(it, width / 4f, height / 4f, mPaint)  
    }  
    mPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER)  
    makeSrc(width / 2, height / 2).also {  
        canvas.drawBitmap(it, width / 2f, height / 2f, mPaint)  
    }  
}

仔细观察上述代码,你会发现 网友用法中两次绘制实际上并不完全重叠。官方生成的两个Bitmap,是同样大小的并在同一个位置绘制,而后者是只生成了部分需要绘制的位置图形,在特定位置绘制。

单个图片绘制示意

例如,对于一个400X400的目标区域,官方方法是创建了两个400X400的Bitmap,直接在(0, 0) 位置绘制。 另一种方法是创建了两个200X200的Bitmap,一个在(100, 100)处绘制,另一个在(200, 200)处绘制。

后者绘制区域并不重叠,因而可以看到圆形变化的始终只有圆形的右下角四分之一圆。

Xfermode的几个应用

  1. 抖音Loading双色加载球
  2. 圆形头像甚至异形头像裁剪
  3. 抖音弹幕描边裁剪

弹幕需求Xfermode的应用

B站的带描边弹幕

弹幕有个优化需求,是给弹幕文字加上描边。描边一般是将paint的style设置为Stroke再进行一次绘制实现的。

// 绘制描边
with(layout.paint) {
    color = Color.BLACK  
    style = Paint.Style.STROKE  
    strokeWidth = 5f.dpFloat
}
layout.draw(canvas)

// 绘制填充
with(layout.paint) {
    style = Paint.Style.FILL  
    color = drawColor
}
layout.draw(canvas)

然而我这样实现后,效果并不好,因为 弹幕文本和描边都是有透明度的,而描边笔画重叠的位置也会进行绘制

描边与透明度的冲突

所以,我需要先将描边与填充重叠的地方裁掉,然后再绘制填充

override fun draw(canvas: Canvas) {  
    // 外层saveLayer也需要保留,否则裁剪时会有非预期表现  
    canvas.saveLayer(null, null)  

    // 绘制描边
    drawStroke(canvas)  

    clipPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)  
    canvas.saveLayer(null, clipPaint)  
    // 绘制文本剪影,DST_OUT方式图像混合。因为混合时会受到透明度影响,所以指定为黑色。
    drawSolid(canvas, Color.BLACK)  
    canvas.restore()  

    // 绘制文本
    drawSolid(canvas)  

    canvas.restore()  
}  

private fun drawStroke(canvas: Canvas) {  
    with(layout.paint) {  
        color = Color.BLACK  
        style = Paint.Style.STROKE  
        strokeWidth = 5f.dpFloat  
    }  
    layout.draw(canvas)  
}  

private fun drawSolid(canvas: Canvas, drawColor: Int ? = null) {  
    with(layout.paint) {  
        style = Paint.Style.FILL  
        color = drawColor ?: "#99FF0000".color  
    }  
    layout.draw(canvas)   
}

弹幕xfermode绘制效果

不过这个方案实现出来 性能表现太差 被砍掉了 Orz

总之就是,慎用saveLayer,优先考虑其他实现,尤其是在这种比较注重性能的场合。


其他

Xfermode兼容性

这个是需求测试中发现的。QA一台安卓手机上绘制效果不符合预期,然后借来跑了下demo,发现得到的结果和我自己测试机上还不一样。这里我找了个安卓8的手机,可以看到结果和我们之前得到的并不尽相同。

xfermode兼容问题

显然,图上 CLEAR、DARKEN、LIGHTEN 是和前面运行结果不一样的。

我建议, 以上提到3个表现不一致的(CLEAR、DARKEN、LIGHTEN)和ADD、OVERLAY这两个没有在文档提到的不要在工程中用。

此外, 尽量只在canvas.saveLayer上和canvas.drawBitmap上设置xfermode 在普通的图形绘制上不要给画笔设置xfermode,不是官方示例中的用法,可能出问题(本人踩坑,在vivo的某个机型出现了奇怪的绘制结果)

canvas如果来源于View的onDraw方法,要使用xfermode要在最外层套上saveLayer ,因为View拿出来的Canvas并不是透明背景,当你直接用xfermode裁剪掉部分区域时会遇到奇怪的问题(被裁掉的区域出现大块黑色块)。

愿在天堂的需求不需要用到xfermode。


最后修改于 2022-09-05