DrawModifierNode的工作原理和对绘制的精细影响

基础概念

在深入探究 DrawModifierNode 之前,我们先来了解几个基本的概念:

  1. 修饰符(Modifier) : 这是 Compose 中用来修改组件外观和行为的。比如,size()可以设置大小,padding()添加边距,background()设置背景颜色。

  2. 修饰符链(Modifier Chain) : 多个修饰符按顺序应用形成的链式结构,例如 Modifier.size(100.dp).background(Color.Red).padding(8.dp)。这些修饰符会按照声明顺序依次影响组件的最终表现。

  3. 修饰符节点(Modifier.Node) : 每个修饰符在内部都会创建一个节点对象,这些节点连接在一起形成了修饰符链。Compose 内部通过这些节点来管理和应用修饰符效果。

  4. 修饰符类型: 修饰符的类型,主要包括:

    • 布局修饰符(LayoutModifier) : 如 size()padding() 等,影响组件的尺寸和位置
    • 绘制修饰符(DrawModifier) : 如 background()border() 等,影响组件的视觉外观
    • 指针修饰符(PointerInputModifier) : 如 clickable() 等,处理交互事件

前言

DrawModifierNode 管理的是绘制,比如background()border()等Modifier修饰符背后使用的都是 DrawModifierNode。

理解了 DrawModifierNode 的工作原理,可以帮助我们更好地控制我们写出来的代码效果。

首先我们来看看DrawModifierNode是怎么用的。

基本用法

drawWithContent 函数

Compose 给我们提供了一个简便函数 drawWithContent() 来创建 DrawModifierNode 最简单的实现类,这样我们就可以在 drawWithContent() 的函数中去写绘制代码。

kotlin 复制代码
Box(
    Modifier.drawWithContent {
        // 在这里写绘制代码
    }
)

为什么它不叫draw()呢?

因为WithContent 是一个强提醒,提醒我们,我们现在写的是当前内容的绘制逻辑,包括原有内容,而不是原有内容之外进行绘制。 所以,如果你要让drawWithContent()函数不生效,与直接使用Box(Modifier)的效果相同,就需要在内部调用 drawContent() 方法;否则,会将原有的绘制内容清空。

这也表明了这个修饰符可以访问并控制元素原有内容的绘制。

drawContent 方法的作用

kotlin 复制代码
Box(
    Modifier.drawWithContent {
        // 绘制原有内容
        drawContent()
    }
)

什么是原有的绘制内容?

指的是drawWithContent之后(右侧)声明的修饰符所绘制的内容,以及组件本身的内容。

来验证一下:

kotlin 复制代码
Box(Modifier.size(40.dp).border(1.dp, Color.Gray).background(Color.Green))
// 调用 drawContent() 方法
Box(Modifier
    .size(40.dp)
    .border(1.dp, Color.Gray)
    .drawWithContent {
        drawContent()
    }
    .background(Color.Green))

// 不调用 drawContent() 方法
Box(Modifier
    .size(40.dp)
    .border(1.dp, Color.Gray)
    .drawWithContent {

    }
    .background(Color.Green))

可以发现,不调用drawContent() 方法,绿色区域确实被"擦除"了,虽然我们有background(Color.Green)修饰符,但由于drawWithContent中没有调用drawContent()绘制链被中断,后续的绘制修饰符无法工作,导致绿色背景没有机会被绘制。

那为什么这个drawContent()方法,Compose不自动调用,而是要我们程序员来手动调用呢?

很简单,给我们最大的自由度。我们可以控制当前的绘制内容是在原有内容之上,还是原有内容之下,还是两者都有,我们都可以去进行选择。

这种设计遵循了Compose的"显式优于隐式"原则,让我们对绘制流程有完全的控制权。就比如输入框TextField的输入事件onValueChange也是要我们手动提供,而不是Compose 直接帮我们完成。

基本绘制操作

那说到现在,绘制的代码具体该怎么写?

使用Canvas,调用它的方法。比如我们来绘制一条线,可以调用drawLine()

这些绘制方法都在 DrawScope 接口中定义。

