一个圆屏逼得我好好学习 Compose MeasurePolicy

最近我在做一个车载智能设备的项目,这个小设备有个小的圆屏幕要显示一些 UI 元素。

很快,我发现问题来了。

在这个小圆屏上,几乎所有现有的标准 UI 组件都不能用。

Box 的方形布局在圆形屏幕上显得突兀,ColumnRow 的线性排列在有限空间里也施展不开。最后只能大量依赖自定义控件,自己测量、自己布局、自己绘制。

这就引出了本篇文章的主题:Compose 到底是怎么决定"一个控件该画多大、放在哪里"的?

答案就是 MeasurePolicy

它是 Compose 布局系统的骨架,掌握了它,你就能构建任意形状、任意逻辑的自定义布局------不管是圆形屏幕上的弧形排列,还是复杂的数据可视化图表。

渲染的三阶段

Jetpack Compose 采用了声明式的 UI 模型,整个渲染过程被拆分为三个阶段:

  1. 组合(Composition):决定显示哪些 UI(构建 UI 树)
  2. 布局(Layout):决定 UI 放在哪(测量和定位节点)
  3. 绘制(Drawing):决定怎么画(把像素画到屏幕上)

MeasurePolicy 就是布局阶段的核心。

无论是 BoxColumnRow 这些标准布局,还是你自己写的任何自定义布局,底层都在和 MeasurePolicy 打交道。

MeasurePolicy 是什么

简单来说,MeasurePolicy 就是一套"布局规则"。

它告诉 Compose:怎么测量我的子元素、我自己该占多大、把子元素放在哪里。

它是一个函数式接口,简化后的定义长这样:

kotlin 复制代码
fun interface MeasurePolicy {

    fun MeasureScope.measure(

        measurables: List<Measurable>, 

        constraints: Constraints

    ): MeasureResult

    // ... 固有测量方法 ...

}

正因为它是 fun interface(函数式接口),你在调用 Layout 组合函数时,可以直接用一个 lambda 就把测量逻辑写出来,非常简洁。

measure :布局的核心

measure 函数是整个机制的核心,它接收两个参数:

  1. measurables: List<Measurable>:你所有的子元素。注意,这时候它们还只是"可被测量的对象",还没真正被测量过。
  2. constraints: Constraints:父布局给你的尺寸限制------你的最小/最大宽度和高度是多少。

单次测量

Jetpack Compose 有一个很关键的设计:单次测量(single-pass measurement)。

简单说就是:父布局对每个子元素只测量一次。

官方文档的原话是:

父布局和子布局之间没有测量协商:一旦子元素选定了自己的大小,父布局就必须正确处理。

这跟传统的 Android View 系统不一样。

在旧的 View 系统里,父 View 和子 View 可能会来回测量好几次,直到大家达成共识。这种"协商"虽然灵活,但性能开销很大。

Compose 选择了"只测一次",牺牲了一点灵活性,换来了更好的性能。

父子之间怎么交互

父子之间的交互过程如下图所示:

具体流程是这样的:

  1. 你调用 measurables[i].measure(constraints) 来测量一个子元素;
  2. 这时候,Measurable 就变成了 Placeable
  3. 所有子元素都测量完之后,你把算出的总尺寸传给 layout(width, height) { ... },生成最终的 MeasureResult
  4. 最后通过 placeable.placeRelative(x, y) 把子元素放到指定位置。

这里有个关键的概念转换:

  • Measurable:子元素被测量之前的状态------它"可以被测量",但还没测,不知道自己多大
  • Placeable :子元素被测量之后的状态------它已经知道了自己精确的 widthheight,随时可以被放到屏幕上

实战 ClockFaceLayout

理论讲完了,来点实际的。

我们来实现一个特别简单的布局 ------ ClockFaceLayout,该布局的子元素会像表盘上的数字一样排列。

Kotlin 复制代码
/**
 * 表盘布局 - 将子元素按照钟表的12个小时位置排列
 * 适用于圆形屏幕或需要环形排列的场景
 */
@Composable
fun ClockFaceLayout(
    modifier: Modifier = Modifier,
    radiusFraction: Float = 0.4f, // 子元素距离中心的半径比例
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier, content = content
    ) { measurables, constraints ->
        // 1. 测量所有子元素 - 使用固定的子元素大小
        val childSize = min(constraints.maxWidth, constraints.maxHeight) / 6
        val childConstraints = Constraints.fixed(childSize, childSize)
        val placeables = measurables.map { measurable ->
            measurable.measure(childConstraints)
        }

        // 2. 布局大小等于父容器大小
        val layoutSize = min(constraints.maxWidth, constraints.maxHeight)
        val finalSize = layoutSize.coerceIn(constraints.minWidth, constraints.maxWidth)

        // 3. 按照表盘位置放置子元素
        layout(finalSize, finalSize) {
            val centerX = finalSize / 2
            val centerY = finalSize / 2
            val radius = (finalSize * radiusFraction).roundToInt()

            placeables.forEachIndexed { index, placeable ->
                // 计算角度:12点方向是 -90 度(顶部),顺时针旋转
                // 每个小时间隔 30 度 (360 / 12 = 30)
                val angleInDegrees = (index * 30) - 90
                val angleInRadians = Math.toRadians(angleInDegrees.toDouble())

                // 计算子元素的中心点位置
                val itemCenterX = centerX + (radius * cos(angleInRadians)).roundToInt()
                val itemCenterY = centerY + (radius * sin(angleInRadians)).roundToInt()

                // 转换为左上角坐标(placeRelative 需要的是左上角)
                val x = itemCenterX - placeable.width / 2
                val y = itemCenterY - placeable.height / 2

                placeable.placeRelative(x = x, y = y)
            }
        }
    }
}

