在 Jetpack Compose 里做自定义布局,绝大多数场景一个Layout就够了:拿到子项,measure 一遍,然后按自己的规则摆放。但如果你遇到“要先知道文字实际占了多高,才决定要不要在右下角放一个展开按钮”“要根据每个标签 chip 的真实宽度,决定哪些放进这一行、哪些换到下一行”这类的需求,Layout就会把你卡死——因为它的测量阶段只能摆弄已经组合好的内容,你没法在测量过程中临时决定“再组合一个新孩子出来”。SubcomposeLayout就是专门为这种“测量驱动组合”场景准备的出口,它是 Jetpack Compose 自定义布局体系里最深的一层 API,官方很多看似魔法的组件(比如 Material 的某些流式布局内部)都在用它,真正搞懂它能让你从“会写布局”跨到“能设计布局”。
这篇文章适合已经写过Layout自定义布局、但对SubcomposeLayout只有模糊印象的开发者。我不打算只贴官方文档,而是把我自己拆源码、写示例、踩坑的过程完整讲一遍,包括它为什么存在、调用时要遵守哪些规则、两个能直接抄的实战例子,以及几个你迟早会撞上的崩溃和性能陷阱。
1. 为什么需要 SubcomposeLayout:Compose 测量模型的边界
1.1 普通 Layout 的“先有鸡还是先有蛋”问题
我们先说清楚普通Layout的运作模型。Compose 的测量是单趟的,一个Layout节点在measure阶段拿到的是已经组合完成的子项列表,你只能对这些子项逐个measure得到Placeable,然后决定怎么摆放。这意味着一个硬性前提:所有子项必须在测量开始前就存在。
但在真实 UI 里,内容的出现往往依赖测量结果。我做过一个标签筛选条,需求是:最多显示 n 个关键词标签,放不下的那部分收进一个 “+N” 的按钮。问题出在“到底放得下几个标签”只能靠宽度算,而“+N”本身也是一个要显示的组件。用普通Layout写,你没有一个合适的时机去决定要不要组合出那个“+N”,因为决定这个问题的数据(前 n 个标签的宽度总和)要等测量完才知道。
换个更常见的例子:流式标签云的每行能放几个 chip?chip 的宽度取决于文字长度、内边距、字体大小,这些全是运行时数据。普通Layout虽然可以测量后重排,但“标签已经被组合好了”这件事不会变。当你的决策会影响“是否有某个子项存在”时,普通Layout就彻底不够用了。
1.2 SubcomposeLayout 的核心原理:测量阶段也可以组合
SubcomposeLayout在源码上继承了Layout,但它的测量回调里多了一个关键能力:subcompose(slotId, content)。这个方法会在当前测量阶段,把传入的content启动成一个独立的 subcomposition,并返回一组Measurable。你可以立刻对返回值用任意Constraints测量,拿到Placeable再摆放;也可以先测量 A 的结果,再决定要不要subcomposeB。
用人话翻译:普通Layout是“孩子先全部出生,再统一量身高”,SubcomposeLayout是“先量一部分,发现还缺个组件,现场生一个再量”。这种边测量边组合的能力,把 Compose 的组合阶段和测量阶段打通了。
关键点在于这里的slotId。它相当于 subcomposition 的身份证,同一个slotId在一次测量里多次调用能对应到同一个子组合;跨重组时,如果布局没有被销毁,这个子组合内部用remember保存的状态也会保留下来。后面我会专门讲 slot 稳定性的问题,这里先留个印象:slotId选得好不好,直接决定你的布局状态会不会串。
2. 核心 API 与调用规范
2.1 SubcomposeLayoutState 的用途,以及为什么默认就该带上它
SubcomposeLayout有个可选的state参数,类型是SubcomposeLayoutState,通常用rememberSubcomposeLayoutState()创建。很多人第一次写都会漏掉它,默认情况下系统也会帮你隐式持有,但我建议一律显式传进去。
原因有两个。第一,state内部维护了 slot 与 subcomposition 的关联关系,有了它,同一slotId在重组时才能精准复用旧内容而不是从头组合一次。第二,如果你要做 slot 复用的精细控制,比如列表项滚出视野再滚回来时不想重新组合,这个state是唯一入口。
实际调用形态是这样的:你传入一个带接收者 lambda,SubcomposeMeasureScope提供subcompose,同时它本身也是MeasureScope,所以layout(width, height) { }这种收尾写法照常可用。
@Composable fun SomeCustomLayout( modifier: Modifier = Modifier, items: List<Item> ) { val state = rememberSubcomposeLayoutState() SubcomposeLayout( modifier = modifier, state = state ) { constraints -> // 在测量回调里按需组合 val measurable = subcompose(key = items.first().id) { ItemView(item = items.first()) }.first() val placeable = measurable.measure(constraints) layout(constraints.maxWidth, placeable.height) { placeable.placeRelative(0, 0) } } }要注意subcompose返回的是List<Measurable>。一般一个 slot 组合出的内容只会有一个根节点,所以取.first()就够了;但如果你写的content里有多个顶层 composable,比如Text(...); Text(...)这种没有包父布局的写法,列表里就会有多个元素。我建议始终在content里保证单一根节点,不然处理多个测量结果会把你逼疯。
2.2 约束传递与 slot 复用策略
subcompose出来的Measurable不会自动继承父布局的Constraints,你得自己给它measure(constraints)。这里容易犯的错是直接把父约束原封不动往下传。父约束若是Constraints.Infinity(比如你在一个无限滚动的竖直方向里,高度传进来就是无穷大),子项如果还用了fillMaxHeight之类会把无限约束继续放大的写法,运行时就会直接崩。后文会给出排查方法。
再看 slot 复用策略。SubcomposeLayoutState提供了setSlotReusePolicy(policy),参数是SubcomposeSlotReusePolicy。它要解决的问题很实际:列表项滚出屏幕后,此前的 subcomposition 不会立刻回收,而是挂在池子里;等它滚回来时,如果canReuse判断两个 slot 的 content 相同,就能直接拿来用,省掉整个重组和重新测量的开销。
state.setSlotReusePolicy( SubcomposeSlotReusePolicy { oldReuseData, newReuseData -> oldReuseData.content === newReuseData.content } )注意SubcomposeSlotReusePolicy在不同 Compose 版本里的注解要求不一样,有的版本需要@OptIn(ExperimentalLayoutApi::class)才能用,写之前看一眼你依赖的版本,别照抄我的代码就完事。
3. 实战一:手写 FlowLayout(流式换行布局)
3.1 需求分析和数据建模:为什么这里必须用列表数据
我要实现一个标签流式布局,项与项之间横向 8dp、纵向 8dp,放不下了自动换行。这个需求用普通Layout理论上也能做——只要把所有子项组合出来,然后测量重排。但我故意要用SubcomposeLayout来写,因为我想让它支持一个更灵活的场景:根据测量结果动态过滤子项,或者动态决定哪些项参与本轮摆放。
这类需求要求你先把数据建模成可枚举的列表,而不是一个开放的黑盒@Composable () -> Unit。为什么?因为subcompose是按 slot 工作的,你需要为每个子项准备一个独立且稳定的slotId。如果只知道一个整体 content,就无法把其中每个子项拆成独立的 slot,那测量时只会拿到一个巨大的整体Placeable,换行逻辑也就无从谈起。
@Composable fun SimpleFlowLayout( items: List<Any>, modifier: Modifier = Modifier, horizontalGap: Dp = 8.dp, verticalGap: Dp = 8.dp, content: @Composable (item: Any) -> Unit ) { val state = rememberSubcomposeLayoutState() SubcomposeLayout( modifier = modifier, state = state ) { constraints -> val hGapPx = horizontalGap.roundToPx() val vGapPx = verticalGap.roundToPx() val maxWidth = constraints.maxWidth val measured = items.map { item -> subcompose(key = item) { content(item) }.first().measure(constraints) } val placeInfos = mutableListOf<Pair<Placeable, Pair<Int, Int>>>() var lineX = 0 var lineY = 0 var lineHeight = 0 var totalHeight = 0 measured.forEach { placeable -> val needWrap = lineX + placeable.width > maxWidth && lineX > 0 if (needWrap) { lineY += lineHeight + vGapPx lineX = 0 lineHeight = 0 } placeInfos += placeable to (lineX to lineY) lineX += placeable.width + hGapPx lineHeight = maxOf(lineHeight, placeable.height) totalHeight = maxOf(totalHeight, lineY + lineHeight) } layout(maxWidth, totalHeight) { placeInfos.forEach { (placeable, pos) -> placeable.placeRelative(pos.first, pos.second) } } } }3.2 完整实现和关键逻辑拆解
上面这段代码里有几个细节值得展开。
第一,subcompose(key = item)对 slotId 的选择。这里直接用item对象本身,是因为我要求调用方传进来的items是稳定且可比较的。如果你用普通List<String>,那没问题,字符串是值类型,天然稳定。但如果你传的是可变数据类,就得认真实现equals/hashCode,否则重组时 slot 对不上,状态会乱。经验是:能用一个独立的id: Long就不要用整个对象。
第二,换行判定lineX + placeable.width > maxWidth && lineX > 0。注意后面那个lineX > 0条件,它保证第一个子项就算超过最大宽度也不会把自己卡死循环里,而是硬放下去(虽然视觉上会溢出,但至少不崩)。现实产品里你应该再配一个水平滚动策略,或者把超宽的项单独压缩。
第三,layout(maxWidth, totalHeight)里用的是整体最大宽度,而不是最后一行实际占用的宽度。这会让布局在宽度方向上填满父容器的上限。如果你希望组件宽度能自适应内容(类似 wrap_content),需要先把每行最大宽度算出来,再取最大值作为 layout 宽度。两种都有各自的应用场景,我写的是填满型,适合做筛选条底部通栏。
3.3 对比普通 Layout 方案,及这个版本留下的优化空间
如果用普通Layout写同样的换行,代码更短,因为省掉了subcompose这一层。那SubcomposeLayout版本的价值在哪?在于把“子项的组合”延迟到了测量阶段。
举例:我可以轻松实现在测量过程中跳过某些超宽项、提前终止遍历;或者根据当前容器宽度,从完整列表里挑出能放下的一部分来组合,另一部分不组合。这些在普通Layout里做不到,因为子项在测量前已经全部组合完了。
代价也很明显:每个子项都要经历一次 subcomposition,开销比普通测量大得多。所以这个方案适合“数量少且内容不是特别复杂”的标签、筛选 chip 场景。如果你要流式排列几百个图片卡片,请老老实实用LazyVerticalGrid这类带窗口化的组件,别手搓一个SubcomposeLayout去扛大列表,扛不住的。
4. 实战二:文本“展开/收起”的测量后决策
4.1 需求与思路:组合结果依赖测量结果
第二个案例是文本的展开/收起。需求很常见:默认显示 3 行,超出的文本折叠,右下角出现一个“展开”按钮;点“展开”显示全文和“收起”按钮。
这个需求的难点不在于显示按钮,而在于“判断要不要显示按钮”这件事,必须依赖文本完整测量后的高度。普通思路是用Text的onTextLayout回调拿hasVisualOverflow判断是否溢出,但这要求你先把状态提升到外部,而且它只能服务于这个具体的文本场景。SubcomposeLayout的价值在于提供一种通用框架:先把完整文本测一遍,根据结果当场决定要不要组合出按钮,再对按钮和文本的宽度做二次协调。
4.2 实现过程:两次 subcompose、动态收窄文本宽度
实现上要拆成几步:
- 第一步,先组合一份不限制行数的完整文本,测量出它的真实高度
fullHeight。 - 第二步,和
maxLines行对应的最大高度maxHeightPx比较,决定是否需要按钮。 - 第三步,如果不需要按钮,直接用完整文本布局;如果需要按钮,先组合并测量按钮,从总宽度里扣除按钮宽度,重新测量文本,再把文本和按钮放进同一个
layout。
我写了一个教学版实现,注释都在关键行:
@OptIn(ExperimentalLayoutApi::class) @Composable fun ExpandableText( text: String, modifier: Modifier = Modifier, maxLines: Int = 3, expanded: Boolean = false, onExpandedChange: (Boolean) -> Unit ) { val state = rememberSubcomposeLayoutState() val lineHeightPx = with(LocalDensity.current) { 20.sp.toPx() } val maxHeightPx = (lineHeightPx * maxLines).roundToInt() SubcomposeLayout( modifier = modifier, state = state ) { constraints -> // 1. 先测完整文本的原始高度 val fullMeasurable = subcompose("full_text") { Text(text = text, style = MaterialTheme.typography.body1) }.first() val fullPlaceable = fullMeasurable.measure( constraints.copy(maxHeight = Constraints.Infinity) ) val fullHeight = fullPlaceable.height val needCollapse = fullHeight > maxHeightPx val showToggle = needCollapse || expanded // 2. 按需组合“展开/收起”按钮,并测量它 val togglePlaceable: Placeable? = if (showToggle) { subcompose("toggle_button") { Text( text = if (expanded) "收起" else "展开", style = MaterialTheme.typography.body2, color = MaterialTheme.colorScheme.primary, modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable { onExpandedChange(!expanded) } .padding(4.dp) ) }.first().measure(Constraints()) } else null // 3. 文本宽度需要让出按钮宽度 val textMaxWidth = if (togglePlaceable != null) { (constraints.maxWidth - togglePlaceable.width - 8.dp.roundToPx()) .coerceAtLeast(0) } else { constraints.maxWidth } // 4. 按最终宽度重新测量真正显示的文本 val visibleMeasurable = subcompose("visible_text") { Text( text = text, style = MaterialTheme.typography.body1, maxLines = if (expanded) Int.MAX_VALUE else maxLines, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(textMaxWidth.toDp()) ) }.first() val visiblePlaceable = visibleMeasurable.measure( constraints.copy(maxWidth = textMaxWidth) ) // 5. 计算整体高度并摆放 val totalHeight = if (expanded) fullHeight else visiblePlaceable.height layout(constraints.maxWidth, totalHeight) { visiblePlaceable.place(0, 0) togglePlaceable?.place( x = constraints.maxWidth - togglePlaceable.width, y = totalHeight - togglePlaceable.height ) } } }这段代码的关键动作在第 1 步和第 4 步:第一次测量是为了拿到“决策依据”,第二次测量才是拿到“最终显示内容”。两次subcompose用了不同的 slotId(full_text和visible_text),所以它们互不干扰;但注意full_text的 subcomposition 也会真实存在组合树里,只是不参与放置。如果这种“只用于测量不用于显示”的 slot 太多,多少有点浪费,你要自己有这个意识。
4.3 对比 onTextLayout 方案,以及我推荐的取舍
有经验的读者会问:直接用Text的onTextLayout+hasVisualOverflow不是更简单吗?确实更简单,而且如果产品需求就只是“一段文本的展开收起”,我强烈建议用那个方案。它少一次 subcomposition,状态也更直观。
但两套方案的适用面不一样,我做了个小对比:
| 维度 | SubcomposeLayout 方案 | onTextLayout 方案 |
|---|---|---|
| 适用范围 | 任意测量后组合决策 | 仅文本溢出判断 |
| 实现复杂度 | 高,要管理多个 slot | 低,一个回调加一个状态 |
| 状态归属 | 子组合内部自行保留 | 需要外部用 remember 提升 |
| 性能开销 | 多次 subcompose 和测量 | 一次文本布局回调 |
| 可扩展性 | 可以继续加“关注”“点赞”等按钮 | 再加组件就得改逻辑 |
所以我的结论是:别为了炫技什么都上SubcomposeLayout。碰到文本这种情况,先评估有没有现成的专用 API;只有当你确定需要“测量结果参与组合决策、且决策内容不止一个文本”的时候,再考虑这套方案。工具是用来解决问题的,不是用来证明水平的。
5. 性能陷阱与问题排查实录
5.1 三个最容易踩的坑:无限约束、slot 状态丢失、重复组合
先说无限约束崩溃。SubcomposeLayout测出来的子项,如果你直接拿父约束里的无限值去measure,遇到某些自带特殊测量逻辑的组件,会在运行时抛IllegalStateException,日志里通常会带Constraints和Infinity关键词。我的习惯是:所有传给subcompose结果的Constraints,都先copy再手动 clamp 一遍最大值,特别是高度方向要约束成父布局的实际可用最大值,别信父约束里的 Infinity。
再说 slot 状态丢失。很多人图省事把slotId写成items.indexOf(item)或者循环里的index,结果列表一增删,同一个下标对应了不同的数据,子组合里remember的状态全串台了。我踩过一次:一个可拖拽排序的标签列表,用过 index 当 key,拖拽拖了几次之后标签的选中态全乱了,调了半天才定位到 slotId 上。经验就是:slotId 必须与数据身份绑定,不能与位置绑定。
第三种是重复组合。SubcomposeLayout的 measure 阶段本来就是高频调用,如果你在subcompose的内容里写了不稳定的 lambda 捕获,重组时子组合无法复用,等于每次测量都重新组合一遍内容,性能会肉眼可见地掉帧。排查时重点看SubcomposeLayoutState有没有显式传入,以及content里是否有变化无常的局部变量。
5.2 性能自查:怎么判断 subcompose 是否过度
我习惯用三个方法自查。
第一,打开 Layout Inspector,看组合树里 subcomposition 节点多不多,如果明显膨胀,说明你的 slot 拆分太细或者数量太多。第二,在subcompose调用的前后加日志,分别打印调用次数和当前时间戳,对比一次滚动操作里的调用频次;如果单帧里出现大量新增 subcomposition,那就有问题了。第三,检查 item 数量。一个经验阈值是:如果同一屏要组合超过几十个子项,且每个子项内部还有复杂内容,就该考虑LazyColumn/LazyVerticalGrid这类带窗口化机制的容器,而不是纯SubcomposeLayout。
5.3 调试手段:约束和尺寸怎么看
调试自定义布局最痛苦的是看不到中间数据。我举两个可操作的笨办法:
- 在 measure 块开头把
constraints打印出来,直接看minWidth/maxWidth/minHeight/maxHeight是不是预期值。很多时候布局不对,根本不是摆放逻辑写错,而是约束从一开始就给宽了。 - 给
subcompose结果换一个明显背景色,比如临时加一层Modifier.background(Color.Yellow),在真机上用截图对比,一眼就能看出每个 slot 实际占据的区域。
另外一个版本相关的提醒:SubcomposeLayout和它配套的SubcomposeSlotReusePolicy在不同 Compose 版本里的实验性注解要求不一样,早期版本大多要@OptIn(ExperimentalLayoutApi::class),新版本可能已经转正。项目里如果编译报错提示要加注解,先看一眼依赖版本,按提示补上就行,别硬删。我在一个升级 Compose 版本的 MR 里就碰到过这个差异,当时以为是代码写错了,最后发现是 SDK 版本变化。
5.4 我个人用的最小实践原则
最后聊一点自己的体会。SubcomposeLayout的能力上限很高,但它不是随便乱用的东西。在我自己做组件库的经验里,能用普通Layout解决的,绝不上SubcomposeLayout;必须用时,也尽量把subcompose的次数控制到个位数,而且所有 slotId 都设计成与数据绑定的稳定标识。它的本质是在测量阶段引入“按需组合”,这打破了 Compose 常规的执行模型,带来便利的同时也把性能风险推给了使用者。
如果你的需求确实走到了“必须根据测量结果决定组合内容”这一步,建议先拿两个实战案例里的最小结构跑通:一个做换行,一个做测量后决策。跑通之后再去调 slot 复用、约束裁剪和状态保留这些精细化问题。理解了边界,比背 API 管用得多。