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) {
            this@LayoutNodeDrawScope.draw() // 调用DrawModifierNode的draw()方法
        }
    }
    this.drawNode = previousDrawNode // 恢复原来的绘制节点
}

这个方法的核心是调用了with(drawNode) { this@LayoutNodeDrawScope.draw() },这行代码实际上是在调用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 中心区域的部分

相关推荐
shenshizhong39 分钟前
Compose + Mvi 架构的玩android 项目,请尝鲜
android·架构·android jetpack
alexhilton3 天前
学会在Jetpack Compose中加载Lottie动画资源
android·kotlin·android jetpack
ljt27249606617 天前
Compose笔记(六十一)--SelectionContainer
android·笔记·android jetpack
QING6187 天前
Jetpack Compose 中的 ViewModel 作用域管理 —— 新手指南
android·kotlin·android jetpack
惟恋惜8 天前
Jetpack Compose 的状态使用之“界面状态”
android·android jetpack
喜熊的Btm8 天前
探索 Kotlin 的不可变集合库
kotlin·android jetpack
惟恋惜8 天前
Jetpack Compose 界面元素状态(UI Element State)详解
android·ui·android jetpack
惟恋惜8 天前
Jetpack Compose 多页面架构实战:从 Splash 到底部导航,每个 Tab 拥有独立 ViewModel
android·ui·架构·android jetpack
alexhilton9 天前
Jetpack Compose 2025年12月版本新增功能
android·kotlin·android jetpack
モンキー・D・小菜鸡儿11 天前
Android Jetpack Compose 基础控件介绍
android·kotlin·android jetpack·compose