kotlin 复制代码
Box(Modifier
    .size(40.dp)
    .border(1.dp, Color.Gray)
    .drawWithContent {
        // 绘制原有内容
        drawContent()

        // 绘制一条线
        drawLine(
            Color.Red, // 线的颜色
            Offset.Zero, // 线的起点 
            Offset(40.dp.toPx(), 40.dp.toPx()), // 线的终点
            strokeWidth = DefaultStrokeLineMiter // 线的粗细
        )
    }
    .background(Color.Green))

注意:Compose 的 Canvas 不能绘制文字,如果想要绘制文字,要拿到它底层的原生安卓中的Canvas来进行绘制。

kotlin 复制代码
drawIntoCanvas { canvas ->
   val nativeCanvas = canvas.nativeCanvas
   nativeCanvas.drawText("Hello", x, y, paint)
}

由于这里的重心是讲解DrawModifierNode的工作原理,所以更多的绘制就不讲了。

DrawModifierNode的工作原理

讲完了DrawModifierNode的基本写法,现在就正式开始讲解DrawModifierNode的原理。

要知道它是怎么对绘制产生影响的,我们就先看看 Compose 的绘制过程。

Composable函数在布局过程中会创建LayoutNode对象,它负责组件的测量、布局和绘制过程。就比如我们写的Box(),它会生成对应的LayoutNode对象。

而 Compose 的绘制过程发生在 LayoutNode 的 draw()方法中,我们进入源码看看。

从 LayoutNode 到 NodeCoordinator

LayoutNode.draw()方法

kotlin 复制代码
// LayoutNode.kt
fun draw(canvas: Canvas) = outerCoordinator.draw(canvas)

每个LayoutNode对象都有一个outerCoordinator,它是NodeCoordinator类型,负责协调这个LayoutNode的所有修饰符,简单来说它维护了当前LayoutNode的修饰符链。outerCoordinator是最外层的协调器,代表了修饰符链的起点。

比如Box(Modifier.padding()),对应的示意图:

其中LayoutModifierNodeCoordinator协调器是由布局修饰符创建的,比如这里的padding()。

其中会调用outerCoordinator的draw()方法,点击去看看。

NodeCoordinator.draw()方法

kotlin 复制代码
// NodeCoordinator.kt
internal var wrapped: NodeCoordinator? = null // 内层NodeCoordinator
internal var wrappedBy: NodeCoordinator? = null // 外层NodeCoordinator

fun draw(canvas: Canvas) {
    val layer = layer
    if (layer != null) { 
        layer.drawLayer(canvas)
    } else { // 如果图层为空
        val x = position.x.toFloat()
        val y = position.y.toFloat()
        canvas.translate(x, y)
        drawContainedDrawModifiers(canvas) //⚡️ 重点
        canvas.translate(-x, -y)
    }
}

这个draw()方法的作用是负责将当前 NodeCoordinator 所代表的布局节点绘制到提供的 Canvas 上

其中layer是图层的意思,并且是当前NodeCoordinator的图层,不过不重要,我们当前关注的是没有layer的情况,即直接在当前画布上绘制。所以重要的是里面的drawContainedDrawModifiers()方法,点进去看看。

绘制链的传递机制

NodeCoordinator.drawContainedDrawModifiers()方法

kotlin 复制代码
// NodeCoordinator.kt
fun drawContainedDrawModifiers(canvas: Canvas) {
    // 获取修饰符链中第一个 Draw 类型的修饰符节点
    val head = head(Nodes.Draw) 
    if (head == null) {
        // 如果当前 NodeCoordinator 没有 Draw 类型的修饰符节点
        performDraw(canvas)
    } else {
        // 如果有 Draw 类型的修饰符节点,就执行它的绘制逻辑
        val drawScope = layoutNode.mDrawScope
        drawScope.draw(canvas, size.toSize(), this, head)
    }
}

这个方法中,有两条分支,我们先来看看第一条分支:当前 NodeCoordinator节点没有绘制修饰符。

点击去performDraw(canvas)

kotlin 复制代码
fun performDraw(canvas: Canvas) {
    wrapped?.draw(canvas)
}

可以看见方法内部调用了NodeCoordinator节点的内层节点的 draw() 方法(wrapped代表的是内部NodeCoordinator),这个 draw() 方法,我们在前面已经看到过了,就是NodeCoordinator的draw()方法,它的作用是将当前 NodeCoordinator 所代表的布局节点绘制到提供的 Canvas 上。

