深入理解 ParentDataModifierNode:为布局打辅助

142 阅读13分钟

前言

ParentDataModifierNode 是在布局过程(测量和布局)中,起到一个辅助作用的 Modifier。

我们先来看看这段示例代码:

Row(
    Modifier
        .border(1.dp, Color.LightGray)
        .size(150.dp, 40.dp)
) {
    Box(
        Modifier
            .size(40.dp)
            .background(Color.Blue)
    )
    Box(
        Modifier
            .size(40.dp)
            .background(Color.Red)
    )
    Box(
        Modifier
            .size(40.dp)
            .background(Color.Green)
    )
}

运行效果是:

如果我们要让绿色区域填满剩余空间,可以使用 weight() 修饰符,像这样:

Row(
    Modifier
        .border(1.dp, Color.LightGray)
        .size(150.dp, 40.dp)
) {
    Box(
        Modifier
            .size(40.dp)
            .background(Color.Blue)
    )
    Box(
        Modifier
            .size(40.dp)
            .background(Color.Red)
    )
    Box(
        Modifier
            .size(40.dp)
            .background(Color.Green)
+            .weight(1f)
    )
}

运行效果:

那么 weight() 修饰符的背后是哪个 Modifier 接口?

看起来好像是 LayoutModifierNode 接口,因为它负责组件的测量和布局嘛。

那我们来验证一下,进入 weight() 的源码,发现是抽象方法,那要去看它的具体实现:

跳到具体的实现处,快捷键:Ctrl + Alt + B

// Row.kt
object RowScopeInstance : RowScope {
    override fun Modifier.weight(weight: Float, fill: Boolean): Modifier {
        ...
        return this.then(
            LayoutWeightElement(
               ...
            )
        )
    }  
    ...
}

then() 方法内部创建了 LayoutWeightElement 对象,点进去:

// RowColumnImpl.kt
class LayoutWeightElement(
    val weight: Float,
    val fill: Boolean,
) : ModifierNodeElement<LayoutWeightNode>() {
    ...
}

ModifierNodeElement是帮助构建和维护 LayoutNode 视图树的,不管它,再点进去 LayoutWeightNode:

class LayoutWeightNode(
    var weight: Float,
    var fill: Boolean,
) : ParentDataModifierNode, Modifier.Node() {
    ...
}

Modifier.Node 是真正在渲染树中执行修饰操作的组件,不管它,看到真正实现的是 ParentDataModifierNode 接口,而不是我们认为的 LayoutModifierNode 接口。

现在你也许会问:ParentDataModifierNode 到底是什么?它的作用又是什么?为什么要用它而不是 LayoutModifierNode 来实现权重功能?它又是如何实现这些效果的?

ParentDataModifierNode 的作用

ParentData 翻译过来是 “父数据” 的意思,表明了虽然你将这个修饰符设置在子组件(Composable函数)上,但它实际上是给父组件使用的,父组件就是当前组件的外层组件。比如在上面的例子中,Row 就是 Box 的父组件,我们给 Box 设置的 weight 修饰符是给 Row 使用的。

具体来说,LayoutModifierNode 和 ParentDataModifierNode 的区别在于:LayoutModifierNode 是直接干预组件自身的测量和布局过程,拦截并修改测量过程,直接影响组件的尺寸和位置;而 ParentDataModifierNode 是在父组件测量子组件时,为父组件提供额外信息,帮助父组件更好地测量和布局子组件。

所以 weight() 是无法通过 LayoutModifierNode 实现的,计算一个组件的权重布局,组件不能仅凭自身信息就计算出自己的尺寸,我们还需要知道它的兄弟组件的权重(weight)值以及可用空间,才能得到实际尺寸。在LayoutModifierNode 中,组件无法获取兄弟组件的信息,所以没法完成权重效果。

但我们可以使用另一种更加方便的解决方法:让父组件去掌握每个子组件的信息,然后做统一的计算和协调,就是使用 ParentDataModifierNode。

