关于 avoiding-subcomposition-pitfalls
面向 Android Compose 开发者的技能,用于诊断代价较高的 SubcomposeLayout 用法,并减少测量阶段的组合。它帮助判断何时替换子组合、避免嵌套,或在确有需要时进行调优。
主要功能
- 01定位热路径中的 SubcomposeLayout
- 02用布局修饰符替换不必要的子组合
- 03避免嵌套使用子组合布局
- 04在合理场景复用或预组合插槽
- 05通过 Perfetto 追踪验证变更
适用场景
- 01降低首帧或配置变更时的开销
- 02处理惰性列表项中 BoxWithConstraints 引发的卡顿
- 03简化嵌套的 Scaffold 或 BoxWithConstraints
- 04调优需要测量约束的布局
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill avoiding-subcomposition-pitfalls安装后在 Claude Code 中输入/avoiding-subcomposition-pitfalls确认已加载。
详细介绍
避免子组合陷阱——不要在测量阶段进行组合
SubcomposeLayout 会在测量阶段组合其内容,而不是在父级的组合阶段组合。这样做以增加布局开销为代价,让你可以在子项的组合过程中使用已测量的约束。BoxWithConstraints、material3.Scaffold 和懒加载布局都基于它构建,因此只有在嵌套使用或将其放入高频路径时,这项开销才会显现。本技能将教 Claude 如何检测误用、在不需要 SubcomposeLayout 能力时如何用开销更低的原语替代它,以及在确实需要使用时如何保持高效。
何时使用此技能
- 开发者仅仅为了读取
maxWidth/maxHeight并在几个可组合项之间进行选择,就用BoxWithConstraints包裹内容;但屏幕在首帧或配置变更时(旋转、IME 显示)感觉卡顿。 - 一个
BoxWithConstraints或Scaffold嵌套在另一个BoxWithConstraints或Scaffold中,使子组合开销倍增。 - 开发者报告,
LazyColumn/LazyRow的列表项中有一个BoxWithConstraints,而添加这个包裹后出现了滚动卡顿。 - 开发者手动编写了
SubcomposeLayout块,但只用约束计算最终位置,从未用它来驱动子项组合。 - Layout Inspector 或 Perfetto 跟踪显示,
Compose:applyChanges或测量阶段的组合次数会随父级约束变化而增加(旋转、拖动调整大小、容器尺寸动画)。 - 自定义
SubcomposeLayout的measurePolicy块会在每次测量时于subcompose(slotId) { … }中分配一个新的可组合 lambda。AndroidX 在compose/lint/internal-lint-checks/中维护了一个名为ComposableLambdaInMeasurePolicy的内部 lint 检查,专门标记其自身代码库中的这种模式;BoxWithConstraints、material.Scaffold和material3.TabRow等已发布 API 会抑制此检查,因为这种权衡是其公开契约的根本组成部分。在应用代码中重现这种模式,却没有相应的抑制理由,同样要付出代价。
何时不要使用此技能
- 开发者需要组合一个内容依赖于另一个子项已测量尺寸的子项——这是
SubcomposeLayout的典型用例,没有开销更低的原语可用。保留SubcomposeLayout,并对其进行调优(参见「模式:调优有充分理由使用的 SubcomposeLayout」)。 - 慢路径出现在
LazyColumn列表项中,但原因是不稳定参数,而非子组合。请先使用../../stability/diagnosing-compose-stability/SKILL.md进行诊断。 - 驱动重复测量的状态读取来自动画值,而非结构性约束。这属于
../deferring-state-reads/SKILL.md。 - 用户想要优化懒加载列表预取——这属于
../../lists/configuring-lazy-prefetch/SKILL.md。
前置条件
- 熟悉 Compose 的三个阶段(组合、布局、绘制)。如果不确定,请先阅读
../deferring-state-reads/references/three-phases.md。 - 进行任何最终测量时,使用带 R8 的 release 构建。
../../measurement/testing-compose-in-release-mode/SKILL.md介绍了为什么 debug 构建会产生误导。 - 配备 Layout Inspector 或 Perfetto 跟踪工具。
../../measurement/tracing-recompositions-at-runtime/SKILL.md介绍了如何接入androidx.compose.runtime:runtime-tracing,以便在系统跟踪中显示测量阶段的工作。 - 使用提供
SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int)工厂和懒加载列表路径可暂停子组合功能的现代 Compose UI 版本。这两项功能都已包含在当前的androidx.compose.ui:ui/androidx.compose.foundation:foundation构件中;如果锁定到较旧版本,请查看 https://developer.android.com/jetpack/androidx/releases/compose-ui 和 https://developer.android.com/jetpack/androidx/releases/compose-foundation 上的发行说明。
工作流
-
1. 找出热路径中的所有
SubcomposeLayout使用者。 在受影响的屏幕中搜索BoxWithConstraints、Scaffold、SubcomposeLayout,以及任何委托给它们的自定义@Composable。material3.Scaffold和foundation.layout.BoxWithConstraints都是用SubcomposeLayout实现的;二者开销相同。 -
2. 对每个使用者,判断它是否在组合期间实际使用了约束。 如果
BoxWithConstraints只读取maxWidth并将其作为参数传给子项,就浪费了子组合机制。如果BoxWithConstraints根据maxWidth < 600.dp在两个完全不同的@Composable树之间进行选择,那么这种用法就是合理的。 -
3. 用开销更低的原语替换不合理的
BoxWithConstraints。 如果使用方只需要最终的像素尺寸(例如用于计算偏移量、alpha 或绘制指令),则使用Modifier.layout { measurable, constraints -> … }或Modifier.onSizeChanged { … }。二者都会在布局阶段运行,不会启动新的组合。 -
4. 避免嵌套使用子组合布局。 嵌套两个
Scaffold,或者在Scaffold插槽中的BoxWithConstraints内再包含另一个BoxWithConstraints,都会增加测量阶段组合的开销。将外层布局提升到屏幕根节点,并在内部的分支中使用普通的Box/Column/Row。 -
5. 绝不要用
BoxWithConstraints包裹LazyColumn/LazyRow的列表项。 每个列表项都会在测量期间进行子组合。如果列表项需要可用宽度,就从父级向下传递一次(LazyColumn已经知道自身的约束),或者使用Modifier.fillMaxWidth().onSizeChanged { … }进行一次性尺寸测量。 -
6. 在有充分理由使用
SubcomposeLayout时,配置SubcomposeLayoutState(SubcomposeSlotReusePolicy(n))。 状态对象会保留最多n个先前使用过的插槽,使下一次子组合能够复用它们的插槽表和节点子树,而不必付出全新组合的开销。工厂SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int)位于androidx.compose.ui.layout中。 -
7. 对于可预测的后续插槽,在上一帧调用
state.precompose(slotId, content)。 它会返回一个PrecomposedSlotHandle;测量期间下一次调用subcompose(slotId, …)时,就会跳过组合步骤。这正是惰性布局跨帧分摊列表项组合的方式。 -
8. 在 Perfetto 系统跟踪中进行验证。 添加
androidx.compose.runtime:runtime-tracing,并确认在滚动或调整大小时,Compose:recompose和Compose:applyChanges不再随着父级约束变化的频率触发。测量结果应显示,只有发生实际结构变化时(例如旋转)才会进行子组合,而不是每一帧都进行。
模式
模式:BoxWithConstraints 用于读取尺寸——替换为 Modifier.onSizeChanged
// WRONG
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
BoxWithConstraints {
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
Image(
painter = painterResource(R.drawable.hero),
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.graphicsLayer {
translationX = scrollOffset() * widthPx * 0.2f
},
contentDescription = null,
)
}
}
// WRONG because: BoxWithConstraints subcomposes its content during the measure phase. Each
// new constraint (rotation, IME show, animated parent size) re-runs that subcomposition just
// to read maxWidth, when a Modifier.onSizeChanged would have given the same value with no
// extra composition.
// RIGHT
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
var widthPx by remember { mutableStateOf(0f) }
Image(
painter = painterResource(R.drawable.hero),
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.onSizeChanged { widthPx = it.width.toFloat() }
.graphicsLayer {
translationX = scrollOffset() * widthPx * 0.2f
},
contentDescription = null,
)
}
onSizeChanged 在布局阶段运行(不会进行子组合);底层的 onRemeasured 会在每次布局过程中调用,并在测量尺寸与上次不同时将其转发给用户回调。graphicsLayer 块会在 Draw 阶段运行,使用最新的 widthPx 和最新的滚动偏移量。
模式:用 BoxWithConstraints 选择布局——保留,但将其提升
// WRONG
@Composable
fun ProductCard(product: Product) {
BoxWithConstraints {
if (maxWidth < 360.dp) {
CompactProductCard(product)
} else {
WideProductCard(product)
}
}
}
@Composable
fun ProductGrid(products: List<Product>) {
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(products) { ProductCard(it) }
}
}
// WRONG because: every grid cell wraps its content in BoxWithConstraints. Each cell pays
// for a subcomposition during the grid's measure pass. With 200 cells visible across a long
// scroll session, that is 200 redundant subcompositions on every measurement.
// RIGHT
@Composable
fun ProductGrid(products: List<Product>) {
BoxWithConstraints {
val isCompact = maxWidth / 2 < 360.dp
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(products) { product ->
if (isCompact) CompactProductCard(product) else WideProductCard(product)
}
}
}
}
网格根节点中的单个 BoxWithConstraints 会在父级约束变化时进行一次子组合。这个选择结果会以普通 Boolean 的形式传递给每个列表项,因此它是稳定且可跳过的。
模式:嵌套 Scaffold——合并为一个
// WRONG
@Composable
fun ProfileScreen(...) {
Scaffold(topBar = { TopBar(...) }) { outer ->
Scaffold(
modifier = Modifier.padding(outer),
bottomBar = { ProfileTabBar(...) },
) { inner ->
ProfileContent(modifier = Modifier.padding(inner))
}
}
}
// WRONG because: each Scaffold is a SubcomposeLayout with its own measure-phase composition.
// Nesting them doubles that cost on every frame whose constraints change (IME show, rotation,
// nav bar inset change).
// RIGHT
@Composable
fun ProfileScreen(...) {
Scaffold(
topBar = { TopBar(...) },
bottomBar = { ProfileTabBar(...) },
) { padding ->
ProfileContent(modifier = Modifier.padding(padding))
}
}
Scaffold 已经支持在一次子组合中提供顶部栏、底部栏、Snackbar 宿主和 FAB。请使用提供的插槽,而不是再嵌套一个 Scaffold。
模式:调优合理使用的 SubcomposeLayout
如果屏幕确实需要在测量时访问约束(例如,标签页宽度由各自内容决定、同时共享行最大宽度的标签栏;由前瞻布局驱动的共享元素容器;与已测量锚点对齐的自定义弹出层),请保留 SubcomposeLayout,但调优其状态。
@Composable
fun BadgeRow(badges: List<Badge>) {
val slotState = remember {
SubcomposeLayoutState(SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse = 8))
}
SubcomposeLayout(state = slotState) { constraints ->
val measurables = subcompose(slotId = "badges") {
badges.fastForEach { Badge(it) }
}
val placeables = measurables.map { it.measure(constraints) }
val width = placeables.sumOf { it.width }
val height = placeables.maxOf { it.height }
layout(width, height) {
var x = 0
placeables.fastForEach { p ->
p.place(x, 0)
x += p.width
}
}
}
}
SubcomposeSlotReusePolicy(8) 允许布局保留最多 8 个之前组合过的槽位。当槽位 ID 再次出现时(例如,徽标离开屏幕后又重新进入),布局会复用现有槽位表,而不是重新运行组合。惰性布局使用类似机制,并实现了自己的策略。
对于可预测的后续槽位,请在更早的一帧调用 state.precompose(slotId, content),如果最终不需要该槽位,则释放返回的 PrecomposedSlotHandle。测量期间的下一次 subcompose(slotId, …) 会复用预先组合的内容,不再执行组合步骤。
模式:包含 BoxWithConstraints 的 LazyListScope.item { }
// WRONG
LazyColumn {
items(rows) { row ->
BoxWithConstraints {
val isWide = maxWidth > 600.dp
RowContent(row, isWide)
}
}
}
// WRONG because: each row item subcomposes during the LazyColumn's measure pass. Scroll the
// list and every newly-visible item pays for a fresh subcomposition. On a 60Hz feed this is
// the difference between smooth and dropped frames.
// RIGHT
@Composable
fun RowFeed(rows: List<Row>) {
BoxWithConstraints {
val isWide = maxWidth > 600.dp
LazyColumn {
items(rows, key = { it.id }) { row -> RowContent(row, isWide) }
}
}
}
信息流根节点处唯一的 BoxWithConstraints 仅在信息流本身重新测量时才会执行子组合。每行中的 item 都是普通的 RowContent 调用,并带有稳定的 Boolean 参数,可由强跳过处理。
强制规则
- 禁止仅为读取
maxWidth/maxHeight,以实现Modifier级别的效果而使用BoxWithConstraints。改用Modifier.onSizeChanged { … }或Modifier.layout { … }。 - 禁止在
LazyColumn/LazyRow/LazyVerticalGrid的 item 内放置BoxWithConstraints。将约束读取提升到惰性布局的父节点,并向下传递解析后的值。 - 禁止在另一个
Scaffold内嵌套Scaffold。使用外层Scaffold已提供的插槽(topBar、bottomBar、snackbarHost、floatingActionButton)。 - 必须在子项的组合确实依赖测量值(父约束、同级尺寸)时保留
SubcomposeLayout。关键是使用合适的工具,而不是禁止使用SubcomposeLayout。 - 必须在槽位会动态出现和消失时,将
SubcomposeSlotReusePolicy传给SubcomposeLayoutState。默认的空操作策略会立即释放槽位,并在每次槽位重新出现时付出完整的组合成本。 - **优先:**对于可预测的后续槽位,在前一帧调用
state.precompose(slotId, content),并让测量阶段的subcompose复用结果。 - **优先:**当值仅用于
graphicsLayer { }、drawBehind { }或其他布局/绘制阶段的消费者时,使用Modifier.onSizeChanged { … }测量一次,而不是在BoxWithConstraints主体中读取maxWidth。
验证
- 使用
androidx.compose.runtime:runtime-tracing录制的 Perfetto 系统跟踪显示,受影响子树的每次测量都不再触发Compose:recompose和Compose:applyChanges。它们只应在结构确实发生变化时触发(旋转、导航、列表项插入)。 - Layout Inspector 中,任何由原先的
BoxWithConstraints包裹的子树,其重组计数都不会随父约束变化而递增。 - 对于保留的自定义
SubcomposeLayout,subcompose(slotId) { … }内容 lambda 应提升为由remember稳定保存的引用(或者该 lambda 本身开销很小且有意如此,并用一行注释说明为何在此处每次测量都创建新 lambda 是安全的)。这是 AndroidX 的ComposableLambdaInMeasurePolicylint 在 AndroidX 代码库中强制执行的模式。 - 对迁移文件执行 grep,确认
LazyColumn/LazyRow/LazyVerticalGrid的 item 内没有任何BoxWithConstraints块。 - 迁移文件中没有任何
Scaffold嵌套在另一个Scaffold内。 - 对于每个保留的
SubcomposeLayout,都将SubcomposeSlotReusePolicy传给SubcomposeLayoutState,或者注释明确说明所有槽位都是永久存在的,因此无需复用。
参考资料
- Android Developers — 自定义布局:https://developer.android.com/develop/ui/compose/layouts/custom
- Android Developers — Jetpack Compose 的各个阶段:https://developer.android.com/develop/ui/compose/phases
- Android Developers —
SubcomposeLayout参考:https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2) - Compose UI 源码 —
SubcomposeLayout.kt:https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt - Compose foundation 源码 —
BoxWithConstraints.kt:https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/BoxWithConstraints.kt - Material3 源码 —
Scaffold.kt(使用SubcomposeLayout实现界面框架布局):https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt - Chris Banes — “Composable metrics”(Perfetto 跟踪记录中的子组合开销):https://chrisbanes.me/posts/composable-metrics/
- skydoves — Jetpack Compose Mechanism(组合与子组合):https://speakerdeck.com/skydoves/jetpack-compose-mechanism
../deferring-state-reads/references/three-phases.md— 三阶段模型的背景资料,说明了“不要在测量期间运行组合”的原因。../../lists/configuring-lazy-prefetch/SKILL.md— 惰性布局使用precompose和可暂停子组合;自定义SubcomposeLayout用户也可以使用相同的 API。