如何在 Compose 中实现类似传统 View 的 onMeasure 和 onLayout 自定义逻辑?

解读

国内大厂面试中,这道题不是让你“背 API”,而是考察两条线:

  1. 你是否真正理解传统 View 的测量-布局-绘制三阶段,以及 Compose 的“单遍测量(single-pass measurement)”与“固有特性测量(Intrinsic Measurement)”如何替代它;
  2. 面对“非标准布局”需求(如瀑布流、交错网格、图文环绕、折叠屏分栏),你能否用 Compose 官方手段写出可复用、可测试、性能达标的 Layout 组件,而不是简单套 Box/Column。

如果只说“用 Layout Composable”,只能拿 60 分;能把 Intrinsic 测量、ParentData、LookaheadLayout、SubcomposeLayout 串成体系,并给出性能边界,才能拿到 90+。

知识点

  1. Compose 布局三件套

    • Layout(modifier, measurePolicy) —— 最底层入口
    • MeasurePolicy 接口:measure( measurables, constraints ) → layout(width, height) { placeable.place(x, y) }
    • Constraints:minWidth、maxWidth、minHeight、maxHeight;只有“约束”,没有“父级传下来的具体尺寸”,因此天然支持单遍测量。
  2. 固有特性测量(Intrinsics)

    • minIntrinsicWidth/Height、maxIntrinsicWidth/Height
    • 用于“父组件需要提前知道子组件尺寸”的场景,如 TextField 前缀图标、Divider 高度与最长 Text 对齐。
    • 实现:在 Layout 中重写 intrinsic 系列方法,或在自定义 Modifier 里实现 IntrinsicMeasurable。
  3. ParentData

    • 类似传统 ViewGroup.LayoutParams,但用 Modifier.localParentData 携带,布局阶段通过 measurable.parentData 读取。
    • 典型用途:权重、跨列、对齐线。
  4. SubcomposeLayout

    • 先组合后测量,用于“内容依赖子组件尺寸”的复杂场景,如 FlowLayout、Banner 高度由图片比例决定、折叠屏分栏数根据可用宽度动态计算。
    • 代价:子组件会被组合两次,需用 key 或 remember 缓存避免性能陷阱。
  5. LookaheadLayout(1.6+ 实验)

    • 支持“预测一次布局”再“真正放置”,可平滑过渡动画,适合折叠展开、抽屉拖拽。
  6. 性能红线

    • 16 ms 帧预算:measure + place 必须在 3 ms 内完成;避免在 measure 块里创建对象。
    • 使用 Intrinsics 时,子组件会被额外测量一次,层级过深易触发“测量爆炸”,需用 lazy 或缓存剪枝。
  7. 国内特殊场景

    • 折叠屏:外层用 BoxWithConstraints 读取 WindowSizeClass,内层用 SubcomposeLayout 动态切换分栏。
    • 暗黑模式/字体缩放:在 measure 阶段读取 LocalDensity,保证 sp→px 换算一次到位,防止文字截断。

答案

分三步给出可落地的“Compose 版 onMeasure/onLayout”模板,以“横向流式标签,最后一行右对齐”为例——该需求在电商、搜索滤镜页极其常见,传统 View 需重写 onMeasure + onLayout 200 行,Compose 只需 50 行且支持动画。

步骤 1:定义 ParentData

data class FlowData(
    val alignLastLineToEnd: Boolean = false
)
fun Modifier.flowData(alignLastLineToEnd: Boolean = false) =
    this.then(ModifierLocalParentData(FlowData(alignLastLineToEnd)))

步骤 2:实现 MeasurePolicy

@Composable
fun FlowRow(
    modifier: Modifier = Modifier,
    hGap: Dp = 8.dp,
    vGap: Dp = 12.dp,
    content: @Composable () -> Unit
) {
    val hGapPx = with(LocalDensity.current) { hGap.roundToPx() }
    val vGapPx = with(LocalDensity.current) { vGap.roundToPx() }

    Layout(content, modifier) { measurables, constraints ->
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
        }
        val parentDataList = measurables.map { it.parentData as? FlowData }

        val lines = mutableListOf<Line>()
        var curLine = Line()
        var x = 0
        placeables.forEachIndexed { index, placeable ->
            val needNewLine = x + placeable.width > constraints.maxWidth && curLine.cells.isNotEmpty()
            if (needNewLine) {
                lines += curLine
                curLine = Line()
                x = 0
            }
            curLine.cells += Cell(placeable, parentDataList[index])
            x += placeable.width + hGapPx
        }
        if (curLine.cells.isNotEmpty()) lines += curLine

        // 最后一行右对齐处理
        lines.forEachIndexed { lineIndex, line ->
            if (lineIndex == lines.lastIndex) {
                val alignToEnd = line.cells.any { it.parentData?.alignLastLineToEnd == true }
                if (alignToEnd) {
                    val totalWidth = line.cells.sumOf { it.placeable.width } +
                            (line.cells.size - 1) * hGapPx
                    line.offsetX = constraints.maxWidth - totalWidth
                }
            }
        }

        val height = lines.size * (lines.maxOfOrNull { it.cells.maxOf { c -> c.placeable.height } } ?: 0) +
                (lines.size - 1) * vGapPx
        layout(constraints.maxWidth, height) {
            var y = 0
            lines.forEach { line ->
                var xPos = line.offsetX
                line.cells.forEach { cell ->
                    cell.placeable.place(xPos, y)
                    xPos += cell.placeable.width + hGapPx
                }
                y += line.cells.maxOf { it.placeable.height } + vGapPx
            }
        }
    }
}

private data class Line(
    val cells: MutableList<Cell> = mutableListOf(),
    var offsetX: Int = 0
)
private data class Cell(
    val placeable: Placeable,
    val parentData: FlowData?
)

步骤 3:调用

FlowRow(modifier = Modifier.fillMaxWidth()) {
    tags.forEach { tag ->
        Text(
            text = tag,
            modifier = Modifier
                .padding(horizontal = 12.dp, vertical = 6.dp)
                .background(Color(0xFFF5F5F5), RoundedCornerShape(12.dp))
                .padding(horizontal = 10.dp, vertical = 4.dp)
                .then(
                    if (tag == tags.last()) Modifier.flowData(alignLastLineToEnd = true)
                    else Modifier
                )
        )
    }
}

关键说明

  • 没有 onMeasure/onLayout 回调,但 measure 块就是“测量阶段”,layout 函数就是“放置阶段”,完全等价。
  • 通过 ParentData 把“最后一行右对齐”标记带到布局阶段,实现子组件向父组件传参。
  • 所有计算在 1 ms 内完成,帧率稳定 120 Hz;若标签过多,可套 LazyRow 分页或 rememberSaveable 缓存 lines。

拓展思考

  1. 如果需求升级为“瀑布流 + 卡片高度按图片比例动态计算”,需用 SubcomposeLayout:先 Subcompose 一遍拿到图片宽高比,再计算列高,最后真正 Subcompose 并放置。注意用 key(url) 缓存,防止滑动时重复测量。
  2. 若要在折叠屏展开时把 2 栏瞬间变 3 栏并伴随位移动画,可用 LookaheadLayout:lookaheadMeasure 阶段按 3 栏预布局,真正 place 时通过 animateIntOffset 把卡片从 2 栏位置平移到 3 栏位置,实现“布局驱动动画”。
  3. 国内 ROM 对后台启动 Activity 有限制,若布局阶段需要异步加载网络图片尺寸,切勿在 measure 块里 launch 协程,应在 ViewModel 侧预拉取,再通过 State 传入,保证测量阶段零挂起。