当然使用 ParentDataModifierNode 的场景不只有 weight(),比如说还有一个 layoutId() 修饰符,你可以使用它来设置组件的id(标识符),这个id可以重复,其实有点像标签(tag)。

@Stable
fun Modifier.layoutId(layoutId: Any) 

layoutId() 修饰符常常与 Layout() 函数配合使用,Layout() 是各个组件最底层的实现,为什么要和它配合呢?

@UiComposable
@Composable
inline fun Layout(
    content: @Composable @UiComposable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    ...
}

因为它可以定制测量和布局规则,就比如 Row、Column、Box这些组件,都是在定制测量和布局规则,简单来说可以自定义布局。

然后在这个过程中可以获取你给组件设置的id,从而对不同id的组件应用不同的测量策略。比如现在我要写一个自定义布局:

当然了,这里只是写一个大概框架,并不是真实地去完成自定义布局

// 自定义布局
@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        // 遍历子组件
        measurables.forEach { measurable ->
            // 获取layoutId
            val id = measurable.layoutId
            // 根据id的不同,做出不同的测量策略
            // id 可能为空,因为子组件没有设置 layoutId
            ...
        }

        layout(0,0) {
            /* 布局逻辑 */
        }

    }
}

这样看 layoutId() 好像是一个"半成品",因为它需要配合自定义 Layout 布局使用,而 weight() 这样的"成品" 设置后就能直接生效,界面上能直接看到效果。

总之 ParentDataModifier 的核心作用是:给子组件设置属性,让父组件在测量和布局过程中使用这些属性来辅助测量和布局(作用在子组件上)。它是实现复杂布局逻辑的重要工具,特别是当组件的布局需要考虑兄弟组件信息时。

ParentDataModifierNode 的实现

在实现 ParentDataModifierNode 之前,我们要明确一个重要前提:

ParentDataModifierNode 的用法不是简单地写一个修饰符函数,然后在组件上调用一下,就可以了,这是没用的。只有当父组件已经实现了对 ParentDataModifierNode 提供的数据的处理逻辑时,修饰符才会生效,否则就是媚眼抛给瞎子看。

就像钥匙和锁一样:就算你创造了一把新钥匙,但是世界上没有任何一种锁可以和你匹配,那么你这把钥匙是没有意义的,至少它开不了锁。

并且现成组件的内部逻辑是固定的,我们无法在它的内部添加上数据的处理逻辑,也不会针对它的内部编写提供数据的修饰符,所以我们使用的是这些组件提供的专用函数。比如 Row 组件提供的 weight() 函数。

基本实现步骤

清楚了这个前提后,ParentDataModifierNode 的正确用法就知道了:

当我们自定义Composable函数时,只有我们需要用到子组件中的一些特定属性,我才会需要用到 ParentDataModifierNode。

这个时候要做三件事:

  1. 因为这是一个自定义的布局,所以要使用 Layout() 函数,这样才能拿到每个子组件对应的 measurable 对象。
  2. 拿到子组件所提供的数据,通过measurable.parentData获取
  3. 创建可以提供数据的修饰符函数,实现 ModifierNodeElement 和 ParentDataModifierNode

我们根据上面的步骤来完成。

第一步只需这样就可以了:

@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        // 测量和布局逻辑
        measurables.forEach { measurable ->

        }

        layout(width = 0, height = 0) { // 宽高随便填
            /* 布局逻辑 */
        }

    }
}

第二步,也仅需获取 measurableparentData 属性。

@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        measurables.forEach { measurable ->
+            val data = measurable.parentData
        }

        layout(width = 0, height = 0) {

        }
    }
}

那么 parentData 的类型是什么呢?

点进去可以看到是 Any?,你不知道parentData会不会被提供,被提供了,也不知道提供的是什么类型的。但我们是组件的开发者,规则由我们制定,所以我们是知道 parentData 是什么类型的。

这里把它定为 String 类型,只需将 parentData转为 String 类型就可以了。

@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        measurables.forEach { measurable ->
+            val data = measurable.parentData as? String
        }

        layout(width = 0, height = 0) { 

        }

    }
}

