关于 optimizing-lazy-layouts
面向 Jetpack Compose 开发者的懒加载布局优化技能,覆盖列表与网格的稳定键、内容类型、动画和分配优化,并强调验证条目稳定性。
主要功能
- 01为条目配置稳定键
- 02为异构列表设置内容类型
- 03使用 Modifier.animateItem()
- 04将高开销分配移出条目块
- 05验证条目可跳过性
- 06通过工具检查重组
适用场景
- 01减少 LazyColumn 滚动卡顿
- 02在插入或重排后保留条目状态
- 03优化混合类型的信息流
- 04诊断滚动时条目反复重组
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill optimizing-lazy-layouts安装后在 Claude Code 中输入/optimizing-lazy-layouts确认已加载。
详细介绍
优化延迟布局——Keys、contentType 和 animateItem()
延迟布局只组合可见内容,但仍有两项开销:本应复用的项却重新组合(缺少 key),以及随滚动速度累积的逐项分配开销(缺少 contentType,在 items { } 内创建修饰符链)。这两项都有一行代码就能完成的修复。本技能将教 Claude 如何正确应用这些修复,并验证项组合函数本身是否可跳过。预取调优是另一个主题——请参阅 ../configuring-lazy-prefetch/SKILL.md。
何时使用本技能
- 开发者反馈
LazyColumn、LazyRow、LazyVerticalGrid或LazyHorizontalGrid滚动卡顿、掉帧或不流畅。 - 插入、移除或重新排序时,项丢失滚动位置、焦点或组合状态。
- 混合类型的信息流(卡片 + 标题 + 广告 + 轮播)感觉迟缓,即使每一行都很轻量。
- 添加了
Modifier.animateItem(),但插入或移除时没有动画。 - 编译器报告显示项组合函数为
unstable/不可跳过,或者@TraceRecomposition显示项组合函数在每次滚动时都会重新组合。
何时不使用本技能
- 瓶颈在于预取窗口(项较重、滚动速度快、希望扩大前后预取窗口)→ 使用
../configuring-lazy-prefetch/SKILL.md。 - 项组合函数本身接收了不稳定参数(
List<Foo>、Flow<Foo>、领域模型var)→ 先运行../../stability/diagnosing-compose-stability/SKILL.md,然后运行../../stability/stabilizing-compose-types/SKILL.md。 - 项内的动画在组合阶段读取
state.value,导致每帧都重新组合该行 → 使用../../recomposition/deferring-state-reads/SKILL.md。 - 滚动位置推导(例如
firstVisibleItemIndex == 0)是热点路径 → 使用../../recomposition/choosing-derivedstateof/SKILL.md。
前置条件
- 使用 Compose Foundation 1.7+,以支持
Modifier.animateItem()(正式版替代实验性的animateItemPlacement)。 - 使用 Kotlin 2.0.0+,并应用
org.jetbrains.kotlin.plugin.compose。强跳过默认开启;不可跳过的项组合函数在快速滚动时开销会被放大。 - 使用真机 + release 构建进行测量。Skydoves 热门观点 #5:debug 构建会骗人(Live Literals、解释执行模式)。准备好测量时,请参阅
../../measurement/generating-baseline-profiles/SKILL.md。
工作流
- 1. 审查每个
items(...)调用。 遍历每个LazyListScope.items(list)、items(count)、itemsIndexed(list)以及对应的LazyGridScope调用。对于每个调用,判断每个元素是否具有超出单次组合生命周期的稳定标识。如果有——而且几乎总是如此——请使用服务端稳定 ID 提供key = { it.id }。不得使用列表索引、每次发射时求值的UUID.randomUUID(),或可变对象的hashCode()。
// WRONG
LazyColumn { items(snacks) { snack -> SnackRow(snack) } }
// WRONG because: index-based identity → insert/remove discards composition state and breaks animateItem().
// RIGHT
LazyColumn {
items(
items = snacks,
key = { it.id },
contentType = { it::class },
) { snack ->
SnackRow(snack, Modifier.animateItem())
}
}
-
2. 为异构列表添加
contentType。 延迟布局会维护按类型划分的组合缓存,类似于 RecyclerView 的 view-type。当项目 N + 1 的contentType与回收槽位的相同时,会复用缓存的组合;否则会丢弃并重新构建。对于同构列表,Compose 会推断出单一内容类型,因此contentType是可选的。对于混合信息流(卡片、标题、广告、轮播、分隔线),必须提供稳定的类型判别值。 -
3. 验证项目可组合函数的稳定性。 运行
../../stability/diagnosing-compose-stability/SKILL.md。如果项目可组合函数接受一个unstable参数,那么无论如何处理key/contentType都无济于事——该行仍会在每次由滚动驱动的快照更新时重新组合。进一步调优前,请先使用../../stability/stabilizing-compose-types/SKILL.md修复。 -
4. 将分配开销大的值移出 items lambda。 items lambda 会在每次由滚动驱动的(重新)组合中对每个项目运行一次。在 lambda 内构建的画笔、颜色解析结果、形状和
BorderStroke实例会在每次遍历时重新分配。将常量提升到LazyColumn外部,或使用基于remember的缓存。Modifier 链本身开销很低,因为 Compose 会按结构去重——只有在性能分析证明确有必要时才提升Modifier。 -
5. 添加
Modifier.animateItem()以保持视觉连贯性。 与稳定的key配合使用。动画会在插入、移除和重排时运行;没有key,动画就无法绑定到标识,因此会静默失效。默认的淡入 / 淡出 / 放置弹簧效果通常是正确的;仅当设计系统有要求时,才使用fadeInSpec、fadeOutSpec、placementSpec调整。 -
6. 在 items 代码块外缓存常用画笔 / 颜色 / 形状。 每次项目组合时解析
painterResource(...)、MaterialTheme.colorScheme.surface、RoundedCornerShape(...),累积起来开销不小。将它们提升到屏幕级可组合函数并向下传递,或在LazyColumn父级中使用remember缓存一次。 -
7. 使用
@TraceRecomposition和 Layout Inspector 验证。 在受控滚动期间,预期每个项目可组合函数在每次实际状态变化时最多重新组合一次,而不是在每个滚动更新时都重新组合。Layout Inspector → Recomposition Counts 列应趋于平稳,而非持续单调增长。
模式
模式:缺少 key
// WRONG
LazyColumn {
items(snacks) { snack -> SnackRow(snack) }
}
// WRONG because: items default to index-based identity. On insert/remove/reorder, every position past the change point has a different "identity", composition state and scroll-restoration are lost, and Modifier.animateItem() has nothing to animate from.
// RIGHT
LazyColumn {
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }
}
模式:随机或不稳定的 key
// WRONG
items(snacks, key = { UUID.randomUUID() }) { snack -> SnackRow(snack) }
// WRONG because: a fresh key on every recomposition guarantees the cached composition is discarded every time — strictly worse than no key.
// WRONG
items(snacks, key = { it.hashCode() }) { snack -> SnackRow(snack) }
// WRONG because: hashCode() of a mutable type changes when fields mutate, breaking identity continuity for the same logical item.
// RIGHT
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }
模式:混合信息流缺少 contentType
// WRONG
items(feed, key = { it.id }) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}
// WRONG because: cached compositions of one type are discarded when scrolled into a different type's slot — every row crossing a type boundary is a fresh build instead of a recycled update.
// RIGHT
items(
items = feed,
key = { it.id },
contentType = { it::class },
) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}
模式:Modifier.animateItem() 缺少稳定的 key
// WRONG
items(snacks) { snack ->
SnackRow(snack, Modifier.animateItem())
}
// WRONG because: animateItem() binds animation state to the item's key. With no key, identity is index-based, so an insert at position 0 looks like every-row-changed and nothing animates correctly.
// RIGHT
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, Modifier.animateItem())
}
模式:在 items lambda 内进行分配
// WRONG
items(snacks, key = { it.id }) { snack ->
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
// WRONG because: painterResource resolution and BorderStroke allocation happen on every item composition; at high scroll velocity these compound into measurable allocation pressure.
// RIGHT
@Composable
fun SnackList(snacks: ImmutableList<Snack>) {
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
LazyColumn {
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
}
}
注意:Compose 会在内部对结构相等的 Modifier 链去重,因此每个项目都重新分配 Modifier.fillMaxWidth().padding(16.dp) 属于一种微优化。只有在性能分析确定其为瓶颈时,才提升 Modifier——过早使用 remember { Modifier.… } 会增加冗余,却没有可测量的收益。
模式:不稳定的项目可组合函数抵消所有优化收益
// WRONG
@Composable
fun SnackRow(snack: Snack, tags: List<String>) { /* ... */ }
// Caller:
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, tags = snack.tags)
}
// WRONG because: List<String> is an unstable parameter under inference; every scroll-driven recomposition recomposes the row body even though the snack didn't change.
// RIGHT
@Immutable
data class Snack(val id: Long, val name: String, val tags: ImmutableList<String>)
@Composable
fun SnackRow(snack: Snack) { /* ... */ }
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
SnackRow(snack)
}
交叉参考:../../stability/stabilizing-compose-types/SKILL.md。
模式:跨度混合的 LazyVerticalGrid
// RIGHT — keys + contentType apply to grids identically
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(
items = feed,
key = { it.id },
contentType = { it::class },
span = { item -> if (item is FeedItem.Header) GridItemSpan(maxLineSpan) else GridItemSpan(1) },
) { item ->
when (item) {
is FeedItem.Header -> HeaderRow(item, Modifier.animateItem())
is FeedItem.Card -> CardCell(item, Modifier.animateItem())
}
}
}
强制规则
- 必须为每个
items(...)块指定key,前提是条目身份会在单次组合之外持续存在(实际上:所有由领域对象支撑的列表)。 - 必须使用服务端稳定 ID 作为键。不得使用列表索引,不得使用每次发射时计算的
UUID.randomUUID(),不得使用可变对象的hashCode()。 - 必须为异构列表(卡片 + 标题 + 广告等)指定
contentType。使用稳定的类型判别符,例如it::class或密封enum。 - 不得在没有稳定
key的情况下使用Modifier.animateItem()——动画会静默地不执行。 - 必须先使用
../../stability/diagnosing-compose-stability/SKILL.md验证条目可组合项的稳定性,再归咎于惰性布局。不稳定的条目参数会抵消key/contentType带来的所有收益。 - 不得将
items { }包在额外的内联可组合包装器中(Row { items { } }),试图“强制”其可跳过——Row/Column/Box本身就不可重启、不可跳过(skydoves 热门观点 #3)。 - **首选:**仅在完成条目级修复后,再结合
../configuring-lazy-prefetch/SKILL.md用于高速滚动界面。 - **首选:**在宣布修复完成前,先在 release + R8 环境和真实设备上进行测量(skydoves 热门观点 #5)。
验证
- 在真实设备的 release 构建上重现原始滚动卡顿,然后在修复后重新录制;丢帧率应有可测量的下降。
- 插入 / 删除 / 重新排序操作应保留滚动位置和每个条目的状态(焦点、展开状态、拖动后的视频位置)。
- 插入和删除时,
Modifier.animateItem()会按预期执行淡入淡出和位置动画。 - 在 Layout Inspector → Recomposition Counts 列中,条目可组合项的计数在持续滚动期间趋于稳定,而不是单调递增。
- 条目可组合项上的
@TraceRecomposition仅在状态确实发生变化时显示重组,而不是在每次由滚动触发的失效时都显示。 - 编译器报告(
composables.txt)显示条目可组合项为restartable skippable,且所有参数均为stable或runtime。
参考资料
- Android Developers — 列表和网格:https://developer.android.com/develop/ui/compose/lists
- Android Developers — 性能概述:https://developer.android.com/develop/ui/compose/performance
- Android Developers — 实用性能 Codelab:https://developer.android.com/codelabs/jetpack-compose-performance
- Android Developers — Jetpack Compose 新特性(2025 年 4 月,1.8):https://android-developers.googleblog.com/2025/04/whats-new-in-jetpack-compose-april-25.html
- Ben Trengrove — 调试重组:https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
- Chris Banes — Compose 性能标签页:https://chrisbanes.me/tags/jetpack-compose-performance/
- skydoves — Jetpack Compose 的 6 条指南:https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- skydoves — compose-performance 中心页:https://github.com/skydoves/compose-performance