所以第一条分支的作用就是:如果当前NodeCoordinator没有绘制修饰符,就去绘制它的内部NodeCoordinator。

比如这行代码Box(Modifier.size(40.dp).padding(12.dp)),它会生成两个修饰符节点:size和padding。由于这两个都是Layout修饰符而不是Draw修饰符,所以在绘制阶段,当前NodeCoordinator的head(Nodes.Draw)会返回null,于是会执行performDraw(canvas),将绘制请求传递给内层NodeCoordinator,最终到达InnerNodeCoordinator(NodeCoordinator的子类),也就是Box本身的绘制逻辑。

其中黄色区域就是Box本身的绘制逻辑。

接下来,看第二条分支:当前节点有绘制相关的修饰符。

那么就会调用获取当前 NodeCoordinator 节点的绘制作用域对象中的 draw() 方法执行实际的绘制,点击去看看。

kotlin 复制代码
fun draw(
    canvas: Canvas,
    size: Size,
    coordinator: NodeCoordinator,
    drawNode: Modifier.Node,
) {
    // 分发绘制请求给 Draw 类型的修饰符节点
    drawNode.dispatchForKind(Nodes.Draw) {
        drawDirect(canvas, size, coordinator, it)
    }
}

里面又调用了 drawDirect() 方法

NodeCoordinator.drawDirect()方法

kotlin 复制代码
// LayoutNodeDrawScope.kt
internal fun drawDirect(
    canvas: Canvas,
    size: Size,
    coordinator: NodeCoordinator,
    drawNode: DrawModifierNode,
) {
    // 临时设置当前绘制节点,执行完后恢复
    val previousDrawNode = this.drawNode
    this.drawNode = drawNode
    canvasDrawScope.draw(
        coordinator,
        coordinator.layoutDirection,
        canvas,
        size
    ) {
        with(drawNode) {
            [email protected]() // 调用DrawModifierNode的draw()方法
        }
    }
    this.drawNode = previousDrawNode // 恢复原来的绘制节点
}

这个方法的核心是调用了with(drawNode) { [email protected]() },这行代码实际上是在调用DrawModifierNode接口中定义的draw()方法。

这个接口不就是我们一直在讲的DrawModifierNode接口吗?

kotlin 复制代码
interface DrawModifierNode : DelegatableNode {
    fun ContentDrawScope.draw() 
    fun onMeasureResultChanged() {}
}

所以这个地方,是我们在drawWithContent {}的大括号中编写的绘制代码最终被执行的地方。

drawContent 方法的实现细节

我们前面提到,在drawWithContent {}中必须手动调用drawContent()才能显示原有内容。那么这个drawContent()方法究竟做了什么?让我们看看它的实现:

kotlin 复制代码
// LayoutNodeDrawScope.kt
override fun drawContent() {
    drawIntoCanvas { canvas ->
        val drawNode = drawNode!!
        val nextDrawNode = drawNode.nextDrawNode() // 寻找下一个Draw类型的修饰符节点
        if (nextDrawNode != null) {
            nextDrawNode.dispatchForKind(Nodes.Draw) {
                it.performDraw(canvas) // 绘制下一个Draw修饰符
            }
        } else { // 当前NodeCoordinator范围内已经没有下一个Draw类型的修饰符节点了
            val coordinator = drawNode.requireCoordinator(Nodes.Draw)
            val nextCoordinator = if (coordinator.tail === drawNode.node)
                coordinator.wrapped!! // 如果当前节点是最后一个,就使用内层NodeCoordinator
            else
                coordinator
            nextCoordinator.performDraw(canvas) // 继续绘制流程
        }
    }
}

这段代码揭示了drawContent()的核心工作:

  1. 寻找当前Draw修饰符节点之后的下一个Draw修饰符节点
  2. 如果找到了,就执行该节点的绘制逻辑
  3. 如果没找到,就将绘制请求传递给内层的NodeCoordinator

这就形成了一个完整的绘制链,每个Draw修饰符都有机会参与绘制,并通过调用drawContent()将绘制请求传递给下一个修饰符。

这也就解释了为什么不调用drawContent()drawWithContent之后声明的修饰符所绘制的内容都会被清空,因为绘制请求传递中断了。

