1. 自定义布局到底在解决什么问题
1.1 View 时代的布局方式和 Compose 的底层差异
如果你是从 View 体系转到 Jetpack Compose 的,最开始用 Row、Column 时很容易产生一种错觉:这不就是 LinearLayout 换了个写法吗?直到你遇到"标签流、自适应文本折叠、角标叠加"这类需求,才会意识到布局模型的底层逻辑已经完全不同。
View 体系的 onMeasure 和 onLayout 是分两次回调的,父 View 先测量子 View,拿到尺寸后再逐个安排位置。这个过程里子 View 是可以"反向影响"父 View 的,因为 onMeasure 的结果会直接影响父 View 的最终尺寸。Compose 则把"测量 + 放置"压缩成了一个 measurePolicy,父布局在同一个 lambda 里完成:向子组件传递 Constraints,拿到每个子组件的 Placeable,然后算出自己的宽高并调用 layout 函数,最后在 placementBlock 里把所有子组件放到坐标上。
这带来的直接变化是:Compose 中的自定义布局不再是一个"控件",而是一段纯粹的测量策略代码。你可以把它当成一个函数,输入是"可用空间约束 + 一组待测组件",输出是"尺寸和放置指令"。这种设计让布局逻辑天然可复用、可测试,也更容易理解。
Jetpack Compose 自定义布局最核心的价值就在这里:你不受 Row、Column 内置排列方式的限制,可以自己决定"子组件之间的间距怎么计算、换行逻辑是什么、谁和谁对齐、测量顺序如何安排"。凡是系统组件组合不出你想要的效果时,第一步想到的不应该是去改 SDK 或者用绝对定位,而是考虑写一个自定义 Layout。
1.2 什么样的需求值得上手自定义布局
我总结过几种"信号强烈"的需求,遇到这些情况基本就可以确定要用自定义布局了:
- 子组件需要按宽度自动换行,但又不是 Grid 那种严格的列对齐;
- 子组件的排列顺序需要根据测量结果动态变化,比如先测量主体再决定辅助组件是否出现;
- 一个组件的放置位置依赖另一个组件的实际尺寸,比如角标要贴着文本右上角,但文本在窄屏下会换行;
- 你需要模拟"文字环绕"、瀑布流、环形排列这类非矩形排布;
- 单个 Modifier 做不到的效果,比如改变一组子节点之间的相对位置。
当然,自定义布局不是银弹。能通过系统 Modifier 和已有组件组合实现的,我一般不会为了"炫技"去自己写。但掌握它的原理之后,你再看官方组件里那些高级写法的源码,会发现整个世界都变清晰了。
2. 三步测量法:Constraints、Measurable 与 Placeable
2.1 Layout 函数签名怎么读
先看最基础的原型:
@Composable fun Layout( content: @Composable () -> Unit, modifier: Modifier = Modifier, measurePolicy: MeasurePolicy )content 是你想要摆放的子组件集合,modifier 是外部传入的修饰符,measurePolicy 是核心中的核心。measurePolicy 其实就是一个带MeasureScopereceiver 的接口,你只需要实现它的measure方法:
Layout( modifier = modifier, content = content ) { measurables, constraints -> // 步骤一:测量所有子组件 // 步骤二:计算自己的尺寸 // 步骤三:返回 layout 结果并放置子组件 }这里有个容易忽略的细节:content 里的 Composable 并不是以"对象树"形式直接存在的,在自定义 Layout 中你会拿到一个List<Measurable>。Measurable 相当于一个"待测量代理",它与具体 Composable 一一对应。你调用它的measure(constraints)方法才会真正触发测量,并得到一个 Placeable,里面才是最终的宽高数据。
2.2 Constraints 不是简单的"最大宽高"
Constraints 看起来只是一个包含 minWidth、maxWidth、minHeight、maxHeight 的类,但它承载了父布局对子组件的全部要求。日常开发里最常见的误区是:只关心 maxWidth 和 maxHeight,完全不看 min 值。
比如系统组件 Text 在父布局约束minWidth == maxWidth时会强制占满整行,因为它的测量逻辑认为"既然你有明确的宽度要求,我就应该填满"。这就是为什么在自定义 Layout 中测量子组件之前,很多人会先写一句:
val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) }copy(minWidth = 0, minHeight = 0)意思是:我允许子组件按自己的内容尺寸测量,不需要被迫填满父布局。如果你不写这句,在某些约束组合下(尤其是竖屏手机全屏宽度的 Column 里),子组件会被拉伸到你完全意想不到的宽度。
还有一点值得注意:Constraints.Infinity并不是一个超大整数,它是Int.MAX_VALUE。当父布局放在可滚动容器中时,水平或垂直方向会传入无限约束。如果你想在测量逻辑里用constraints.maxWidth做边界判断,必须提前判断它是否为Constraints.Infinity,否则用 Int.MAX_VALUE 去做加法运算会直接溢出。
2.3 测量子组件,再决定自己的尺寸
自定义 Layout 的大致流程永远是:先测量孩子,再根据孩子的结果决定自己。这有点像搭积木,你得先知道每块积木的长宽,才知道整个作品该留多大底板。
@Composable fun SimpleRow( modifier: Modifier = Modifier, content: @Composable () -> Unit ) { Layout( modifier = modifier, content = content ) { measurables, constraints -> val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } val totalWidth = placeables.sumOf { it.width } val maxHeight = placeables.maxOfOrNull { it.height } ?: 0 layout(totalWidth, maxHeight) { var x = 0 placeables.forEach { placeable -> placeable.placeRelative(x, 0) x += placeable.width } } } }这段代码就是最简单的自定义 Layout 雏形。它把所有子组件依次横向排列,整体宽度是所有子组件宽度之和。注意我在layout返回的 lambda 里才调用placeRelative,这个顺序不能乱。Compose 要求放置动作必须在layout的 placementBlock 中同步执行,如果在其他地方延迟放置,会直接触发异常。
2.4 place 与 placeRelative 的选择
很多初学者会纠结place和placeRelative有什么区别。简单说:place是"物理坐标",始终以父布局左上角为基准;placeRelative会跟随布局方向。在从左到右的界面中两者等价,但在布局方向为 RTL 时,placeRelative会自动帮你做水平镜像,适合做国际化适配。
我个人的习惯是:只要不是特殊绘制场景,一律用placeRelative。这样你的自定义布局天然支持阿拉伯语、希伯来语等 RTL 语言,不用后续再返工。如果你确实需要逻辑坐标来确定物理位置,再考虑place。
3. 从零手写一个 FlowLayout:实战 Tag 标签流
3.1 需求拆解
很多 App 的个人主页都有"标签"模块,一排放不下时自动折行,行与行之间还有间距。系统组件里 Row 只排一行,Column 只能一列,Grid 又要求严格对齐,所以最适合的方案就是自定义 FlowLayout。
这个需求的核心算法并不复杂:从左到右累加子组件宽度,如果加上下一个子组件会超过最大可用宽度,就把当前行记下来,换到下一行继续累加。麻烦的是把"间距"和"行高"这些细节处理好。
3.2 测量阶段的分行算法
来看代码实现:
@Composable fun FlowLayout( modifier: Modifier = Modifier, horizontalSpacing: Dp = 8.dp, verticalSpacing: Dp = 8.dp, content: @Composable () -> Unit ) { val density = LocalDensity.current val horizontalPx = with(density) { horizontalSpacing.toPx() } val verticalPx = with(density) { verticalSpacing.toPx() } Layout( modifier = modifier, content = content ) { measurables, constraints -> val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } val rows = mutableListOf<List<Placeable>>() val rowHeights = mutableListOf<Int>() val rowWidths = mutableListOf<Int>() val currentRow = mutableListOf<Placeable>() var currentRowWidth = 0 var currentRowHeight = 0 for (placeable in placeables) { val gap = if (currentRow.isEmpty()) 0 else horizontalPx.toInt() if (currentRowWidth + gap + placeable.width <= constraints.maxWidth) { currentRow.add(placeable) currentRowWidth += gap + placeable.width currentRowHeight = maxOf(currentRowHeight, placeable.height) } else { if (currentRow.isNotEmpty()) { rows.add(currentRow.toList()) rowHeights.add(currentRowHeight) rowWidths.add(currentRowWidth) currentRow.clear() } currentRow.add(placeable) currentRowWidth = placeable.width currentRowHeight = placeable.height } } if (currentRow.isNotEmpty()) { rows.add(currentRow.toList()) rowHeights.add(currentRowHeight) rowWidths.add(currentRowWidth) } val totalWidth = if (constraints.maxWidth != Constraints.Infinity) { constraints.maxWidth } else { rowWidths.maxOrNull() ?: 0 } val totalHeight = rowHeights.sum() + verticalPx.toInt() * maxOf(0, rows.size - 1) layout(totalWidth, totalHeight) { var y = 0 rows.forEachIndexed { index, row -> var x = 0 row.forEach { placeable -> placeable.placeRelative(x, y) x += placeable.width + horizontalPx.toInt() } y += rowHeights[index] + verticalPx.toInt() } } } }这段代码里有几个细节值得展开讲。
第一个是constraints.maxWidth的边界判断。如果 FlowLayout 被放在水平可滚动的容器里,maxWidth可能是无限大,这时候我们不能把totalWidth直接设成constraints.maxWidth,否则会出现一个宽度为 Int.MAX_VALUE 的布局。这种情况下我选择用所有行宽度里的最大值作为整体宽度,让布局保持"内容宽度"。
第二个是空行的处理。currentRow在遇到第一个子组件时就加进去了,之后只有换行时才清空。这样整个循环结束后,最后一个没有换行的行也能被正确记录。如果你把currentRow.isNotEmpty()的条件写错,很容易出现最后一行丢失的问题。
第三个是行高的计算。每行的最终高度是这一行里所有子组件高度的最大值,而不是上一行或者下一行的高度。放置阶段会根据rowHeights逐行累加 y 坐标,中间的verticalPx就是行间距。
3.3 放置阶段的细节和常见问题
放置阶段的算法相对简单:遍历每一行,在行内从左到右放置子组件,行内 x 坐标累加placeable.width + horizontalPx。行与行之间通过 y 坐标累加行高和垂直间距。
实际使用中容易忽略的是"行内垂直对齐"。我这个例子默认所有子组件都贴着行的顶部对齐,如果你希望标签在行内垂直居中,或者底部对齐,需要在放置时做额外的偏移,代码要改成:
placeable.placeRelative( x, y + (rowHeights[index] - placeable.height) / 2 )这样做可以兼容不同高度的标签。比如你的 Tag 有的带边框、有的带图标,高度会参差不齐,不处理垂直居中的话视觉上会非常乱。
还有一个我在实际项目中踩过的坑:FlowLayout 的子组件如果本身带有Modifier.fillMaxWidth(),那么我的分行算法就失效了,因为每个子组件都会被测量成整行宽度,结果只有一个标签占一行。遇到这种情况,我通常会在文档或注释里明确约定:这个布局的子组件应该是"自动宽度",不要用 fill 系列 Modifier。实在需要使用时,可以在测量前用constraints.copy(minWidth = 0)尽量规避,但根本解法还是约束使用方式。
4. 内容驱动的动态组合:SubcomposeLayout 的典型用法
4.1 普通 Layout 的边界在哪里
普通 Layout 的measurables是固定的,你在组合阶段写进content里的东西已经确定了,测量阶段只能对它们逐个测量。但有些场景下,你需要在"测量完成之后"才决定"我到底要不要再组合一个新的子组件"。
举个例子:一个卡片组件,如果主内容宽度超过可用宽度的一半,就不显示右上角的角标。用普通 Layout 实现的话,你只能在 content 里把角标正常写进去,在测量阶段跳过它测量。这样逻辑上可行,但角标的 Composable 已经被组合了,如果角标的组合开销很大,就会产生无效成本。更麻烦的是,如果你的"是否显示角标"还要依赖主内容的测量结果,普通 Layout 很难把条件控制在测量阶段。
SubcomposeLayout解决的就是这类问题。它允许你在 measurePolicy 的测量 lambda 里直接调用subcompose(key, content)动态地组合出子节点,然后对这些新子节点进行测量和放置。
4.2 一个动态角标的简化实现
来看一个简化但能说明问题的例子:
@Composable fun DynamicBadge( modifier: Modifier = Modifier, mainContent: @Composable () -> Unit, badgeContent: @Composable () -> Unit ) { SubcomposeLayout(modifier = modifier) { constraints -> val contentMeasurables = subcompose("main") { mainContent() } val contentPlaceable = contentMeasurables.first() .measure(constraints.copy(minWidth = 0, minHeight = 0)) val badgePlaceable = if (contentPlaceable.width < constraints.maxWidth / 2) { subcompose("badge") { badgeContent() } .firstOrNull() ?.measure(constraints.copy(minWidth = 0, minHeight = 0)) } else { null } val width = contentPlaceable.width val height = contentPlaceable.height layout(width, height) { contentPlaceable.placeRelative(0, 0) badgePlaceable?.let { badge -> badge.placeRelative( x = (width - badge.width) / 2, y = 0 ) } } } }在这段代码里,角标是否被组合,完全取决于主内容测量之后的宽度。如果主内容宽度超过可用宽度的一半,角标就不会出现在subcompose的返回值里,后续自然也不会被测量和放置。这就是普通 Layout 做不到的"测量结果驱动组合"。
SubcomposeLayout的 key 参数也很有讲究。这里的"main"和"badge"相当于 slot id,Compose 会用它来区分不同的内容区块并保持状态。如果你的某个 content 里包含输入框之类的有状态组件,一定要给稳定的 key,否则重组时状态可能丢失。
4.3 使用 SubcomposeLayout 的注意事项
第一个注意点是:不要在测量阶段对同一个 slot 反复调用subcompose。每调用一次就会组合一次内容,如果你的测量逻辑里写了循环,或者因为状态变化导致多次触发测量,会产生非常隐蔽的性能问题。
第二个注意点是:SubcomposeLayout的子节点数量不再由组合阶段固定,而是由测量阶段动态决定,这让它在某些父容器测量时会比较吃力。如果父容器要求你在不同约束下反复测量(比如父布局内部先测量一次估算、再实际测量一次),SubcomposeLayout的代价会比普通 Layout 高很多。
第三个注意点是:尽量让subcompose的调用逻辑与输入参数保持稳定。也就是说,同一个内容 slot,在相同条件下应该返回相同的结果集合,不要在测量阶段读一些会变的东西来决定是否组合内容。这样做不只是为了性能,更是为了避免测量结果不一致导致的无限重组。
5. 性能、固有尺寸与那些我踩过的坑
5.1 为什么官方希望你只测量一次
在 View 体系里,子 View 被父 View 测量两次甚至多次是常态,很多自定义 View 都靠缓存测量结果来优化。但 Compose 的哲学完全不同:一次完整测量只应该发生一次,测量结果直接决定放置,尽量减少"先猜一下再修正"的过程。
自定义 Layout 中,如果你对同一个 Measurable 调用了两次measure,会怎么样?第一次调用返回的 Placeable 就失效了,第二次调用会重新测量。这个操作本身不算非法,但它会破坏 Compose 的优化缓存,导致父布局需要重新走一遍测量流程。如果每个子组件都被多次测量,整棵布局树的性能会成倍下降。
我见过一些代码,为了处理"子组件太高了就缩小一点"这类需求,会先测量一次,发现超了再测量一次。从结果上看功能是正常的,但页面滑动时会明显卡顿。更合理的做法是在业务层提前约定好尺寸策略,或者在父约束阶段就做好上限控制,而不是依赖多次测量。
5.2 Intrinsic 测量和 Modifier.intrinsicSize
Compose 提供了一套 Intrinsic 固有尺寸的机制,用来在不真正测量的前提下估算组件的"最小/最大宽度或高度"。系统 Row 和 Column 都实现了这套机制,所以当你给它们加Modifier.width(IntrinsicSize.Min)时,能计算出和内容匹配的尺寸。
自定义 Layout 默认也会有 Intrinsic 测量,但结果可能不是你想的那样。比如刚才的 FlowLayout,如果父布局要求它返回 Intrinsic 宽度,默认实现往往会把所有子组件的最小宽度加起来,导致估算值偏大。如果你打算把自定义布局用在IntrinsicSize相关的场景,就需要实现 measurePolicy 里的minIntrinsicWidth、minIntrinsicHeight这些方法,并且意识到这是一个额外的工作量。
我的建议是:大多数自定义布局不需要主动实现 Intrinsic,除非你确实遇到"父布局依赖你的固有尺寸来做决策"的需求。真遇到了,也别慌,先想清楚你布局的内在逻辑是横向累加还是纵向累加,按对应方向去实现最小/最大固有尺寸。
5.3 在测量阶段做副作用会怎样
Compose 的测量过程理论上应该是纯函数:输入 Constraints,输出尺寸和放置指令,中间不产生外部可观察的副作用。如果你在 measurePolicy 里写日志、修改状态、发送事件,看起来一时正常,但当父布局因为动画或者滚动频繁测量时,这些副作用会被反复触发,而且触发时机完全不受你控制。
我在排查一个性能问题时遇到过真实例子:有人在自定义布局的测量里读取了 ViewModel 中的某个状态,结果每次父布局测量都读一次,导致状态变化又触发重组,形成循环。正确做法是:把需要的数据在组合阶段读取并传入,在测量阶段只做数学运算。
5.4 调试手段与常见异常
调试自定义布局最直接的方法是打印constraints和placeable的宽高。比如:
val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) // 这里可以打印 it.measuredWidth / it.measuredHeight }Compose 的 Placeable 在 measure 之后才有确定的宽高,访问placeable.width和placeable.height是安全的。但如果还没测量就访问,会拿到 0 或者抛出异常。
另一个常见的坑是:在layout的 placementBlock 里遗漏了某个子组件。Compose 允许你不放置某个子组件,它就不会显示,但这经常导致界面出现"某些内容消失了"的诡异问题。排查时第一件事就是检查所有 Placeable 是否都被placeRelative调用了。
还有一个容易被忽略的坑是放置顺序决定绘制层级。后放置的子组件会绘制在之前放置的子组件之上。如果你想实现某个子组件"最顶层",除了用Modifier.zIndex,也可以通过调整放置顺序达到同样的效果。这两者是有区别的:zIndex会影响绘制和命中测试,单纯的放置顺序只影响绘制顺序。没有特殊需求时,少混用,保持代码可预测。
6. 怎么选型:Layout、Modifier.layout 还是 SubcomposeLayout
6.1 Modifier.layout 的适用边界
Modifier.layout是一个针对"单个组件"的布局工具,它不管理一组子组件,只决定当前节点在父布局坐标系中的位置和尺寸。它的典型用途是:在已有组件上追加padding、偏移、尺寸限制等逻辑,但又不想额外套一层 Layout。
如果你只想让某个 Text 的绘制区域比实际内容大一圈,或者让某个 Box 在父布局中做特殊偏移,用Modifier.layout就够了。它接收measurable和constraints,返回一个layout结果,写法上跟自定义 Layout 很像,但约束范围完全不同。
Modifier.layout { measurable, constraints -> val placeable = measurable.measure(constraints) val extraPadding = 8 layout( width = placeable.width + extraPadding, height = placeable.height + extraPadding ) { placeable.placeRelative(extraPadding / 2, extraPadding / 2) } }这个能力适合做"不动整体布局,只微调某个节点"的需求。一旦你要管理多个子组件的排列关系,就应该跳到Layout。
6.2 三个工具的选择判断表
我平时做技术选型时会按一个简单的判断顺序来走:
| 需求特征 | 推荐方案 | 原因 |
|---|---|---|
| 只调整一个节点的尺寸或位置 | Modifier.layout | 轻量,不增加额外布局节点 |
| 需要管理多个子组件的排列关系,且子组件在组合阶段已经确定 | Layout | 测量逻辑清晰,性能可控 |
| 需要根据某个子组件的测量结果来决定是否组合新内容 | SubcomposeLayout | 支持在测量阶段动态发射内容 |
| 子组件的排列方式本身就是规则的,比如严格等分、网格 | 优先用 Row、Column、LazyVerticalGrid | 系统组件经过了大量优化,没必要重复造轮子 |
这套判断逻辑帮我少写了很多不必要的代码。比如一开始我想过用自定义 Layout 实现一个等分容器,后来发现官方 Row 的weight已经解决了,根本没有必要。
6.3 最后想说的几句经验
回到开头的问题:Jetpack Compose 自定义布局到底难不难?我的体会是,真正需要背下来的 API 并不多,难的是建立"约束和测量"的思维方式。你写的不是一段嵌套的控件树,而是一段关于空间的数学函数,输入是空间限制,输出是尺寸和坐标。想通了这一点,面对任何复杂布局都不会慌。
还有一点很关键:一定要为自定义布局写好参数注释和约束说明。比如 FlowLayout 里的"子组件不要使用 fill 系列 Modifier"、DynamicBadge 里的"角标只在主内容不足一半宽度时显示",这些约定如果不说明,团队成员迟早会踩坑。布局代码看起来只是几十行,但维护成本几乎都藏在"隐含约定"里。
如果你正准备开始写自己的第一个自定义布局,我建议先从最简单的 SimpleRow 开始,亲手跑一遍测量和放置的流程。然后逐步加入换行、间距、对齐。当你发现自己能用代码精确控制每一个子组件的坐标时,Compose 的布局系统对你来说就不再是黑盒了。