Compose 自定义 Layout:MeasurePolicy、固有尺寸与瀑布流实战

做 Compose 图片流时遇到一个需求:图片宽度固定,高度自适应,得像瀑布流一样紧密排列。LazyVerticalStaggeredGrid 只能处理等高或按比例缩放,做不了原生不规则瀑布流。翻遍标准库,没有现成方案。

那就自己写一个 Layout。

MeasurePolicy:Compose 布局的骨架

Compose 的测量模型和传统 View 系统完全不同。View 系统是两次遍历:measurelayout,宽高在 measure 阶段就确定了。Compose 走的是单次测量约束传递:父节点给子节点一个 Constraints,子节点在约束范围内确定自己的尺寸,父节点再根据子节点尺寸摆放位置。

核心接口 MeasurePolicy

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
        modifier = modifier,
        measurePolicy = { measurables, constraints ->
            // 1. 测量所有子元素
            val placeables = measurables.map { measurable ->
                measurable.measure(constraints)
            }
            // 2. 确定容器尺寸(不能大于 constraints 的最大值)
            val width = constraints.maxWidth
            val height = placeables.sumOf { it.height }
            // 3. 摆放子元素
            layout(width, height) {
                var y = 0
                placeables.forEach { placeable ->
                    placeable.placeRelative(0, y)
                    y += placeable.height
                }
            }
        }
    )
}

这段代码实现了一个简单的垂直排列容器,行为和 Column 一致。layout() 返回的宽高必须在 constraints 范围内,否则直接抛异常——我一开始反复踩这个坑,排查后才意识到 Compose 对约束边界的检查是硬性的。

measurable.measure(constraints) 传入的是父容器给的约束。如果想让子元素按自身需求决定尺寸,就需要固有尺寸测量。

固有尺寸协商:让子元素”表达意愿”

假设你做一个表单布局,标签列要对齐到最宽标签的宽度。用普通的 Row 做不到——每个标签不知道其他标签多宽。

这时候 IntrinsicSize 派上用场:

@Composable
fun AlignedForm(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
        modifier = modifier,
        measurePolicy = { measurables, constraints ->
            // 询问每个子元素的固有最大宽度
            val maxIntrinsicWidth = measurables.maxOf { measurable ->
                measurable.maxIntrinsicWidth(constraints.maxHeight)
            }
            // 用这个宽度统一测量所有子元素
            val placeables = measurables.map { measurable ->
                measurable.measure(
                    Constraints.fixedWidth(maxIntrinsicWidth)
                )
            }
            val totalHeight = placeables.sumOf { it.height }
            layout(maxIntrinsicWidth, totalHeight) {
                var y = 0
                placeables.forEach { placeable ->
                    placeable.placeRelative(0, y)
                    y += placeable.height
                }
            }
        }
    )
}

maxIntrinsicWidth(height) 的含义是:在高度给定的前提下,完整展示内容所需的最小宽度。Compose 提供了四个固有尺寸查询:minIntrinsicWidthmaxIntrinsicWidthminIntrinsicHeightmaxIntrinsicHeight

Text 组件的固有宽度有一个坑:遇到超长英文单词会按不换行计算,导致测量值偏大。我在表单标签场景中撞过这个问题,最后对标签加了 softWrap = true + maxLines = 1 的组合约束才解决。如果你也遇到标签列宽异常,大概率是同一回事。

SubcomposeLayout:按需组合的异步布局

当子元素尺寸依赖其他子元素的测量结果时,MeasurePolicy 就不够了——所有子元素在 measure 阶段是”同时”处理的。

SubcomposeLayout 支持分批次组合和测量,BoxWithConstraintsLazyColumn 都基于它实现。它的机制是:先组合一批子元素,拿到测量结果,再根据结果决定下一批的组合策略。

@Composable
fun AdaptiveColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    SubcomposeLayout(modifier) { constraints ->
        // 第一批:先测量一个典型子元素,推算每行能放几个
        val measureResult = subcompose("scout", content).map {
            it.measure(constraints)
        }
        // 基于第一批结果,决定后续布局策略
        // 实际使用时可以分更多批次
        val totalHeight = measureResult.sumOf { it.height }
        layout(constraints.maxWidth, totalHeight) {
            var y = 0
            measureResult.forEach { placeable ->
                placeable.placeRelative(0, y)
                y += placeable.height
            }
        }
    }
}

SubcomposeLayout 的性能代价很大——每多一个批次就多一次完整的组合遍历。我的判断是:能用 Layout + 固有尺寸解决的,绝对不要上 SubcomposeLayout。只有确实需要”用前面的测量结果决定后面怎么组合”时,它才是正确的选择。