as后加一个?,这是安全的类型转换,失败时返回 null,而不是抛异常(会导致软件崩溃)。然后具体的怎么测量、布局,我们不去管它,因为还不会自定义布局。

接着第三步,提供数据,我们仿造 weight() 修饰符的内部实现,来完成:

fun Modifier.data(data: String): Modifier = this.then(DataElement(data))

class DataElement(val data: String) : ModifierNodeElement<DataNode>() {
    // 创建
    override fun create(): DataNode {
        return DataNode(data)
    }

    // 更新
    override fun update(node: DataNode) {
        node.data = data
    }

    override fun equals(other: Any?): Boolean {
        if(this === other) return true
        if (other !is DataElement) return false
        return data == other.data
    }

    override fun hashCode(): Int {
        return data.hashCode()
    }


}

class DataNode(var data: String) : ParentDataModifierNode, Modifier.Node() {
    override fun Density.modifyParentData(parentData: Any?): Any? = data
}

这样就完成了,可以去正常使用。

Modifier.data("这是我提供的信息")

处理多个属性

我们来看看 parentData 参数代表什么,它表示右侧提供的数据。

比如这样写:

Modifier.data("然后轮到我来提供").data("我先提供一个信息")

"我先提供一个信息" 会被当作Density.modifyParentData()函数的参数,也就是会被第一个 data() 修饰符用到。

因为我们当前 modifyParentData() 函数的实现逻辑是丢弃掉参数值,使用内部对象值,所以最终提供给自定义布局中的值是 "然后轮到我来提供"

那这个参数有什么用,反正我们都是丢弃掉,其实它的作用是让我们不要忽略参数,我们可以将内部对象值和参数值进行融合,再返回得到的结果。

当前场景我们是不需要进行融合的,我们只是给当前组件提供一个属性,不需要多次调用,多次调用说明写错了。比如设置组件的权重,你不会这样去写:Modifier.weight(1f).weight(2f).weight(3f)

但是同一个组件也有可能会有多个属性,比如我这里再增加一个 show 属性,如果为true,就显示组件,为false,就隐藏组件。

fun Modifier.data(data: String): Modifier = this.then(DataElement(data))

fun Modifier.show(show: Boolean): Modifier = this.then(ShowElement(show))

// 数据元素
class DataElement(val data: String) : ModifierNodeElement<DataNode>() {
    // 创建
    override fun create(): DataNode {
        return DataNode(data = data)
    }

    // 更新
    override fun update(node: DataNode) {
        node.data = data
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is DataElement) return false
        return data == other.data
    }

    override fun hashCode(): Int {
        return data.hashCode()
    }
}

// 显示元素
class ShowElement(val show: Boolean) : ModifierNodeElement<ShowNode>() {
    // 创建
    override fun create(): ShowNode {
        return ShowNode(show)
    }

    // 更新
    override fun update(node: ShowNode) {
        node.show = show
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is ShowElement) return false
        return show == other.show
    }

    override fun hashCode(): Int {
        return show.hashCode()
    }
}

这样的话,我们要创建数据类来存储所有属性,比如:

// 数据类来存储父数据
data class CustomParentData(
    var data: String? = null,
    var show: Boolean? = true
)

并且新增 ShowNode 节点类,修改 modifyParentData() 函数的逻辑:

// 数据节点类
class DataNode(var data: String?) : ParentDataModifierNode, Modifier.Node() {
    override fun Density.modifyParentData(parentData: Any?): Any? =
        // 如果没有父数据
        if (parentData == null) {
            // 创建新的父数据
            CustomParentData(data = data)
        } else {
            // 否则将data属性校准为当前的data
            (parentData as CustomParentData).data = data
        }

}

// 显示节点类
class ShowNode(var show: Boolean) : ParentDataModifierNode, Modifier.Node() {
    override fun Density.modifyParentData(parentData: Any?): Any? {
        return ((parentData as? CustomParentData) ?: CustomParentData()).also {
            it.show = show
        }
    }
}