这个 lambda 就是我们的 MeasurePolicy。逻辑很清晰,分三步走:测量 → 算大小 → 放置。效果如下图所示:

当然,我们还需要提供一些子元素:

Kotlin 复制代码
ClockFaceLayout(
    modifier = Modifier.fillMaxSize(), radiusFraction = 0.38f
) {
    // 12 个小时位置
    val hourLabels = remember {
        listOf(
            "12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
        )
    }

    val colors = remember {
        listOf(
            Color(0xFFE53935), // 12
            Color(0xFFEF6C00), // 1
            Color(0xFFFDD835), // 2
            Color(0xFF43A047), // 3
            Color(0xFF1E88E5), // 4
            Color(0xFF8E24AA), // 5
            Color(0xFFE53935), // 6
            Color(0xFFEF6C00), // 7
            Color(0xFFFDD835), // 8
            Color(0xFF43A047), // 9
            Color(0xFF1E88E5), // 10
            Color(0xFF8E24AA), // 11
        )
    }
    
    
    // 12 个子元素
    hourLabels.forEachIndexed { index, label ->
        Box(
            modifier = Modifier
                .size(44.dp)
                .clip(CircleShape)
                .background(colors[index]), contentAlignment = Alignment.Center
        ) {
            Text(
                text = label, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp
            )
        }
    }
}

先有鸡还是先有蛋

对绝大多数自定义布局来说,重写 measure 函数就够了。

但 Compose 的单次测量有个限制:父布局只能测一次子元素。

这带来一个问题 ------ 假如你有一个 Row,想让所有子元素的高度都等于最高的那个子元素的高度。问题是:你得先测一遍才知道谁最高,但你又不能测两遍。

这就陷入了一个"先有鸡还是先有蛋"的困境。

为了解决这类场景,MeasurePolicy 提供了固有测量(Intrinsic Measurement) 方法:

  • minIntrinsicWidth(最小固有宽度)
  • minIntrinsicHeight(最小固有高度)
  • maxIntrinsicWidth(最大固有宽度)
  • maxIntrinsicHeight(最大固有高度)

这些方法的作用是:让你在不真正测量的情况下,问子元素"你大概需要多大?"。

比如 maxIntrinsicHeight 会告诉父布局:"如果没有任何限制,这个子元素最多需要多高?"

默认情况下,MeasurePolicy 会尝试通过标准 measure 方法来估算这些值。但对于那些动态计算大小的自定义布局,这种估算可能会出问题(卡顿甚至崩溃)。

我写过一篇文章 - 实战 Compose 中的 IntrinsicSize,如果你想详细了解 IntrinsicSize,可以看看。

这里我给一点实际开发中的建议:如果你写的自定义布局比较复杂,而且可能会被用在 IntrinsicSize 修饰符中(比如 Modifier.height(IntrinsicSize.Max)),那就应该显式重写这些方法,根据你的布局逻辑返回准确的理论值。

总结

回到我开头说的那个车载小圆屏项目。理解了 MeasurePolicy 之后,我发现很多事情变得清晰了:

标准布局不够用?自己写一个

RowColumnBox 只是 Compose 给你的"开箱即用"的布局。当你需要特殊的排列方式(比如圆形布局、瀑布流、自定义网格),你完全可以自己实现一个 MeasurePolicy,逻辑完全由你控制。

性能很重要

Compose 的"单次测量"设计不是没有道理的。它避免了旧 View 系统里那种父子之间反复测量的开销。所以你在写自定义布局时,也要遵循这个原则------尽量一次性搞定,别来回测。

这在我那个小电子设备上尤其有用,毕竟那个设备只有 4G 内存,还是个 4 核机器。

固有测量是你的后手

当你遇到"需要在测量前就知道子元素大小"的场景,固有测量方法(minIntrinsicWidthmaxIntrinsicHeight 等)就是你的救命稻草。

从简单的开始

别一上来就想搞个完美的自定义布局系统。先从模仿 ColumnRow 开始,把 measurelayoutplaceRelative 这个流程跑通,然后再慢慢加功能。

下次需要写自定义布局时,记住这个套路:测量子元素 → 算出自己的大小 → 放置子元素

就这么简单,剩下的都是细节,慢慢解决。

相关推荐
__Witheart__1 小时前
RK Android OTA U盘升级指南
android
__Witheart__1 小时前
RK Android OTA U盘升级包编译指南
android
我命由我123451 小时前
Android Service - Service 生命周期变化、Service 与 Activity 双向交互
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
不会Android的潘潘1 小时前
【AOSP 应用集成全方案】
android·aosp
编程猪猪侠2 小时前
基于uni-app-x 与 uni-app 的安卓与 H5 双向通信完整实现
android·javascript·uni-app
十六年开源服务商2 小时前
2026年WordPress建站新趋势与实战解决方案
android
❀͜͡傀儡师2 小时前
告别脚手架:用 JBang 打通 Java、Kotlin、Python 的脚本化开发
java·python·kotlin·jbang
疏狂难除2 小时前
JetBrains IDE插件开发教程(四)——Action
java·ide·kotlin