基础概念
在深入探究 DrawModifierNode 之前,我们先来了解几个基本的概念:
-
修饰符(Modifier) : 这是 Compose 中用来修改组件外观和行为的。比如,
size()
可以设置大小,padding()
添加边距,background()
设置背景颜色。 -
修饰符链(Modifier Chain) : 多个修饰符按顺序应用形成的链式结构,例如
Modifier.size(100.dp).background(Color.Red).padding(8.dp)
。这些修饰符会按照声明顺序依次影响组件的最终表现。 -
修饰符节点(Modifier.Node) : 每个修饰符在内部都会创建一个节点对象,这些节点连接在一起形成了修饰符链。Compose 内部通过这些节点来管理和应用修饰符效果。
-
修饰符类型: 修饰符的类型,主要包括:
- 布局修饰符(LayoutModifier) : 如
size()
、padding()
等,影响组件的尺寸和位置 - 绘制修饰符(DrawModifier) : 如
background()
、border()
等,影响组件的视觉外观 - 指针修饰符(PointerInputModifier) : 如
clickable()
等,处理交互事件
- 布局修饰符(LayoutModifier) : 如
前言
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()
的核心工作:
- 寻找当前Draw修饰符节点之后的下一个Draw修饰符节点
- 如果找到了,就执行该节点的绘制逻辑
- 如果没找到,就将绘制请求传递给内层的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)
。
过程:
requiredSize(80.dp)
会创建一个 LayoutModifierNodeCoordinator(称为 A)requiredSize(40.dp)
创建一个 LayoutModifierNodeCoordinator(称为 B)background(Green)
是绘制修饰符,被添加到其右侧的 NodeCoordinator 中,也就是 B 中background(Red)
是绘制修饰符,被添加到其右侧的 NodeCoordinator 中,也就是 A 中
由于绘制发生在布局之后,绘制修饰符必须依赖于某个已确定尺寸的布局节点,知道它应该作用的区域大小。而右侧的 NodeCoordinator 已经完成了布局计算,绘制修饰符需要使用这个布局信息来确定自己的绘制范围因此,绘制修饰符会归属于其右侧的 NodeCoordinator。
那你可能又有疑问了?
绘制时,绿色区域先绘制,红色区域后绘制,红色区域不会遮盖住绿色区域吗?
红色背景和绿色背景绘制在不同的画布区域上,它们不存在重叠绘制的问题。内层绘制完成后的结果会被保留,不会被外层覆盖。并且外层在绘制时,且会避开内层已绘制的区域。
就比如上述例子,红色区域只绘制在 80dp 区域中不包含 40dp 中心区域的部分。