两个 modifyParentData() 函数内部的逻辑是一样的,只不过第二个实现比较风骚,这也是LayoutWeightNode的 modifyParentData() 函数的内部实现。

// RowColumnImpl.kt
class LayoutWeightNode(
    var weight: Float,
    var fill: Boolean,
) : ParentDataModifierNode, Modifier.Node() {
    override fun Density.modifyParentData(parentData: Any?) =
        ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also {
            it.weight = weight
            it.fill = fill
        }
}

在自定义布局中,parentData 也要转为CustomParentData类型。

val parentData = measurable.parentData as? CustomParentData

你能想到下面的结果吗?

Modifier.data("提供一个数据Y").show(true).show(false).data("提供数据X").show(false)

最终,data属性的值应该是 "提供一个数据Y" ,show属性的值应该是 true

避免API污染

我们创建的 data()show() 修饰符是给自定义布局的子组件使用的,但是在别的地方,这两个修饰符就没有意义了,也不应该被使用到。

所以我们要避免这种污染,Row() 函数是做了这种抗污染的,它的content参数使用了 RowScope 修饰,让我们在 content 中拥有了 RowScope上下文,可以调用里面的函数,别的地方是不允许的,不信你调用一下它的 weight() 修饰符试一下。

@Composable
inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
)

并且它把修饰符函数放到了 RowScope 接口中。

@LayoutScopeMarker
@Immutable
interface RowScope {
    @Stable
    fun Modifier.weight(
        @FloatRange(from = 0.0, fromInclusive = false)
        weight: Float,
        fill: Boolean = true
    ): Modifier
   
    ...
}

其中 @LayoutScopeMarker 注解很重要,可以让这种限制更加严格,我们不能在RowScope的内部的内部,也就是间接内部去调用RowScope内部的函数,比如:

Row {
    Box(Modifier.weight(1f)){
        Box(Modifier.weight(1f)) // 报错❗
    }
}

我们只要仿照着写就可以了,创建 MyScope 接口。

@LayoutScopeMarker
@Immutable // 表示该对象是不可变的,有利于重组优化
interface MyScope{
    fun Modifier.data(data: String): Modifier = this.then(DataElement(data))
    fun Modifier.show(show: Boolean): Modifier = this.then(ShowElement(show))
}

Row() 函数的实现中,还需要创建单例对象来实现 RowScope 接口,我们就创建一个 MyScopeInstance 单例对象。

private object MyScopeInstance : MyScope {
    @Stable
    override fun Modifier.data(data: String): Modifier = this.then(DataElement(data))

    @Stable
    override fun Modifier.show(show: Boolean): Modifier = this.then(ShowElement(show))

}

注意:这里的单例对象需要为私有,否则只要使用处导一下包,就能使用了。

最后修改自定义布局函数:content参数加上 MyScope 前缀,Layout函数的content参数改为 { MyScopeInstance.content() }

@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable MyScope.() -> Unit) {
    Layout(content = { MyScopeInstance.content()}, modifier = modifier) { measurables, constraints ->
        measurables.forEach { measurable ->
            val parentData = measurable.parentData as? CustomParentData
        }

        layout(width = 0, height = 0) {

        }

    }

}

注意:上述的自定义布局函数、接口、接口的单例对象都要放到同一个文件里,比如这里我是放在了CustomLayout.kt文件中。

这样就解决了API污染的问题,如果需要更规范的写法,可以去看官方的源码。

效果:

@Composable
fun ScopeTest() {
    Box(Modifier.data("this is data").show(false)) // ❌ 无效

    CustomLayout {
        Box(Modifier.show(true).data("it is also a data")) { // ✅ 有效
            Box(Modifier.data("data too").show(true)) // ❌ 无效
        } 
    }
}

第一个无效是私有化单例对象的作用,它获取不到单例对象,也不在 MyScope 的作用域内(不在CustomLayout 内部),所以无效;

第二个无效是 @LayoutScopeMarker 注解的作用,它不是 CustomLayout 的直接子组件,超出了 MyScope 的作用域范围,所以无效。

ParentDataModifierNode 的工作原理

