Android 高级绘制技巧: BlendMode

418 阅读9分钟

什么是 BlendMode

BlendMode 是在画布上绘图时可使用的算法

在画布上绘制形状或图像时,可以使用不同的算法来混合像素。BlendMode 的不同值指定了不同的此类算法。

每种算法都有两个输入:源(source)是正在绘制的图像,目标(destination)是源图像将要合成到其中的图像。目标通常被视为背景。源和目标都有四个颜色通道,即红、绿、蓝和 alpha(透明度)通道。这些通道的值通常用 0.0 到 1.0 范围内的数字表示。算法的输出也具有这四个通道,其值由源和目标计算得出。

以上是 BlendMode 的注释,不好理解是吧。

通俗来讲,BlendMode 是用来指定两个图像共同绘制时的颜色策略的。不同的 Mode 可以指定不同的策略。「颜色策略」的意思,就是说把源图像绘制到目标图像处时应该怎样确定二者结合后的颜色。

看到这里很可能依然会一头雾水:「什么怎么结合?就……两个图像一叠加,结合呗?还能怎么结合?」你还别说,还真的是有很多种策略来结合。

比如,如果我们不指定任何模式,绘制两个图案,大家应该都知道后面的会叠加在前面之上。

比如这段代码:

Canvas(Modifier.size(600.px)) {
    drawCircle(Color(0xffe91e63), radius = 125f, center = Offset(450f, 200f))
    drawRect(
        Color(0xff2196f3),
        topLeft = Offset(200f, 200f),
        size = Size(250f, 250f)
    ) 
}

image.png

这样的代码我们太熟悉了,矩形在圆形之后绘制,所以它应该覆盖在圆形之上,太理所当然了对吧。但这是因为我们绘制的默认 BlendMode 为 SrcOver 造成的。

fun drawRect(
        color: Color,
        topLeft: Offset = Offset.Zero,
        size: Size = this.size.offsetSize(topLeft),
        @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
        style: DrawStyle = Fill,
        colorFilter: ColorFilter? = null,
        blendMode: BlendMode = DefaultBlendMode
    )
    
companion object {

    /**
     * Default blending mode used for each drawing operation.
     * This ensures that content is drawn on top of the pixels
     * in the destination
     */
    val DefaultBlendMode: BlendMode = BlendMode.SrcOver
}

我们如果换个 BlendMode 结果可能就不是这样了。

有哪些混合模式

直接看官图源图像和目标图像:

image.png

这里解释下源图(Source)和目标图(Destination),源图指的是即将绘制的图像,目标图则是已存在的图像,理解这个很重要。

一类是:Alpha 合成,就是如何组合两种图案。

image.png

一类是:颜色混合,就是很多图像处理软件里会有的混合模式,对普通人来说比较抽象,不过设计师应该会比较了解。当设计师设计稿中有用到这类算法,则我们就可以找到对应的模式去还原设计稿。这个大家了解下有这个东西就好。

image.png

如何使用 BlendMode

好,我们了解了 BlendMode 的各种混合模式之后,代码如何实现呢?

Canvas(modifier = Modifier.fillMaxSize()) {
    // 设置画布图层
    drawIntoCanvas { canvas ->
        ......
        drawImage(circleBitmap.asImageBitmap(), Offset.Zero)
        drawImage(squareBitmap.asImageBitmap(),Offset.Zero, blendMode = BlendMode.DstIn)
        ......
    }
}

盗用朱凯老师的图示例下:

blendmod_dstIn.drawio.png

999bf6c3755e6.gif

这就是最简单 BlendMode 的用法,代码很简单。简单解释一下,先绘制正方形,再绘制圆,那么这个正方形就是在圆绘制之前,所以它是 Destination ,这个圆是 SourceDstIn 的作用是什么?,我们来看下官方注释。

Show the destination image, but only where the two images overlap. The source image is not rendered, it is treated merely as a mask. The color channels of the source are ignored, only the opacity has an effect.

翻译下就是:仅在两张图像重叠的区域显示目标图像。源图像不进行渲染,仅作为遮罩处理。源图像的颜色通道被忽略,只有不透明度起作用。

总结一下

在混合模式中,通常涉及两个图层:

  • 源图层(Source):新绘制的图形或图像
  • 目标图层(Destination):已经存在于画布上的内容