实战:自适应网格

回到开头的问题。一个简单的自适应网格:每个单元格宽度固定 120dp,根据容器宽度自动计算列数,子元素高度自适应。

@Composable
fun AdaptiveGrid(
    modifier: Modifier = Modifier,
    cellWidth: Dp = 120.dp,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
        modifier = modifier
    ) { measurables, constraints ->
        val cellWidthPx = cellWidth.roundToPx()
        // 根据容器宽度动态计算列数
        val columns = max(1, constraints.maxWidth / cellWidthPx)
        val actualCellWidth = constraints.maxWidth / columns
        val itemConstraints = Constraints(
            minWidth = actualCellWidth,
            maxWidth = actualCellWidth
        )
        val placeables = measurables.map { it.measure(itemConstraints) }
        // 按行累加高度
        val rows = (placeables.size + columns - 1) / columns
        val rowHeights = IntArray(rows)
        placeables.forEachIndexed { index, placeable ->
            val row = index / columns
            rowHeights[row] = max(rowHeights[row], placeable.height)
        }
        val totalHeight = rowHeights.sum()
        layout(constraints.maxWidth, totalHeight) {
            var y = 0
            placeables.forEachIndexed { index, placeable ->
                if (index % columns == 0 && index > 0) {
                    y += rowHeights[index / columns - 1]
                }
                val x = (index % columns) * actualCellWidth
                placeable.placeRelative(x, y)
            }
        }
    }
}

三十行左右的代码,已经可以替代 LazyVerticalGrid 的大部分非懒加载场景。Constraints 构造时只限制宽度,高度由子元素自行决定——这是实现自适应的核心。

实战:瀑布流

自适应网格的每行高度由该行最高的单元格决定。瀑布流不同:每个单元格紧贴排列,优先填入当前累计高度最小的列。

@Composable
fun WaterfallLayout(
    modifier: Modifier = Modifier,
    columns: Int = 2,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
        modifier = modifier
    ) { measurables, constraints ->
        val columnWidth = constraints.maxWidth / columns
        val itemConstraints = Constraints(maxWidth = columnWidth)
        val placeables = measurables.map { it.measure(itemConstraints) }

        // 维护每列当前累计高度
        val columnHeights = IntArray(columns)
        // 记录每个子元素被分配到哪个列
        val placements = mutableListOf<Pair<Int, Int>>() // (column, yOffset)

        placeables.forEach { placeable ->
            val shortestColumn = columnHeights.indices.minBy { columnHeights[it] }
            placements.add(shortestColumn to columnHeights[shortestColumn])
            columnHeights[shortestColumn] += placeable.height
        }

        val totalHeight = columnHeights.max()
        layout(constraints.maxWidth, totalHeight) {
            placeables.forEachIndexed { index, placeable ->
                val (column, y) = placements[index]
                placeable.placeRelative(column * columnWidth, y)
            }
        }
    }
}

瀑布流的核心就是 minBy 这一行:每次选高度最小的列放元素,标准的贪心策略。配合 verticalScroll 就能得到一个非懒加载的瀑布流页面。如果需要处理大规模数据,LazyLayout 可以改造——不过实现复杂度翻倍,这里不展开了。

调试 Layout 的两个工具

写自定义 Layout 时,有两个调试手段经常用到。

第一个是 Modifier.layoutId,给不同子元素打标签:

Layout(
    content = {
        Box(Modifier.layoutId("header")) { Header() }
        Box(Modifier.layoutId("body")) { Body() }
    }
) { measurables, constraints ->
    val header = measurables.first { it.layoutId == "header" }
    val body = measurables.first { it.layoutId == "body" }
    // 分别测量,不同策略
}

第二个是 Placeable.PlacementScope 里的坐标验证。元素位置不对时,在 placeRelative 之前打日志输出坐标,通常能快速定位。调试自定义布局最怕逻辑看着没问题但位置总歪,十有八九是坐标系计算出了偏差。

一条核心原则

写过多个自定义 Layout 之后,我总结出一条经验:优先在 measure 阶段解决问题,不要在 layout 阶段过度补偿。

如果你发现自己在 layout 阶段反复调偏移量来弥补测量阶段的不足,那说明测量策略已经出了问题。子元素的测量结果决定了 widthheight,摆放阶段只能改位置,不能改尺寸。用 placeRelative 做偏移”微调”,其实是在掩盖上游的错误。

我的实践是先给容器和子元素加半透明背景——Modifier.background(Color.Red.copy(alpha = 0.3f))——直观看到每个元素的真实边界。边界和预期对不上,就回头修正测量参数,不在摆放阶段硬调。