并且前面提到清除的内容还包括了组件本身的内容,就比如 Text 组件的内容也会被清除。

kotlin 复制代码
Row {
    Text(
        "Hello World!",
        Modifier
            .border(1.dp, Gray)
            .drawWithContent {
                drawContent()
            }
    )

    Spacer(Modifier.width(8.dp))

    Text(
        "Hello World!",
        Modifier
            .border(1.dp, Gray)
            .drawWithContent {

            }
    )
}

这是因为Text组件内部的绘制也是使用的Draw修饰符,而组件的绘制是在InnerNodeCoordinator节点中,在绘制链的末尾,所以当绘制链中断时,组件自然得不到绘制。

修饰符的执行顺序与绘制顺序

理解了上面的原理,我们就能解释以下代码了:

koltin 复制代码
Box(
    Modifier
        .border(1.dp,Color.Gray)
        .requiredSize(80.dp)
        .background(Color.Green)
        .requiredSize(40.dp)
)

为什么它是这样?绿色区域的尺寸是40dp?

先来一张图:

首先只有Layout布局修饰符会创建LayoutModifierNodeCoordiantor,比如代码中的requiredSize(80.dp)、requiredSize(40.dp)。而绘制修饰符归属于其右边的NodeCoordiantor,就是requiredSize(40.dp)所创建的LayoutModifierNodeCoordiantor(图中红色NodeCoordiantor),所以绿色区域的大小就是40dp了。

那为什么绘制修饰符归属于归属于其右侧最近的 NodeCoordinator?

  • 测量和布局是从外到内执行的(从左到右)
  • 绘制是从内到外执行的(从右到左)

例如 Modifier.background(Red).requiredSize(80.dp).background(Green).requiredSize(40.dp) ,测量和布局时,先应用 requiredSize(80.dp),再应用 requiredSize(40.dp);绘制时,先绘制 background(Green),再绘制 background(Red)

过程:

  1. requiredSize(80.dp) 会创建一个 LayoutModifierNodeCoordinator(称为 A)
  2. requiredSize(40.dp) 创建一个 LayoutModifierNodeCoordinator(称为 B)
  3. background(Green) 是绘制修饰符,被添加到其右侧的 NodeCoordinator 中,也就是 B 中
  4. background(Red) 是绘制修饰符,被添加到其右侧的 NodeCoordinator 中,也就是 A 中

由于绘制发生在布局之后,绘制修饰符必须依赖于某个已确定尺寸的布局节点,知道它应该作用的区域大小。而右侧的 NodeCoordinator 已经完成了布局计算,绘制修饰符需要使用这个布局信息来确定自己的绘制范围因此,绘制修饰符会归属于其右侧的 NodeCoordinator。

那你可能又有疑问了?

绘制时,绿色区域先绘制,红色区域后绘制,红色区域不会遮盖住绿色区域吗?

红色背景和绿色背景绘制在不同的画布区域上,它们不存在重叠绘制的问题。内层绘制完成后的结果会被保留,不会被外层覆盖。并且外层在绘制时,且会避开内层已绘制的区域。

就比如上述例子,红色区域只绘制在 80dp 区域中不包含 40dp 中心区域的部分

相关推荐
_一条咸鱼_10 小时前
Android大厂面试通关秘籍
android·面试·android jetpack
_一条咸鱼_18 小时前
Android嵌套滑动详解
android·面试·android jetpack
alexhilton2 天前
深入理解Jetpack Compose中的函数的执行顺序
android·kotlin·android jetpack
Wgllss2 天前
Android图片处理:多合一,多张生成视频,裁剪,放大缩小,滤镜色调,饱和度,亮度调整
android·架构·android jetpack
雨白2 天前
LayoutModifierNode 和 Modifier.layout()
android jetpack
_一条咸鱼_2 天前
Android Picasso 调度模块深度剖析(七)
android·面试·android jetpack
_一条咸鱼_3 天前
Android Picasso 监听器模块深度剖析(八)
android·面试·android jetpack
_一条咸鱼_3 天前
Android Picasso 网络请求模块深度剖析(四)
android·面试·android jetpack
_一条咸鱼_3 天前
Android Picasso 网络请求模块深度剖析(二)
android·面试·android jetpack