BlendMode.DstIn 的混合规则是:

  • 最终像素的颜色 = 目标图层颜色 × 源图层的透明度(Alpha 值)
  • 最终像素的透明度 = 目标图层透明度 × 源图层透明度

注意事项

如果你运行上面的示例代码,你会发现与本文的结果并不相同,可能是这样的:

image.png

按照逻辑我们会认为,在第二步画圆的时候,跟它共同计算的是第一步绘制的方形。但实际上,却是整个 View 的显示区域都在画圆的时候参与计算,并且 View 自身的底色并不是默认的透明色,而且是遵循一种迷之逻辑,导致不仅绘制的是整个圆的范围,而且在范围之外都变成了黑色。

那么,怎么解决?

使用离屏缓冲

通过使用离屏缓冲,把要绘制的内容单独绘制在缓冲层,合成完毕之后再绘制到Canvas上。使用方法很简单,在代码的头跟尾分别加上canvas.saveLayer(layerRect, layerPaint)canvas.restore()

Canvas(modifier = Modifier.fillMaxSize()) {
    drawIntoCanvas { canvas ->
        val layerPaint = Paint()
        val layerRect = Rect(Offset.Zero, size)
        canvas.saveLayer(layerRect, layerPaint)
        drawImage(squareBitmap.asImageBitmap(), Offset.Zero)
        drawImage(circleBitmap.asImageBitmap(),Offset.Zero, blendMode = BlendMode.DstIn)
        canvas.restore()
    }
}

这样绘制出来的结果便与本文的示例结果一样了。

必须手动创建离屏缓冲(withSaveLayer)的 BlendMode 包括DstInDstOutDstAtopSrcInSrcOutSrcAtopXorClear。这些模式的核心特征是:混合结果依赖 “源(Src)” 和 “目标(Dst)” 的原始像素状态,且需要在混合完成后保留结果(不被后续绘制覆盖)。使用时需将相关绘制操作包裹在 withSaveLayer 作用域内,确保混合逻辑基于隔离的目标像素生效。

无需手动创建离屏缓冲的常见模式:大多数 “源优先” 或 “简单叠加” 的混合模式(如 SrcOverDstOverMultiplyScreen 等),因混合逻辑不依赖目标像素的长期稳定性(或仅依赖当前帧的瞬时状态),无需离屏缓冲即可正常工作。例如:

控制透明区域

使用 BlendMode 来绘制的内容,除了注意使用离屏缓冲,还应该注意控制源的透明区域不要太小,要让它足够覆盖目标区域的内容,否则得到的结果很可能不是你期望的。用图片来具体说明一下:

image.png

如图所示,由于透明区域过小而覆盖不到的地方,将不会受到 Xfermode 的影响。

了解了什么是 BlendMode 之后又产生了一个问题:它有什么用?

BlendMode 使用场景

列表边缘阴影 DstIn

output.jpg

相信大家肯定见过很多这样的列表设计,你如果用一个半透明的蒙版盖在上面,效果肯定不好,就像这样:

output2.jpg

并且,如果列表的颜色改变,你如果需要更好的效果,蒙版也要做调整。反正就是效果也不好,开发也麻烦。

这个时候我们用 BlendMode.DstIn 就可以渲染出想要的设计效果。

fun Modifier.listFadingEdges(listState: ScrollableState): Modifier = this.then(
    // adding layer fixes issue with blending gradient and content
    Modifier
        .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
        .drawWithContent {
            drawContent()
            val h = size.height
            val end1 = h * 0.2f
            val end2 = h * 0.8f
            if (listState.canScrollBackward) {
                drawRect(
                    brush = Brush.verticalGradient(
                        colors = listOf(Color.Red.copy(0f), Color.Red),
                        startY = 0f,
                        endY = end1
                    ),
                    topLeft = Offset(0f, 0f),
                    size = Size(size.width, end1),
                    blendMode = BlendMode.DstIn
                )
            }
            if (listState.canScrollForward) {
                drawRect(
                    brush = Brush.verticalGradient(
                        colors = listOf(Color.Red, Color.Red.copy(0f)),
                        startY = end2,
                        endY = h
                    ),
                    topLeft = Offset(0f, end2),
                    size = Size(size.width, h - end2),
                    blendMode = BlendMode.DstIn
                )
            }
        })

blendmod_dstIn.drawio.png

可以看到,用 BlendMode 添加的“蒙版”很自然,没有分界线,并且不管列表背景什么颜色都不会受影响。

