LayoutModifierNode 和 Modifier.layout()

前言

我们在写代码时,经常会有很多疑问:为什么这个组件的显示效果和我想的不一样?我该如何精确地控制它的位置和尺寸?Modifier 链的顺序到底有什么影响?

其实这都和Compose 的测量与布局流程 有关,本文将带你深入理解 Modifier.layout() 以及它背后的核心实现 LayoutModifierNode,帮助你理清 Compose 布局的原理和实际应用方式。

Modifier.layout()

什么是 Modifier.layout()?

Modifier.layout()是一个修饰符,可以去自定义目标组件的测量和放置过程,从而修改目标组件的尺寸和位置。

kotlin 复制代码
// LayoutModifier.kt
fun Modifier.layout(
    measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) = this then LayoutElement(measure)

这个函数接收一个 lambda 表达式作为参数,该表达式会在布局过程中被调用,负责测量组件并确定其位置。

方法解析

lambda表达式接收两个参数:

kotlin 复制代码
Modifier.layout { measurable, constraints ->
    // 自定义测量和布局
    // 返回 MeasureResult
}
  • measurable:表示当前被修饰的组件,调用它的measure()方法,就可以确定组件的实际尺寸。

  • constraints:表示父组件对当前组件的约束条件,定义了组件可用的最大和最小尺寸。

Constraints 主要包含 minWidthmaxWidthminHeightmaxHeight 四个属性,用于限定组件的可用空间。


lambda表达式需要返回一个MeasureResult对象,它包含了组件的最终尺寸、对齐线信息,以及通过placeChildren()方法执行在layout()函数中定义的放置逻辑。

kotlin 复制代码
// MeasureResult.kt
interface MeasureResult {
    val width: Int // 组件的最终宽度
    val height: Int // 组件的最终高度
    val alignmentLines: Map<AlignmentLine, Int> // 对齐线信息
    fun placeChildren() // 放置子组件的逻辑
}

我们通常调用MeasureScope 提供的layout()函数来创建MeasureResult实例:

kotlin 复制代码
layout(width, height) { // 最终尺寸
    // 在这里放置子组件
    placeable.placeRelative(x, y) // 内容的位置偏移
}

基本用法

我们现在来看看layout()函数最基本的用法,即不影响组件的测量和布局的写法:

kotlin 复制代码
Text(text = "Hello World.", modifier = Modifier.layout { measurable, constraints ->
    val placeable = measurable.measure(constraints) // 测量Text组件

    // 设置最终尺寸
    layout(width = placeable.width, height = placeable.height) {
        // 设置Text组件的偏移量(无偏移)
        placeable.placeRelative(0, 0)
    }
})

这段代码和直接写Text(text = "Hello World.")的效果相同,我们使用了默认的测量或布局逻辑。

示例:自定义间距修饰符

我们现在来用 layout() 实现一个自定义的间距修饰符,功能类似于官方的 Modifier.padding()

kotlin 复制代码
/**
 * 自定义间距修饰符
 * 在组件四周添加指定的间距
 */
fun Modifier.spacing(
    all: Dp = 0.dp,
    start: Dp = all,
    top: Dp = all,
    end: Dp = all,
    bottom: Dp = all
) = layout { measurable, constraints ->
    // 转换 dp 到像素(px)
    val startPx = start.roundToPx()
    val topPx = top.roundToPx()
    val endPx = end.roundToPx()
    val bottomPx = bottom.roundToPx()

    // 计算水平和垂直间距总和
    val horizontalPadding = startPx + endPx
    val verticalPadding = topPx + bottomPx

    // 修改约束条件,减少内容可用空间
    val newConstraints = constraints.copy(
        maxWidth = if (constraints.maxWidth != Constraints.Infinity) {
            max(constraints.maxWidth - horizontalPadding, 0)
        } else Constraints.Infinity,
        maxHeight = if (constraints.maxHeight != Constraints.Infinity) {
            max(constraints.maxHeight - verticalPadding, 0)
        } else Constraints.Infinity,
        // 如果原始约束有最小宽度/高度要求,也需要相应调整
        minWidth = max(constraints.minWidth - horizontalPadding, 0),
        minHeight = max(constraints.minHeight - verticalPadding, 0)
    )

    // 测量内容,使用修改后的约束条件来测量
    val placeable = measurable.measure(newConstraints)

    // 计算最终尺寸
    val width = placeable.width + horizontalPadding
    val height = placeable.height + verticalPadding

    // 返回测量结果
    layout(width, height) {
        // 放置子组件(有偏移)
        placeable.placeRelative(startPx, topPx)
    }
}

使用示例:

