mcpField
O
optimizing-lazy-layouts

optimizing-lazy-layouts

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

关于 optimizing-lazy-layouts

面向 Jetpack Compose 开发者的懒加载布局优化技能,覆盖列表与网格的稳定键、内容类型、动画和分配优化,并强调验证条目稳定性。

主要功能

  1. 01为条目配置稳定键
  2. 02为异构列表设置内容类型
  3. 03使用 Modifier.animateItem()
  4. 04将高开销分配移出条目块
  5. 05验证条目可跳过性
  6. 06通过工具检查重组

适用场景

  1. 01减少 LazyColumn 滚动卡顿
  2. 02在插入或重排后保留条目状态
  3. 03优化混合类型的信息流
  4. 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。

参考资料

评论

支持 Markdown