所以,大家在遇到需要展示 Destination 和 Source 相交,并且需要 Source 的透明度影响相交部分,则可以使用 DstIn 混合绘制。

绘制渐变文字 SrcIn

先看效果: output.gif

一个比较酷的文字效果,实现起来很简单,先绘制文字,再绘制一个渐变矩形覆盖文字,用 BlendMode.SrcIn 混合,就可以实现渐变文字效果,然后我们再移动这个渐变矩形,就可以实现这种效果了。看代码:

@Composable
fun AnimatedGradientText(
    text: String,
    colors: List<Color> = listOf(
        Color(0xFFFF8A00),
        Color(0xFFFF3D00),
        Color(0xFFFF0080)
    ),
    durationMillis: Int = 2400,
    fontSize: TextUnit = 280.SP,
    fontWeight: FontWeight = FontWeight.Bold,
    modifier: Modifier = Modifier
) {
    val infinite = rememberInfiniteTransition(label = "gradient")
    val shift by infinite.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis, easing = LinearEasing)
        ),
        label = "shift"
    )

    Text(
        text = text,
        modifier = modifier
            .padding(300.px)
            // 如遇到设备兼容性黑边问题,可强制离屏合成:
             .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
            .drawWithCache {
                val w = size.width
                val h = size.height

                // 让渐变在水平方向来回“扫动”
                val startX = -w + 2f * w * shift
                val endX = startX + w

                val brush = Brush.linearGradient(
                    colors = colors,
                    start = Offset(startX, 0f),
                    end = Offset(endX, h)
                )

                onDrawWithContent {
                    // 先正常画出文字
                    drawContent()
                    // 再画一层渐变矩形,用文字做遮罩(仅保留文字内的像素)
                    drawRect(
                        brush = brush,
                        size = size,
                        blendMode = BlendMode.SrcIn
                    )
                }
            },
        style = TextStyle(
            fontSize = fontSize,
            fontWeight = fontWeight
        ),
        maxLines = 1,
        overflow = TextOverflow.Ellipsis
    )
}

aa.png

22.gif

原理很简单,先绘制文字,然后在绘制一个渐变矩形,重复移动这个矩形,混合模式设置为 BlendMode.SrcIn,那么最终就会绘制 Source(矩形)和 Destination (文字)重叠的部分,但是颜色显示Source 的颜色,这样我们就实现这种效果了。

总结

关于 BlendMode 混合模式就介绍到这边,本文主要描述了 BlendMode 它是什么,有什么作用,以及在 Android Compose 中如何使用。

另外,BlendMode 有非常多的模式,大家只需要记得几个常用的,像 SrcIn、DstIn、Clear 等,其他的大家可以在遇到需要混合的时候去翻阅资料,应该使用哪种模式。

  • SRC_OVER:这是默认的混合模式,源图像绘制在目标图像之上,源图像的不透明部分会覆盖目标图像,透明部分则显示目标图像,根据源图像的 alpha 值来决定两者的混合程度。比如绘制一个半透明的圆形(源图像)到有颜色的矩形(目标图像)上,圆形的透明区域会透出矩形的颜色。
  • DST_OVER:与 SRC_OVER 相反,目标图像绘制在源图像之上,根据目标图像的 alpha 值来决定混合效果。例如先绘制一个圆形,再绘制一个矩形,矩形的透明部分会透出圆形的颜色。
  • SRC_IN:只显示源图像与目标图像重叠的部分,并且结果的 alpha 值取决于源图像的 alpha 值。比如源图像是一个不透明的圆形,目标图像是一个矩形,重叠部分仅显示圆形的内容。
  • DST_IN:只显示目标图像与源图像重叠的部分,结果的 alpha 值取决于源图像的 alpha 值。
  • SRC_OUT:显示源图像中不与目标图像重叠的部分,并且结果的 alpha 值取决于源图像的 alpha 值。
  • DST_OUT:显示目标图像中不与源图像重叠的部分,结果的 alpha 值取决于源图像的 alpha 值。
  • SRC_ATOP:显示目标图像与源图像重叠的部分,同时源图像的不重叠部分也会显示,结果的 alpha 值取决于源图像的 alpha 值。
  • DST_ATOP:显示目标图像与源图像重叠的部分,同时目标图像的不重叠部分也会显示,结果的 alpha 值取决于源图像的 alpha 值。
  • XOR:显示源图像和目标图像不重叠的部分,会根据两者的 alpha 值计算最终结果的透明度。