kotlin 复制代码
Column(Modifier.fillMaxWidth()) {
    Text(
        "标准 padding",
        Modifier
            .background(Color.Yellow)
            .padding(16.dp)
            .background(Color.LightGray)
    )
    
    Spacer(Modifier.height(8.dp))
    
    Text(
        "自定义 spacing",
        Modifier
            .background(Color.Yellow)
            .spacing(top = 24.dp, bottom = 8.dp, start = 16.dp, end = 16.dp)
            .background(Color.LightGray)
    )
}

示意图:

其中黄色部分都是间距,灰色部分才是组件的实际内容区域。

constraints参数本来是外层组件对当前被修饰的组件的尺寸限制,当我们使用layout()修饰符后,我们插入了一个LayoutModifierNode到布局链中,它可以拦截和修改约束条件。这个节点成为了约束传递的中间者,可以根据需要修改约束条件后再传递给被修饰的组件,就像上面的示例一样。

注意所有参数值都是像素(px),而不是 Dp。如果要使用 Dp,需要转换,调用roundToPx()函数,比如8.dp.roundToPx(), 不过使用这个函数要在Density的上下文中。

使用场景

Modifier.layout()是用来修改组件的尺寸和位置的,它的本质是给组件的位置和尺寸添加装饰效果,不干涉这个组件内部的测量和布局规则。

所以它的使用场景就很明确了,如果你对一个组件的本身很满意,只要对组件的尺寸、位置做一些额外的调整,就可以使用Modifier.layout();如果对组件的内部布局不满足,就要去修改内部源码(如果源码不属于你,可以将代码抄一份过来,再进行修改),不能使用Modifier.layout()了,因为它触及不了那么深的地方。

LayoutModifierNode

LayoutModifierNodeModifier.layout() 背后的核心实现,它是一个接口,它还是Modifier.Element的子类,专门用于修改布局过程的修饰符节点。它通过实现measure方法来拦截和修改测量过程。