来看看 ParentDataModifierNode 是怎么实现的。

首先所有的修饰符都是在 LayoutNode 对象中被处理的,ParentDataModifierNode 也不例外,而ParentDataModifierNode 和 DrawModifierNode 以及 PointerInputModifierNode 有一个共同点,就是底层存储方式是相同的。所以 ParentDataModifierNode 也是归属于右边最近的 LayoutModifierNode。

比如:

Box-1(
    Modifier
        .then(ParentDataModifierNode-1)
        .then(ParentDataModifierNode-2)
        .then(LayoutModifierNode-1)
)
Box-2(
    Modifier
        .then(LayoutModifierNode-2)
        .then(ParentDataModifierNode-3)
        .then(LayoutModifierNode-3)
        .then(ParentDataModifierNode-4)
)

ParentDataModifierNode-1 和 ParentDataModifierNode-2 都属于 LayoutModifierNode-1 布局节点(LayoutModifierNodeCoordinator), LayoutModifierNode-2 和 ParentDataModifierNode-3 都属于 LayoutModifierNode-3 布局节点,ParentDataModifierNode-4 属于 Box-2 组件(InnerNodeCoordinator)。

对,你说的没错,那么这对界面有什么影响?

不着急,我们来看看 parentData 属性的实现:

选择 NodeCoordiantor,因为它是 LayoutModifierNodeCoordinatorInnerNodeCoordinator 的父类,来到了 parentData 的 get() 函数:

/**
 * 获取当前布局节点的父数据。
 * 父数据用于子节点向父节点传递布局信息,例如在Row中的weight或align等属性。
 */
override val parentData: Any?
    get() {
        // 检查当前布局节点是否包含ParentData类型的节点
        if (layoutNode.nodes.has(Nodes.ParentData)) {
            // 获取当前修饰符链的尾部节点,作为遍历的终止点
            val thisNode = tail
            
            var data: Any? = null
            
            // 从尾部到头部遍历修饰符节点链
            layoutNode.nodes.tailToHead { node ->
                // 检查当前节点是否是ParentData类型
                if (node.isKind(Nodes.ParentData)) {
                    // 对ParentData类型的节点执行特定操作
                    node.dispatchForKind(Nodes.ParentData) {
                        // 调用ParentDataModifierNode的modifyParentData方法
                        // 这允许每个ParentData修饰符修改或添加到累积的父数据中
                        // 使用layoutNode的density来确保尺寸计算正确
                        data = with(it) { layoutNode.density.modifyParentData(data) }
                    }
                }
                
                // 如果已经处理到了当前修饰符链的尾部节点,停止遍历
                // 这确保我们只处理当前修饰符链的节点,而不是整个布局树
                if (node === thisNode) return@tailToHead
            }
            
            // 返回累积的父数据
            return data
        }
        
        // 如果没有ParentData节点,返回null
        return null
    }

这就是 parentData 的获取过程,简单来说,就是右侧的 parentData 会作为 modifyParentData() 函数的参数,与左侧的 parentData 进行处理。因此一个提供父数据的修饰符写在布局修饰符的左边或右边是无所谓的,比如:

Row {
    Box(Modifier.weight(1f).size(40.dp).weight(1f).padding(6.dp).weight(1f).size(80.dp).weight(1f)
}

以上四个 weight() 修饰符,去掉任意三个效果都是一样的,因为它们是给父组件使用的。

注意我们在 layout() 修饰符中,也可以拿到 parentData,但是这是没有意义的。

Row {
    Box(Modifier
        .size(40.dp)
        .padding(6.dp)
        .layout { measurable, constraints ->
            val parentData = measurable.parentData // 无意义

            val placeable = measurable.measure(constraints)

            layout(placeable.width, placeable.height) {
                placeable.placeRelative(0, 0)
            }
        }
        .weight(1f)
    )
}

总结

ParentDataModifierNode 的作用:为父组件提供布局信息。

实现步骤:创建自定义布局,实现提供父数据的修饰符,然后在自定义布局中获取。