ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Jetpack Compose SubcomposeLayout:测量驱动组合的自定义布局终极指南

Jetpack Compose SubcomposeLayout:测量驱动组合的自定义布局终极指南 在 Jetpack Compose 里做自定义布局绝大多数场景一个Layout就够了拿到子项measure 一遍然后按自己的规则摆放。但如果你遇到“要先知道文字实际占了多高才决定要不要在右下角放一个展开按钮”“要根据每个标签 chip 的真实宽度决定哪些放进这一行、哪些换到下一行”这类的需求Layout就会把你卡死——因为它的测量阶段只能摆弄已经组合好的内容你没法在测量过程中临时决定“再组合一个新孩子出来”。SubcomposeLayout就是专门为这种“测量驱动组合”场景准备的出口它是 Jetpack Compose 自定义布局体系里最深的一层 API官方很多看似魔法的组件比如 Material 的某些流式布局内部都在用它真正搞懂它能让你从“会写布局”跨到“能设计布局”。这篇文章适合已经写过Layout自定义布局、但对SubcomposeLayout只有模糊印象的开发者。我不打算只贴官方文档而是把我自己拆源码、写示例、踩坑的过程完整讲一遍包括它为什么存在、调用时要遵守哪些规则、两个能直接抄的实战例子以及几个你迟早会撞上的崩溃和性能陷阱。1. 为什么需要 SubcomposeLayoutCompose 测量模型的边界1.1 普通 Layout 的“先有鸡还是先有蛋”问题我们先说清楚普通Layout的运作模型。Compose 的测量是单趟的一个Layout节点在measure阶段拿到的是已经组合完成的子项列表你只能对这些子项逐个measure得到Placeable然后决定怎么摆放。这意味着一个硬性前提所有子项必须在测量开始前就存在。但在真实 UI 里内容的出现往往依赖测量结果。我做过一个标签筛选条需求是最多显示 n 个关键词标签放不下的那部分收进一个 “N” 的按钮。问题出在“到底放得下几个标签”只能靠宽度算而“N”本身也是一个要显示的组件。用普通Layout写你没有一个合适的时机去决定要不要组合出那个“N”因为决定这个问题的数据前 n 个标签的宽度总和要等测量完才知道。换个更常见的例子流式标签云的每行能放几个 chipchip 的宽度取决于文字长度、内边距、字体大小这些全是运行时数据。普通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是唯一入口。实际调用形态是这样的你传入一个带接收者 lambdaSubcomposeMeasureScope提供subcompose同时它本身也是MeasureScope所以layout(width, height) { }这种收尾写法照常可用。Composable fun SomeCustomLayout( modifier: Modifier Modifier, items: ListItem ) { 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返回的是ListMeasurable。一般一个 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: ListAny, 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 mutableListOfPairPlaceable, PairInt, 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是稳定且可比较的。如果你用普通ListString那没问题字符串是值类型天然稳定。但如果你传的是可变数据类就得认真实现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用了不同的 slotIdfull_text和visible_text所以它们互不干扰但注意full_text的 subcomposition 也会真实存在组合树里只是不参与放置。如果这种“只用于测量不用于显示”的 slot 太多多少有点浪费你要自己有这个意识。4.3 对比 onTextLayout 方案以及我推荐的取舍有经验的读者会问直接用Text的onTextLayouthasVisualOverflow不是更简单吗确实更简单而且如果产品需求就只是“一段文本的展开收起”我强烈建议用那个方案。它少一次 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 管用得多。
返回列表