本文译自「Why Your LazyColumn Is Still Janky」,原文链接proandroiddev.com/why-your-la...,由Subhankar Bag发布于2026年9月22日。

现代 Compose 性能优化系列的第 4 部分。此前文章: 别再像 2024 年那样编写 Compose 代码了、 你的 Compose 应用并不慢------慢的是你的状态,以及 我在生产环境中经常看到的 Compose 重组错误。如果你是从这里加入的,本文假设你的状态读取是干净的,并且没有人在一行中构建 SimpleDateFormat。
你的状态是干净的。你的重组计数是平稳的。编译器报告显示每一行都是可重启/可跳过的。
信息流仍然卡顿。
并非总是如此------这才是最令人恼火的地方。在你的 Pixel 手机上流畅运行,在办公室的 Wi-Fi 网络下也流畅运行,但有人用一台中端设备在 4G 网络下录制了一段屏幕视频到 Slack 中,视频显示,每当出现三个新条目时,列表就会出现明显的卡顿。你自己用测试设备滚动查看,也能感受到:不是卡死,只是略有迟疑。原本应该只需 8 毫秒的帧,却用了 40 毫秒。
我花了比我愿意承认的更长的时间才理解这一点:滚动时感觉到的卡顿通常并非滚动本身造成的。 当你的手指移动时,Compose 几乎已经没有剩余需要决策的了。真正的开销早在之前就已经产生------当你声明列表、塑造项目形态、决定图像解码位置时。滚动只是最终产生这些开销的那一刻。
本文讨论的就是这笔账单。文章指出了列表项存在的六个具体问题,解释了为什么每个问题只在滚动时才会出现,并介绍了修复前后的对比。
TL;DR:你的清单出现问题的六个原因:
- 没有键,或者键不稳定------Compose 无法区分你的项目,因此会重新构建它们。
- 嵌套滚动容器和无限制的高度 ------一个位置错误的
Modifier.verticalScroll会导致整个列表一次性重新构建。 - 项目内容开销过大------虽然可以跳过该行,但第一次构建仍然需要 20 毫秒。
- 在主线程上解码图像------即使没有出现在重新构建中,图像的解码也完全不计入计算。
- 预取针对错误的列表进行了调整------默认缓存窗口假定项目较少。
- 在调试版本中进行测量------你遇到的很多问题在正式版中并不存在。
那么:如何通过一次追踪来判断你拥有的是六种中的哪一种呢?
首先:一个滚动帧究竟要完成什么
在 60Hz 设备上需要 16 毫秒,在 120Hz 设备上需要 8 毫秒。在这个时间范围内,对于每次快速移动的每一帧,Compose 都必须:
- 确定哪些元素当前可见(成本低------只需计算滚动偏移量)
- 合成所有刚刚进入视口且未预取的元素(成本高------这是元素的整个可合成函数,首次运行,没有任何缓存)
- 测量并放置可见元素(成本中等)
- 绘制(成本中等,除非你需要解码某些内容)
步骤 2 是列表崩溃的地方。对现有行进行重新组合成本很低------这正是第 2 部分和第 3 部分要讨论的内容。但是,首次组合一个全新的行则代价高昂:每个 remember 语句都要重新执行,每个子可组合元素都要执行,每个修饰符链都要构建。如果在滚动帧内执行这些操作,就会超出预算。
这重新定义了整个问题。滚动性能主要取决于两个问题:
- 制作一件物品需要多少成本?(错误 1、3、4)
- 何时付款------提前付款,还是在需要付款时付款?(错误 2、5)
以下所有内容都属于这两种情况之一。
1. 没有键,或键不稳定
这是列表中最便宜的解决方法,但也是最常见的被忽略的解决方法。
❌ 造成不必要的工作------职位标识:
kotlin
LazyColumn {
items(messages) { message ->
MessageRow(message)
}
}
如果没有键,Compose 会通过索引来识别项目。这对于静态列表来说没问题,但对于实时列表来说却是一场灾难。当聊天流顶部出现三条新消息时,其下方所有项目的索引都会发生变化------因此,在 Compose 看来,第 4 条消息并没有移动,而是变成了另一个项目。与位置相关的状态(例如行内位置、嵌套行内的滚动位置、展开/折叠状态、动画效果等)会被重新分配给错误的内容,导致整个可见窗口重新组合,而不是被重复使用。
当物品到达时,你会立刻感觉到:出现卡顿,偶尔还会出现视觉故障,例如展开的卡片坍塌或头像闪现到错误的名字上。
✅ 更稳定------身份稳定,重复使用成本低:
kotlin
LazyColumn {
items(
items = messages,
key = { it.id },
contentType = { it.type } // see below
) { message ->
MessageRow(message)
}
}
现在 Compose 会移动项目而不是重新构建它们。以下两点在生产环境中至关重要:
键在整个列表中必须稳定且唯一。 不是索引(这是你默认转义的值),不是可变内容的哈希值,也不是 UUID.randomUUID()------我已经认真检查过这个方法了。如果你的 API 可能会在不同页面之间发送重复的 ID,那么请使用类似${page}_${id}这样的复合键,而不是寄希望于此。
contentType 是一个被低估的属性。在混合类型的信息流中(例如文本行、图片卡片、广告、日期分隔符),contentType 可以告诉 Compose 哪些项目共享相同的结构,从而可以复用它们之间的组合槽位。如果没有它,每次类型转换都需要重新构建。有了它,滚动异构信息流的性能会显著提升,而且只需要一个 lambda 函数。
2. 嵌套滚动和无界高度
这个错误会将"懒惰"变成"急迫",它通常是由试图修复布局问题的人引入的,而不是性能问题。
❌ 这份清单不再偷工减料了:
kotlin
Column(Modifier.verticalScroll(rememberScrollState())) {
ProfileHeader()
StatsRow()
LazyColumn { // infinite height available
items(posts, key = { it.id }) { PostCard(it) }
}
}
原因:有人需要标题能够随内容一起滚动。使用列 + 垂直滚动条是最直接的解决方法。但是,可滚动的列会给它的子元素设置无限高度限制 ------因此,LazyColumn 被告知"你可以随意设置高度",而一个拥有无限空间的惰性列表会立即生成_每一个条目_。两百篇文章在第一个页面加载完成之前就已经生成并测量完毕。(旧版本的 Compose 在这里会直接崩溃;现在它通常"能用",但这更糟糕------它只是默默地成为了应用中速度最慢的页面。)
✅ 一个列表,标题作为其中的一项:
kotlin
LazyColumn {
item(key = "header") { ProfileHeader() }
item(key = "stats") { StatsRow() }
items(posts, key = { it.id }, contentType = { "post" }) { PostCard(it) }
}
同样的规则也适用于 LazyColumn 项内的 LazyRow------这是合法的(约束在相反的轴上受到限制),但要给内部行一个能够保留的 rememberLazyListState(),否则当你滚动过去再返回时,每个水平轮播图都会静默地重置其滚动位置。
如果你确实需要在滚动父元素内使用固定高度的列表,请显式地限制其高度(Modifier.height(...) 或 heightIn(max = ...)),以确保惰性加载机制仍然有效。如果你经常需要这样做,通常的解决方案是使用一个包含更多项目类型的 LazyColumn,而不是嵌套两个滚动容器。
3. 可有可无但价格昂贵的项目
这一点推翻了你在第 3 部分学到的一切,这也是为什么在卡顿的页面上,重新合成的计数看起来仍然很完美的原因。
重新合成次数衡量的是重复工作。它无法告诉你一行第一次合成的成本------在快速切换过程中,随着新行进入视口,这种情况会发生数十次。
❌ 会导致性能分析器卡顿,其重组列将永远不会显示:
kotlin
@Composable
fun PostCard(post: PostUi) {
Card(Modifier.dropShadow(shape = cardShape, shadow = Shadow(radius = 24.dp))) {
Column {
Box(
Modifier
.background(Brush.linearGradient(post.gradientStops)) // built per item
.blur(20.dp) // expensive
) {
AuthorRow(post.author)
}
Text(buildAnnotatedString { // parsed per item
appendMarkdown(post.body)
})
ReactionBar(post.reactions)
CommentPreview(post.topComments) // 3 more rows inside
}
}
}
这些效果单独来看都无可厚非。但它们组合起来,从冷启动到完成一行渲染需要 15-25 毫秒------而你需要在用户手指移动的同时,在 8 毫秒内完成渲染。玻璃变形风格的特效值得特别一提:模糊、分层半透明和大半径阴影都是绘制阶段的开销,每帧都会重复,而不是一次性的渲染开销。在详情页面上看起来精美的设计,可能正是导致信息流无法达到 120Hz 刷新率的唯一原因。
✅ 先降低前期投入成本,其余部分以后再付:
kotlin
@Composable
fun PostCard(post: PostUi) {
Card(Modifier.dropShadow(shape = cardShape, shadow = postShadow)) { // hoisted constant
Column {
Box(Modifier.background(post.headerBrush)) { // built once in the mapper
AuthorRow(post.author)
}
Text(post.bodyAnnotated) // parsed in the ViewModel
ReactionBar(post.reactions)
if (post.hasComments) {
CommentPreview(post.topComments) // only when it exists
}
}
}
}
三个步骤,按收益顺序排列:
- 将工作完全移出合成层------带注释的字符串、格式化的日期、渐变定义和画笔对象应该放在 UI 模型映射器中,而不是行中。(这是第 3 部分中的错误 1,但列表中的乘数使问题变得更加复杂。)
- 提升常量------不依赖于项目数据的形状、阴影、文本样式和绘制器应该是文件级值或主题值,而不是每个项目单独构造的值。
- 扁平化树状结构 ------每个仅用于应用一个修饰符的嵌套
Box/Column都是一个合成节点加上一次测量过程,并且每次测量都会乘以每一行。Modifier链、ConstraintLayout或自定义Layout可以将三层结构合并为一层。
证明这一点的测量方法是:计算的是合成追踪,而不是重新合成。追踪一个甩动动作,找到可合成元素在帧中的_首次_出现时间------如果超过几毫秒,那就是卡顿,再多的稳定性工作也无济于事。
4. 在需要它的那帧中解码图像
如果你的列表显示图片并且显示卡顿,请从这里开始------我见过这一点导致的滚动卡顿,比其他五个问题加起来还要多,而且它在 Compose 工具中完全不可见。
❌ 该帧支付的是全分辨率位图的费用:
kotlin
AsyncImage(
model = post.imageUrl, // 3000x2000 JPEG from the CDN
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(200.dp)
)
这里没有任何过度重新构图。但在这个帧中,一个 600 万像素的 JPEG 图片正在被解码成位图------分配了大约 24MB 的内存,运行解码器,然后缩小到你实际需要的 400×200 像素。内存分配的频繁变化触发了垃圾回收;垃圾回收的暂停恰好发生在滑动中途;你会感觉到画面卡顿,但合成工具却无法将其归因于此。
✅ 解码为实际绘制的尺寸:
kotlin
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(post.imageUrl)
.size(width = 800, height = 400) // decode to display size, not source size
.crossfade(false) // crossfade animates → per-frame work during scroll
.memoryCacheKey(post.id)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
我对所有图片列表都遵循以下规则:
- 请求显示尺寸,而非源尺寸。 如果后端可以生成调整大小后的版本,请向其请求;使用提供 ?w=800 图像 CDN 比本系列中的任何 Compose 优化都能更有效地提升滚动性能。
- 禁用列表中的淡入淡出效果。 淡入淡出效果是在每个新出现的项目上逐帧添加动画,在快速滚动时根本看不出来。将其保留在详情页面,因为那里效果很好。
- 在加载图像之前为其指定固定大小,这样新出现的位图就不会触发整个可见窗口的重新布局。
- 检查内存缓存是否真正命中------启用图像加载器的日志记录,向下滚动再向上滚动。如果在向上滚动时缓存未命中,则意味着返回的每一行都需要重新解码。
5. 预取功能已针对比你的列表更轻的列表进行了调整
第一部分介绍了两个专门为此设计的搭配功能:可暂停合成 (自 2025 年 12 月版本起默认启用)允许运行时在帧预算耗尽时暂停正在进行的合成,并在下一帧恢复播放。LazyLayoutCacheWindow(目前仍为实验性可选功能)允许你声明视口前后要保留多少合成内容。
默认设置假定项目大小适中。如果你的项目是内容丰富的信息卡片,默认窗口会预取一个项目------但当用户以 4000 像素/秒的速度滑动时,预取一个项目是不够的。
kotlin
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Feed(posts: List<PostUi>) {
val state = rememberLazyListState(
cacheWindow = LazyLayoutCacheWindow(ahead = 400.dp, behind = 200.dp)
)
LazyColumn(state = state) {
items(posts, key = { it.id }, contentType = { "post" }) { PostCard(it) }
}
}
这是一种权衡,而非唾手可得:更多的预取意味着占用更多内存,以及更多屏幕外的渲染操作。要用数据而非感觉来调整------在性能最差的设备上,测量预取前后卡顿和内存占用情况,并预期最佳平衡点应该是"大约提前一个半屏幕",而不是"越多越好"。
这里有一个容易让团队犯错的正确性陷阱,值得在第一部分中再次提及,因为它在列表中尤其容易造成严重后果:预取项在可见之前就已经被组合在一起了。 因此,行中的 LaunchedEffect 并不是展示信号,而从组合触发的自动播放会播放用户根本看不到的视频。请使用 Modifier.onVisibilityChanged(从稳定版 1.11 开始,onFirstVisible 已被弃用),并自行跟踪首次播放状态。
6. 你正在测量调试版本
以上所有内容都假设你的测量结果真实可靠。但通常情况下,其中一半都不准确。
对于 Compose 来说,调试版本可能比发布版本慢好几倍 ------因为没有 R8,实时字面量检测功能未启用,而且至关重要的是没有基线性能分析,所以每个 Compose 运行时类在首次使用时都会被解释执行或 JIT 编译。而最后一点恰恰会触发代码的运行路径,就像冷启动滚动浏览代码一样。
在任何人声称某个列表运行缓慢或存在问题之前,我都会坚持采用以下测量方法:
- 发布版本,R8 及更高版本,签名方式与正式版相同。
- 已安装基线配置文件------如果你的应用没有自带基线配置文件,那么这是本文中最具影响力的修复方法。为你的主要滚动页面生成一个基线配置文件,通常可以带来两位数百分比的滚动卡顿改善,而且无需任何代码更改。
- 使用 scrollAndMeasure 风格的交互进行宏基准测试,并报告 FrameTimingMetric 指标。关注 P90 和 P99 帧的持续时间,而不是平均值------卡顿_体现_于帧尾。平均 9 毫秒,而 P99 帧却有 70 毫秒,这样的列表看起来非常不正常。
- 使用你支持最差的设备,而不是你的 Pixel 手机。我特意在办公桌上放了一部性能一般的手机,就是为了这个目的,它拒绝的"优化"比任何代码审查都多。
- 相同的数据集,相同的网络条件,两次运行。 针对不同数据的 A/B 对比只是徒增噪音和形式主义。
寻找你的答案:一线线索,六个答案
与其尝试所有六种修复方法,不如在真实设备上以发布模式捕获一次 fling 操作的系统跟踪,并启用合成跟踪,然后按以下顺序读取它:
- 只有当新项目出现在页面边缘时,帧才会变长吗? → 项目创建成本。错误 1、3、5。
- 打开页面后的第一帧是否非常大? → 高度无限制,整个列表一次性构建完成。错误 2。
- 你是否看到 编排器间隙与垃圾回收活动以及主线程上的图像解码工作有关? → 错误 4。
- 项目组合是否重复出现,且内容相同? → 键缺失或不稳定。错误 1。(使用"布局检查器"可以更快地确认这一点。)
- 所有内容(包括框架代码)看起来都很长吗? → 你使用的是调试版本。错误 6。请返回并正确测量。
- 帧正常,但半透明/模糊行的绘制时间很高吗? → 特效的绘制阶段成本。错误 3 的后半部分------或许应该从设计的角度来讨论,而不是从代码的角度来讨论。
维修清单
以下是我实际处理一个不规范列表的顺序:
- 确认测量结果------使用发布版本、基准配置文件、真机和 P99 帧时间。其他一切都只是猜测。
- 添加
key------如果数据源是异构的,则添加contentType。 - 移除嵌套滚动容器 ------使用一个
LazyColumn,并将标题作为 item {}。 - 审核图像管道------请求显示尺寸的图像,关闭淡入淡出效果,固定尺寸,并验证缓存命中情况。
- 追踪冷启动项的合成过程------将字符串构建、格式化和笔刷创建移至映射器;提升常量;扁平化冗余的布局节点。
- 合理分配特效预算------模糊和大半径阴影会增加每帧的绘制成本;将昂贵的特效处理留给不会快速滑动的页面。
- 然后调整预取------在性能最差的设备上,测量 LazyLayoutCacheWindow 的内存占用情况。
- 重新测量并保留基准测试结果------只有能够重新运行的宏基准测试结果,才能确保修复方案在接下来的六个迭代周期中有效运行。
最后想说的话
过去三篇文章中存在一个值得注意的模式。第二部分指出,你的应用运行缓慢并非因为你的应用本身,而是因为你的应用状态。第三部分指出,代码重组本身并非敌人,不必要的代码重组才是。而这篇文章则从另一个角度切入,得出了相同的结论:
滚动卡顿很少是滚动本身的问题,而是"我们做事的时机不对"的问题。
上述所有修复都将工作转移到无人等待的时刻------例如 ViewModel 映射器、预取窗口、文件级常量、服务器上调整大小的图像,或安装时编译的基准配置文件。列表速度的提升并非源于框架滚动性能的改进,而是因为在执行列表操作时,已经没有任何耗费资源的操作需要完成。
这就是整个流程的精髓所在,也是为什么"优化列表"通常是错误指令的原因。列表本身没问题,只是我们把账单安排在了最糟糕的时间。
这是"现代 Compose 性能优化"系列的第四部分。接下来是"Compose 性能优化------7 个真正重要的优化技巧",我们将根据实际收益对本系列中的所有内容进行排名,并剔除那些不值得投入到你的迭代周期中的优化技巧。
如果这篇文章出现在你的信息流页面上,点赞(最多 50 次)可以帮助它传到下一个即将把 LazyColumn 嵌套到滚动 Column 中的开发者那里,关注则意味着你接下来会看到排名优化列表。
现在请在评论区帮我解答一个争论:你遇到的最严重的列表卡顿问题是如何解决的?真的是靠 Compose 层面的修复,还是图片、网络,又或者某个没人愿意重新打开的设计决策?(我遇到的问题是一张漂亮的磨砂玻璃卡片,看起来像犯罪现场。我们保留了原设计,并将模糊效果移到了一个静态的预渲染图层------设计师根本没注意到,而且信息流的刷新率也保持在 120Hz。)
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!