kotlin 复制代码
interface LayoutModifierNode : DelegatableNode {
    // 核心方法,负责修改测量过程
    fun measure(
        measureScope: MeasureScope,
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult
}

那LayoutModifierNode是怎么影响测量和布局过程的呢?

我们先来看看元素的测量和布局过程。

元素的测量和布局过程

Composable函数,在实际运行时,会生成LayoutNode对象,它会去做实际的测量、布局、绘制、触摸反馈等工作。

测量和布局工作主要通过内部的 remeasure()replace() 函数完成。

我们来看看测量函数 remeasure()

kotlin 复制代码
// 位于LayoutNode.kt
fun remeasure(
    constraints: Constraints? = layoutDelegate.lastConstraints
): Boolean {
    //..
    measurePassDelegate.remeasure(constraints) 
}

其中measurePassDelegate是专门用于做测量的工具,点进去这个remeasure()函数:

kotlin 复制代码
// MeasurePassDelegate.kt
fun remeasure(constraints: Constraints): Boolean {
    // ..
    performMeasure(constraints)     
}

代码很长,但是不必管它,关键代码就只有performMeasure(constraints),它是真正做测量工作的,点进去:

kotlin 复制代码
fun performMeasure(constraints: Constraints) {
    //..
    performMeasureBlock
    //..
}

performMeasureBlocklambda表达式,它是实际完成测量工作的,点进去可以看到:

kotlin 复制代码
val performMeasureBlock: () -> Unit = {
    outerCoordinator.measure(performMeasureConstraints)
}

再点进去这个measure()方法,发现竟然来到了Measurable接口:

kotlin 复制代码
interface Measurable : IntrinsicMeasurable {
    fun measure(constraints: Constraints): Placeable
}

说明outerCoordinator还没有实现measure()方法,所以要看看创建outerCoordinator时,传入的实际对象类型,那里面才真正实现了measure()方法。


回退到performMeasureBlocklambda表达式中,点击outerCoordinator到它的定义处:

kotlin 复制代码
class LayoutNodeLayoutDelegate(
    private val layoutNode: LayoutNode,
) {
    val outerCoordinator: NodeCoordinator
        get() = layoutNode.nodes.outerCoordinator
    // ..
}

点击 layoutNode.nodes:

kotlin 复制代码
// LayoutNode.kt
internal val nodes = NodeChain(this)

再进入NodeChain:

kotlin 复制代码
internal class NodeChain(val layoutNode: LayoutNode) {
    internal val innerCoordinator = InnerNodeCoordinator(layoutNode)
    internal var outerCoordinator: NodeCoordinator = innerCoordinator
        private set
}

发现outerCoordinator被赋值为innerCoordinator,而innerCoordinator的类型是InnerNodeCoordinator。

我们进入到InnerNodeCoordinator中查看measure()方法的具体实现:

kotlin 复制代码
override fun measure(constraints: Constraints): Placeable =
    performingMeasure(constraints) {
        // before rerunning the user's measure block reset previous measuredByParent for children
        layoutNode.forEachChild {
            it.lookaheadPassDelegate!!.measuredByParent =
                LayoutNode.UsageByParent.NotUsed
        }
        val measureResult = with(layoutNode.measurePolicy) {
            measure(
                layoutNode.childLookaheadMeasurables,
                constraints
            )
        }
        measureResult
    }

就是在这完成最终、实际的测量的。

方法中调用了measure()方法,返回了一个测量结果给measureResult,用来稍后在布局流程里面用来摆放组件用的。

测量过程:

现在我们知道了测量过程中会干什么:会调用NodeCoordinator中的measure()方法

在布局过程中会获取测量结果,去应用到实际的组件上。

怎么影响测量和布局过程

那LayoutModifierNode到底是怎么影响的?

它的工作原理都包含在了LayoutNode的modifier属性里了。

LayoutNode是运行时实际代表Composable函数的UI节点,我们给每一个Composable函数填写的Modifier参数,经过预处理工作(主要是去掉ComposedModifier),最终会成为LayoutNode的modifier属性,它是一个Modifier链。

我们现在来看看LayoutNode的modifier属性的set()函数:

kotlin 复制代码
override var modifier: Modifier = Modifier
    set(value) {
        //..
        nodes.updateFrom(value) 
        //..
    }

nodes.updateFrom(value)会将我们的Modifier链,转换成Modifier.Node双向链表,将Node双向链表交给nodes进行管理。

kotlin 复制代码
NodeChain(..) {
    val innerCoordinator: InnerNodeCoordinator
    var outerCoordinator: NodeCoordinator
    val tail: Modifier.Node
    var head: Modifier.Node
}

并且方法中会调用syncCoordinators()进行同步,为每个 Modifier.Node 关联对应的 NodeCoordinator 辅助对象,这些 NodeCoordinator 形成一个链式结构,在测量过程中,约束条件从外层NodeCoordinator传递到内层,然后测量结果再从内层传回外层,最终确定组件的尺寸和位置。

LayoutModifierNodeCoordinator 是 LayoutModifierNode 对应的 NodeCoordinator 实现,它的 measure 方法就是 LayoutModifierNode 影响测量过程的关键,每个 LayoutModifierNode 都可以修改约束条件

Modifier 链顺序的实际影响

那我们知道了LayoutModifierNode是怎么影响测量和布局过程:在测量过程中修改约束条件。

那么它Modifier 链顺序的实际影响是什么?

比如

kotlin 复制代码
Box(Modifier.size(100.dp).size(200.dp)) // 最终大小 100dp
Box(Modifier.size(200.dp).size(100.dp)) // 最终大小 200dp

为什么是这样?

情况一:第一个 size(100.dp)把父约束(比如可能是无穷大)限制成最大100dp,再传递下去。而第二个 size(200.dp)接收到的约束已经是最大100dp了,再怎么设置200dp也没用,因为不能突破前面传下来的限制。 于是它会在"最大100dp"的约束下测量,最终尺寸就是100dp。

Box的最终大小是100.dp

情况二:外层的size(200.dp)将约束设为最大200dp,然后内层的size(100.dp)将约束进一步限制为最大100dp。内容在100dp的约束下测量,得到100dp的尺寸。然后这个测量结果向外传递,外层的size(200.dp)修饰符接收到这个结果,但它会强制将最终尺寸设置为200dp(因为这是它的固定尺寸设置)。

所以Box的最终大小是200dp。

相关推荐
我命由我1234512 天前
Android 解绑服务问题:java.lang.IllegalArgumentException: Service not registered
android·java·开发语言·java-ee·安卓·android jetpack·android-studio
我命由我1234513 天前
MQTT - Android MQTT 编码实战(MQTT 客户端创建、MQTT 客户端事件、MQTT 客户端连接配置、MQTT 客户端主题)
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
前行的小黑炭14 天前
Android LiveData源码分析:为什么他刷新数据比Handler好,能更节省资源,解决内存泄漏的隐患;
android·kotlin·android jetpack
_一条咸鱼_14 天前
深度剖析:Java PriorityQueue 使用原理大揭秘
android·面试·android jetpack
_一条咸鱼_14 天前
揭秘 Java PriorityBlockingQueue:从源码洞悉其使用原理
android·面试·android jetpack
_一条咸鱼_14 天前
深度揭秘:Java LinkedList 源码级使用原理剖析
android·面试·android jetpack
_一条咸鱼_14 天前
深入剖析 Java LinkedBlockingQueue:源码级别的全面解读
android·面试·android jetpack
_一条咸鱼_14 天前
探秘 Java DelayQueue:源码级剖析其使用原理
android·面试·android jetpack
_一条咸鱼_14 天前
揭秘 Java ArrayDeque:从源码到原理的深度剖析
android·面试·android jetpack
_一条咸鱼_14 天前
深入剖析!Android WebView使用原理全解析:从源码底层到实战应用
android·面试·android jetpack