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 与 Placeable2.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 中你会拿到一个ListMeasurable。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 mutableListOfListPlaceable() val rowHeights mutableListOfInt() val rowWidths mutableListOfInt() val currentRow mutableListOfPlaceable() 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 idCompose 会用它来区分不同的内容区块并保持状态。如果你的某个 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.intrinsicSizeCompose 提供了一套 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 还是 SubcomposeLayout6.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 的布局系统对你来说就不再是黑盒了。
阅读完成 · 觉得有帮助?