mcpField
A
avoiding-subcomposition-pitfalls

avoiding-subcomposition-pitfalls

github.com/skydoves开发520130 次安装19 周前更新
代码仓库

关于 avoiding-subcomposition-pitfalls

面向 Android Compose 开发者的技能,用于诊断代价较高的 SubcomposeLayout 用法,并减少测量阶段的组合。它帮助判断何时替换子组合、避免嵌套,或在确有需要时进行调优。

主要功能

  1. 01定位热路径中的 SubcomposeLayout
  2. 02用布局修饰符替换不必要的子组合
  3. 03避免嵌套使用子组合布局
  4. 04在合理场景复用或预组合插槽
  5. 05通过 Perfetto 追踪验证变更

适用场景

  1. 01降低首帧或配置变更时的开销
  2. 02处理惰性列表项中 BoxWithConstraints 引发的卡顿
  3. 03简化嵌套的 Scaffold 或 BoxWithConstraints
  4. 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 的 ComposableLambdaInMeasurePolicy lint 在 AndroidX 代码库中强制执行的模式。
  • 对迁移文件执行 grep,确认 LazyColumn/LazyRow/LazyVerticalGrid 的 item 内没有任何 BoxWithConstraints 块。
  • 迁移文件中没有任何 Scaffold 嵌套在另一个 Scaffold 内。
  • 对于每个保留的 SubcomposeLayout,都将 SubcomposeSlotReusePolicy 传给 SubcomposeLayoutState,或者注释明确说明所有槽位都是永久存在的,因此无需复用。

参考资料

评论

支持 Markdown