本章目标:彻底放下 RecyclerView 的 Adapter/ViewHolder/LayoutManager 思维,建立 Lazy 系列的"声明式复用"心智模型------理解
key、contentType、LazyListState每一个参数背后解决的是什么真实问题,以及嵌套滚动、分页、动画项这些高频场景怎么落地。
为什么这一章要专门"戒掉"旧思维
在 View 里,长列表 = RecyclerView + Adapter + ViewHolder + LayoutManager + DiffUtil。你手动写"怎么创建视图、怎么绑定数据、怎么复用"。这套心智模型很强,但也很重------光是"类型多变的信息流"就能写出几百行 Adapter。
Compose 的 Lazy 系列把这一切折叠成几行 lambda :你只描述"每一项长什么样",框架负责复用、diff、滚动。但代价是:复用规则、状态保持、动画连续性这些事,从"你显式控制"变成了"框架按规则自动处理"------你得理解规则,否则会撞上"插入数据后 item 状态错乱""滚动位置丢失""同类 item 互相复用导致闪烁"这些诡异问题。
这一章的 6 个知识点是每天都会碰到的;4 个 是进阶场景(底层 API、动画、分页、嵌套滚动),用到再深入也不迟。
本章知识地图
3.1 verticalScroll vs Lazy:何时用谁
3.2 LazyColumn/Row 与 LazyListState
3.3 items / itemsIndexed / item 与状态持久化
3.4 key:为什么不能省
3.5 contentType:复用效率的关键
3.6 stickyHeader / contentPadding / reverseLayout / GridCells
3.7 LazyLayout 底层 API 概念
3.8 animateItem + PullToRefreshBox
3.9 Paging3 分页加载
3.10 nestedScroll 嵌套滚动 + 性能
3.1 verticalScroll vs Lazy:何时用谁
一句话 :
verticalScroll+ 普通Column适合少量元素 (全量创建、无复用);LazyColumn适合大量/不定长列表(只创建可见项、自动复用)。选错一边,要么卡、要么过度设计。
从一个真实的别扭说起
你想做一个"设置页面",有 8 个开关项,屏幕放不下需要滚动。你惯性地掏出 LazyColumn,写了 8 个 items------能用,但杀鸡用牛刀。反过来,你想展示"后端返回的 1000 条消息",随手用 Column(Modifier.verticalScroll(...)),结果一进页面就创建 1000 个组件,直接 OOM 风险。
它是什么
| 方案 | 创建策略 | 适用场景 |
|---|---|---|
Column(Modifier.verticalScroll(scrollState)) |
全量创建所有子项 | 元素少(< 几十)、内容静态 |
LazyColumn { items(...) } |
只创建可见项 + 复用 | 元素多、长度不定、来自网络分页 |
verticalScroll 本质是在普通布局外裹一层滚动容器,内部子项该创建几个还是几个。LazyColumn 则是"虚拟化"列表:它只测量和组合当前可见 + 缓冲 的少量项,滑走的项被丢弃(其 remember 状态也随之销毁),滑回来重新创建。
最小可运行示例
kotlin
// 场景 A:少量固定项,用 verticalScroll 更直观
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
repeat(8) { SwitchItem(it) }
}
// 场景 B:大量数据,必须用 LazyColumn
LazyColumn {
items(1000) { index ->
MessageItem(index)
}
}
运行时发生了什么
#mermaid-svg-DvAqbVWjNbTq7jo7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DvAqbVWjNbTq7jo7 .error-icon{fill:#552222;}#mermaid-svg-DvAqbVWjNbTq7jo7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DvAqbVWjNbTq7jo7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .marker.cross{stroke:#333333;}#mermaid-svg-DvAqbVWjNbTq7jo7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DvAqbVWjNbTq7jo7 p{margin:0;}#mermaid-svg-DvAqbVWjNbTq7jo7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .cluster-label text{fill:#333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .cluster-label span{color:#333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .cluster-label span p{background-color:transparent;}#mermaid-svg-DvAqbVWjNbTq7jo7 .label text,#mermaid-svg-DvAqbVWjNbTq7jo7 span{fill:#333;color:#333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .node rect,#mermaid-svg-DvAqbVWjNbTq7jo7 .node circle,#mermaid-svg-DvAqbVWjNbTq7jo7 .node ellipse,#mermaid-svg-DvAqbVWjNbTq7jo7 .node polygon,#mermaid-svg-DvAqbVWjNbTq7jo7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DvAqbVWjNbTq7jo7 .rough-node .label text,#mermaid-svg-DvAqbVWjNbTq7jo7 .node .label text,#mermaid-svg-DvAqbVWjNbTq7jo7 .image-shape .label,#mermaid-svg-DvAqbVWjNbTq7jo7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-DvAqbVWjNbTq7jo7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DvAqbVWjNbTq7jo7 .rough-node .label,#mermaid-svg-DvAqbVWjNbTq7jo7 .node .label,#mermaid-svg-DvAqbVWjNbTq7jo7 .image-shape .label,#mermaid-svg-DvAqbVWjNbTq7jo7 .icon-shape .label{text-align:center;}#mermaid-svg-DvAqbVWjNbTq7jo7 .node.clickable{cursor:pointer;}#mermaid-svg-DvAqbVWjNbTq7jo7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .arrowheadPath{fill:#333333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DvAqbVWjNbTq7jo7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DvAqbVWjNbTq7jo7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DvAqbVWjNbTq7jo7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DvAqbVWjNbTq7jo7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DvAqbVWjNbTq7jo7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DvAqbVWjNbTq7jo7 .cluster text{fill:#333;}#mermaid-svg-DvAqbVWjNbTq7jo7 .cluster span{color:#333;}#mermaid-svg-DvAqbVWjNbTq7jo7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DvAqbVWjNbTq7jo7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DvAqbVWjNbTq7jo7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-DvAqbVWjNbTq7jo7 .icon-shape,#mermaid-svg-DvAqbVWjNbTq7jo7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DvAqbVWjNbTq7jo7 .icon-shape p,#mermaid-svg-DvAqbVWjNbTq7jo7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DvAqbVWjNbTq7jo7 .icon-shape .label rect,#mermaid-svg-DvAqbVWjNbTq7jo7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DvAqbVWjNbTq7jo7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DvAqbVWjNbTq7jo7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DvAqbVWjNbTq7jo7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} verticalScroll + Column
一次性组合全部 1000 项
测量全部、占内存 1000 份
滚动只是平移已存在的树
LazyColumn
只组合可见区 ~15 项
滑走的项被丢弃
remember 状态销毁
滑回重新组合
状态重置(除非用 key+saveable)
进阶:滚动状态的类型差异
rememberScrollState() 用于 verticalScroll/horizontalScroll;rememberLazyListState() 用于 Lazy 系列,二者不互通。前者只能读"滚动像素偏移",后者能读"第一项索引、可见项范围"等语义信息(3.2 详述)。
真实案例:用 Column(verticalScroll) 渲染搜索历史导致首帧 800ms、内存翻倍
一个搜索页,历史记录最多存 200 条。开发者用 Column(Modifier.verticalScroll(rememberScrollState())) 渲染。功能正常,但测试机(中端机)上首帧耗时 800ms+,且进入页面内存涨了约 30MB。
排查:用 Layout Inspector 看组合树,200 个 HistoryRow 全部被组合、全部参与首次测量与布局------即使屏幕只显示 8 个。verticalScroll 不改变"子项全量创建"的事实,它只是把超出部分裁掉、提供平移。每个 Row 里还有图标、文字、时间戳三个子组件,200×3 = 600 个节点一次性建好。
修法:换成 LazyColumn { items(history, key = { it.id }) { HistoryRow(it) } }。首帧只组合约 12 个可见项,内存回落,首帧降到 60ms。结论:只要元素数量可能超过"一屏能显示的几倍",就该用 Lazy,哪怕"通常只有十几条"------因为数据是会涨的。
边界:horizontalScroll + LazyRow 的误用
想做"横向可滚动的标签栏",有人写 Row(Modifier.horizontalScroll(rememberScrollState())) 放 50 个标签。同样的全量创建问题。应用 LazyRow { items(tags, key = { it }) { TagChip(it) } }。注意 LazyRow 自带滚动,不要再包 horizontalScroll------两个横向滚动容器嵌套会冲突(见 3.10)。
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
ScrollView + LinearLayout |
verticalScroll + Column |
全量创建 |
RecyclerView |
LazyColumn/Row |
虚拟化复用 |
原理简析 🔬
Lazy 的"虚拟化"是性能的核心:一个 1000 项的列表,屏幕上永远只有十几个 item 存在。内存和首帧时间都从 O(n) 降到 O(可见数)。代价是 item 的状态不跨滑出保留(除非你主动持久化),这是 3.3/3.4 要处理的问题。
常见坑
- 用
Column(verticalScroll)渲染网络长列表------全量创建,OOM/卡顿。 - 在
LazyColumn里嵌套Column(verticalScroll)------两个滚动容器冲突,滚动行为错乱(嵌套滚动见 3.10)。
自检
一个页面有 5 个区块、每块 1-3 行静态文本,是否需要 LazyColumn?
不需要。verticalScroll + 普通 Column 更直观、代码更少,5-15 个元素全量创建毫无压力。Lazy 的收益在"项数多到全量创建会拖垮性能"时才显现。过早用 Lazy 反而增加 key/contentType 的维护成本。
3.2 LazyColumn/Row 与 LazyListState
一句话 :
LazyColumn/LazyRow(及它们的 Grid 变体)是虚拟化列表容器;LazyListState让你读滚动位置、程序化滚动、恢复滚动位置------它是"列表和外部世界通信"的桥梁。
从一个真实的别扭说起
View 里想"滚动到顶部按钮"要拿 RecyclerView 调 smoothScrollToPosition(0);想恢复位置要存 layoutManager.findFirstVisibleItemPosition()。Compose 里这些都收口到 LazyListState 一个对象上,而且它是可观察、可组合 的------你能在 Composable 里直接 if (listState.firstVisibleItemIndex > 0) ShowFab()。
它是什么
kotlin
val listState = rememberLazyListState()
LazyColumn(state = listState) {
items(100) { MessageItem(it) }
}
// 读取滚动语义信息(这些值是 State,变化会触发重组)
val firstIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
// 程序化滚动
Button(onClick = { scope.launch { listState.scrollToItem(0) } }) { Text("回到顶部") }
LazyListState 关键能力:
| 能力 | 成员 | 用途 |
|---|---|---|
| 读当前首项索引 | firstVisibleItemIndex |
判断是否显示"回顶"按钮 |
| 读首项是像素偏移 | firstVisibleItemScrollOffset |
精细判断 |
| 平滑滚动到项 | animateScrollToItem(index) |
用户体验好的跳转 |
| 瞬时滚动到项 | scrollToItem(index) |
恢复位置、无动画 |
| 布局信息 | layoutInfo |
可见项范围、总项数等 |
运行时发生了什么
#mermaid-svg-2OWHUxN1SKpaG9wK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2OWHUxN1SKpaG9wK .error-icon{fill:#552222;}#mermaid-svg-2OWHUxN1SKpaG9wK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2OWHUxN1SKpaG9wK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2OWHUxN1SKpaG9wK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2OWHUxN1SKpaG9wK .marker.cross{stroke:#333333;}#mermaid-svg-2OWHUxN1SKpaG9wK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2OWHUxN1SKpaG9wK p{margin:0;}#mermaid-svg-2OWHUxN1SKpaG9wK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2OWHUxN1SKpaG9wK .cluster-label text{fill:#333;}#mermaid-svg-2OWHUxN1SKpaG9wK .cluster-label span{color:#333;}#mermaid-svg-2OWHUxN1SKpaG9wK .cluster-label span p{background-color:transparent;}#mermaid-svg-2OWHUxN1SKpaG9wK .label text,#mermaid-svg-2OWHUxN1SKpaG9wK span{fill:#333;color:#333;}#mermaid-svg-2OWHUxN1SKpaG9wK .node rect,#mermaid-svg-2OWHUxN1SKpaG9wK .node circle,#mermaid-svg-2OWHUxN1SKpaG9wK .node ellipse,#mermaid-svg-2OWHUxN1SKpaG9wK .node polygon,#mermaid-svg-2OWHUxN1SKpaG9wK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2OWHUxN1SKpaG9wK .rough-node .label text,#mermaid-svg-2OWHUxN1SKpaG9wK .node .label text,#mermaid-svg-2OWHUxN1SKpaG9wK .image-shape .label,#mermaid-svg-2OWHUxN1SKpaG9wK .icon-shape .label{text-anchor:middle;}#mermaid-svg-2OWHUxN1SKpaG9wK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2OWHUxN1SKpaG9wK .rough-node .label,#mermaid-svg-2OWHUxN1SKpaG9wK .node .label,#mermaid-svg-2OWHUxN1SKpaG9wK .image-shape .label,#mermaid-svg-2OWHUxN1SKpaG9wK .icon-shape .label{text-align:center;}#mermaid-svg-2OWHUxN1SKpaG9wK .node.clickable{cursor:pointer;}#mermaid-svg-2OWHUxN1SKpaG9wK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2OWHUxN1SKpaG9wK .arrowheadPath{fill:#333333;}#mermaid-svg-2OWHUxN1SKpaG9wK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2OWHUxN1SKpaG9wK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2OWHUxN1SKpaG9wK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2OWHUxN1SKpaG9wK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2OWHUxN1SKpaG9wK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2OWHUxN1SKpaG9wK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2OWHUxN1SKpaG9wK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2OWHUxN1SKpaG9wK .cluster text{fill:#333;}#mermaid-svg-2OWHUxN1SKpaG9wK .cluster span{color:#333;}#mermaid-svg-2OWHUxN1SKpaG9wK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2OWHUxN1SKpaG9wK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2OWHUxN1SKpaG9wK rect.text{fill:none;stroke-width:0;}#mermaid-svg-2OWHUxN1SKpaG9wK .icon-shape,#mermaid-svg-2OWHUxN1SKpaG9wK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2OWHUxN1SKpaG9wK .icon-shape p,#mermaid-svg-2OWHUxN1SKpaG9wK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2OWHUxN1SKpaG9wK .icon-shape .label rect,#mermaid-svg-2OWHUxN1SKpaG9wK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2OWHUxN1SKpaG9wK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2OWHUxN1SKpaG9wK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2OWHUxN1SKpaG9wK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} LazyColumn(state=listState)
框架在内部维护
listState 的滚动元数据
firstVisibleItemIndex 变化
读它的 Composable 重组
(建议用 derivedStateOf 收窄)
scope.launch{ listState.animateScrollToItem(0) }
框架驱动滚动动画
逐步更新偏移
进阶:为什么读索引要包 derivedStateOf
firstVisibleItemIndex 是频繁变化的 State,直接 val x = listState.firstVisibleItemIndex 会在每一次 微小滚动都触发重组。用 derivedStateOf { ... > 0 } 把它降维成"布尔",只有真假切换才重组(见 4.4)。这是列表性能的基本功。
真实案例一:滚动位置在进程死亡后丢失,被投诉"体验倒退"
一个资讯 App,用户滚到第 80 条时切到后台,系统内存紧张把进程杀了。再回来时停在列表顶部------用户强烈不满,因为 View 时代用 onSaveInstanceState 存了 firstVisibleItemPosition,回来能恢复。
Compose 的 LazyListState 默认不持久化滚动位置。修法有两种:
kotlin
// 方案 A:用 rememberSaveable 保存首项索引 + 偏移,重建后恢复
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = savedIndex,
initialFirstVisibleItemScrollOffset = savedOffset
)
// savedIndex/savedOffset 来自 rememberSaveable { mutableStateOf(0) },并在 onDispose 或滚动停止时回写
但更优的是方案 B :滚动位置本质是数据加载进度的体现,若列表数据本身来自持久层(数据库/Paging),进程重建后数据重新加载、通过 Paging 的 cachedIn 或数据库游标自然恢复到对应位置,无需手动存索引。所以"滚动位置恢复"的正确解法往往是"数据恢复"而非"位置恢复"------这是声明式思维优于命令式存索引的地方。
真实案例二:两个 Tab 共用一个 listState 导致"串台"
一个页面有两个 Tab("关注""推荐"),开发者图省事只 rememberLazyListState() 一次,两个 LazyColumn(state = listState) 共用。结果切到"推荐"时显示的是"关注"的滚动位置,且在"关注"里滚一下,"推荐"也跟着动。
排查:rememberLazyListState() 在 Composable 体内只创建一次,两个列表引用同一 state 对象,滚动元数据互相覆盖。修法:每个列表独立 rememberLazyListState() ,或按 Tab 用 key/remember 隔离:
kotlin
val关注State = rememberLazyListState()
val 推荐State = rememberLazyListState() // 各自独立
推论:LazyListState 是"有状态的、绑定到具体一个列表"的对象,绝不在多个列表间共享。
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
recyclerView.layoutManager.scrollToPosition(0) |
listState.scrollToItem(0) |
一致 |
findFirstVisibleItemPosition() |
listState.firstVisibleItemIndex |
可观察 |
原理简析 🔬
LazyListState 内部维护一个 ScrollableState 和布局信息快照。它和重组系统打通:滚动产生的偏移变化通过 State 通知订阅者,所以你能"响应式"地读滚动位置,而不用像 View 那样手动注册 OnScrollListener。
常见坑
- 每次滚动都重组整个列表------因为直接读了
firstVisibleItemIndex没包derivedStateOf。 - 多个独立列表共用同一个
listState------状态串台,每个列表应有自己的 state。 - 在
LazyColumn里用verticalScroll的ScrollState------类型不匹配,应用rememberLazyListState()。
自检
"滚动到顶部显示 FAB"为什么必须用 derivedStateOf 包 firstVisibleItemIndex,而不是直接 if (listState.firstVisibleItemIndex > 0)?
firstVisibleItemIndex 在用户滚动时频繁变化(每滑过一个 item 就变),直接读会让包含该判断的 Composable 在每一次变化时重组------即便 FAB 的显隐状态(布尔)根本没变。用 derivedStateOf { firstVisibleItemIndex > 0 } 后,只有这个布尔值在 true/false 间切换时才重组,中间那些"索引从 3 变 4 变 5 但都 >0"的变化被过滤掉,列表滚动更顺。
3.3 items / itemsIndexed / item 与状态持久化
一句话 :Lazy 用
items(count){ }/itemsIndexed { }/item { }三个 DSL 描述内容;item 内部的remember状态随滑出销毁 ,要跨配置变更/进程死亡保留得用rememberSaveable,要跨滑出保留得把状态提升到列表外部或配合key。
从一个真实的别扭说起
你在 items 的 lambda 里写了 var expanded by remember { mutableStateOf(false) } 做一个"展开/收起"的 item。滑走再滑回来,展开状态没了------因为 item 被销毁重建,remember 跟着 Slot Table 一起清空。这是 Lazy 和"普通 Composable 的 remember 永久有效"最大的认知差异。
它是什么
kotlin
LazyColumn {
// 单个独立块(如 header/footer/loading)
item { SectionHeader("推荐") }
// 按 count 展开:index 从 0 开始
items(50) { index ->
MessageItem(index)
}
// 需要 index + item 数据时
itemsIndexed(dataList) { index, item ->
RowItem(index = index, data = item)
}
// 底部加载更多
item { if (isLoading) LoadingFooter() }
}
三个 DSL 的区别:
| DSL | 适用 | 是否拿到 index |
|---|---|---|
item { } |
单个块(header/footer) | 否 |
items(count) { } |
纯数量列表 | 是(默认参数 it) |
itemsIndexed(list) { i, item } |
有数据的列表 | 是(index + 元素) |
item 内 remember 的生命周期
#mermaid-svg-LAyzvER1HfK169Tn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LAyzvER1HfK169Tn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LAyzvER1HfK169Tn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LAyzvER1HfK169Tn .error-icon{fill:#552222;}#mermaid-svg-LAyzvER1HfK169Tn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LAyzvER1HfK169Tn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LAyzvER1HfK169Tn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LAyzvER1HfK169Tn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LAyzvER1HfK169Tn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LAyzvER1HfK169Tn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LAyzvER1HfK169Tn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LAyzvER1HfK169Tn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LAyzvER1HfK169Tn .marker.cross{stroke:#333333;}#mermaid-svg-LAyzvER1HfK169Tn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LAyzvER1HfK169Tn p{margin:0;}#mermaid-svg-LAyzvER1HfK169Tn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LAyzvER1HfK169Tn .cluster-label text{fill:#333;}#mermaid-svg-LAyzvER1HfK169Tn .cluster-label span{color:#333;}#mermaid-svg-LAyzvER1HfK169Tn .cluster-label span p{background-color:transparent;}#mermaid-svg-LAyzvER1HfK169Tn .label text,#mermaid-svg-LAyzvER1HfK169Tn span{fill:#333;color:#333;}#mermaid-svg-LAyzvER1HfK169Tn .node rect,#mermaid-svg-LAyzvER1HfK169Tn .node circle,#mermaid-svg-LAyzvER1HfK169Tn .node ellipse,#mermaid-svg-LAyzvER1HfK169Tn .node polygon,#mermaid-svg-LAyzvER1HfK169Tn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LAyzvER1HfK169Tn .rough-node .label text,#mermaid-svg-LAyzvER1HfK169Tn .node .label text,#mermaid-svg-LAyzvER1HfK169Tn .image-shape .label,#mermaid-svg-LAyzvER1HfK169Tn .icon-shape .label{text-anchor:middle;}#mermaid-svg-LAyzvER1HfK169Tn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LAyzvER1HfK169Tn .rough-node .label,#mermaid-svg-LAyzvER1HfK169Tn .node .label,#mermaid-svg-LAyzvER1HfK169Tn .image-shape .label,#mermaid-svg-LAyzvER1HfK169Tn .icon-shape .label{text-align:center;}#mermaid-svg-LAyzvER1HfK169Tn .node.clickable{cursor:pointer;}#mermaid-svg-LAyzvER1HfK169Tn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LAyzvER1HfK169Tn .arrowheadPath{fill:#333333;}#mermaid-svg-LAyzvER1HfK169Tn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LAyzvER1HfK169Tn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LAyzvER1HfK169Tn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LAyzvER1HfK169Tn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LAyzvER1HfK169Tn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LAyzvER1HfK169Tn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LAyzvER1HfK169Tn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LAyzvER1HfK169Tn .cluster text{fill:#333;}#mermaid-svg-LAyzvER1HfK169Tn .cluster span{color:#333;}#mermaid-svg-LAyzvER1HfK169Tn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LAyzvER1HfK169Tn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LAyzvER1HfK169Tn rect.text{fill:none;stroke-width:0;}#mermaid-svg-LAyzvER1HfK169Tn .icon-shape,#mermaid-svg-LAyzvER1HfK169Tn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LAyzvER1HfK169Tn .icon-shape p,#mermaid-svg-LAyzvER1HfK169Tn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LAyzvER1HfK169Tn .icon-shape .label rect,#mermaid-svg-LAyzvER1HfK169Tn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LAyzvER1HfK169Tn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LAyzvER1HfK169Tn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LAyzvER1HfK169Tn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 避免重置
item 进入可见区
组合执行
remember 初始化
用户交互改状态
正常响应
item 滑出可见区
组合被丢弃
remember 状态销毁
滑回可见区
重新组合,状态重置
解法:状态提升
或 rememberSaveable
或 key + 外部存储
进阶:状态该放哪
- 只和 item 自身相关、滑走可丢 (如 item 内临时高亮):放 item 内
remember,无所谓。 - 需跨配置变更(旋转)保留 (如输入框文字):
rememberSaveable。 - 需跨滑出保留 (如展开状态):把状态提升到列表级 或外部 ViewModel,用
key关联;或用rememberSaveable+key(3.4)。
真实案例:item 内 LaunchedEffect(Unit) 发请求,快速滑动触发 20 次网络调用
一个商品列表,每个 item 要在进入可见区时上报曝光(exposure 埋点)。开发者在 item 内写:
kotlin
items(products, key = { it.id }) { product ->
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { analytics.logExposure(product.id) } // ❌
ProductCard(product)
}
表现:快速滑动一屏,曝光接口被调用了 20+ 次(每个滑过的 item 各调一次),且滑回已看过的 item 又重复上报。问题根源:
LaunchedEffect(Unit)在 item 每次组合时启动------滑入组合一次、滑出丢弃、滑回再组合又一次,所以重复。- 曝光埋点本该是"业务事件"而非"UI 生命周期副作用",放在 UI 组合里被虚拟化的创建/销毁反复触发。
修法:曝光上报应在数据进入可见区的语义事件 里做,而非 item 组合副作用。可用 LazyListState.layoutInfo 在列表级监听"新进入可见区的 item",或直接在 ViewModel 加载数据时附带曝光逻辑(数据驱动)。把"UI 副作用"改为"数据/列表级事件",既能去重又能避免虚拟化干扰。
边界:rememberSaveable 在 item 内真的能跨滑出吗?
很多资料说"用 rememberSaveable 跨配置变更"。但 Lazy 的 item 滑出时,整个 Composable 被丢弃,rememberSaveable 的状态也随之从组合树移除 (它存在 Slot Table / Saveable 体系里,但 item 实例都销毁了,Saveable 的 key 空间也没了)。所以 rememberSaveable 只能跨配置变更 (旋转),不能 跨滑出再滑回 。要跨滑出,必须提升状态到列表级/ViewModel(见 3.3 进阶与 3.4)。这是极易混淆的一点,记住:rememberSaveable 救不了"滑出",只能救"旋转"。
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
Adapter.getItemViewType + 多 ViewHolder |
item {} + items {} 组合 |
更直观 |
| ViewHolder 持有临时状态 | item 内 remember |
但滑走即丢 |
原理简析 🔬
Lazy 的"虚拟化"意味着 item 的组合函数会被反复调用和丢弃 。Slot Table 里为这个 item 分配的槽位随组合丢弃而释放,remember 借以存活的内存也随之消失。这不是 bug,是"只保留可见内容"的必然代价------理解它,才能正确决定"什么状态该提升出去"。
常见坑
- 在
items的 lambda 里LaunchedEffect(Unit) { 发起网络请求 }------每次滑回来都重放请求(Effect 随组合重建),应在列表级或 ViewModel 发起。 - 把"应该持久"的状态放在 item 内
remember,滑走丢失后以为是 Compose 的 bug。
自检
一个可展开的 item,展开状态用 item 内 remember 存储。如何让它滑出再滑回后仍保持展开?
三种路:(1) 把 expanded 状态提升到列表级(如 mutableStateMapOf<Int, Boolean> 用 item 的 id 作 key);(2) 用 rememberSaveable(能跨配置变更,但默认仍随 item 销毁而丢,需配合非 Lazy 的持久或 key 机制);(3) 状态本身就在数据模型里(最佳实践------展开态应是数据的一部分,由 ViewModel 持有)。最干净是 (3):UI 状态即数据。
3.4 key:为什么不能省
一句话 :
key告诉 Lazy "这一项是谁"。没有 key,Lazy 用位置 识别 item,增删/重排后会导致状态错乱、动画断裂、复用错误;有 key,Lazy 用身份识别,状态跟随数据而非位置。
从一个真实的别扭说起
你有一个待办列表,用户在头部插入一条新待办。没设 key 时,原来第 3 条"已展开"的状态,会错配到新插入后位置变成第 4 条的那条 上------因为 Lazy 按位置复用组合槽位,第 3 个槽位还是那个槽位,只是里面装的数据变了。设了 key = { it.id },Lazy 知道"这条数据还是原来那条",状态正确跟随。
它是什么
kotlin
LazyColumn {
items(
items = todos,
key = { todo -> todo.id } // 用稳定唯一标识
) { todo ->
TodoRow(todo)
}
}
key 的类型应是稳定的、能唯一标识数据身份的值(通常是数据模型的 id)。它解决的三个问题:
#mermaid-svg-WxgT0U4ZfkyQbbjd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WxgT0U4ZfkyQbbjd .error-icon{fill:#552222;}#mermaid-svg-WxgT0U4ZfkyQbbjd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WxgT0U4ZfkyQbbjd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .marker.cross{stroke:#333333;}#mermaid-svg-WxgT0U4ZfkyQbbjd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WxgT0U4ZfkyQbbjd p{margin:0;}#mermaid-svg-WxgT0U4ZfkyQbbjd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .cluster-label text{fill:#333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .cluster-label span{color:#333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .cluster-label span p{background-color:transparent;}#mermaid-svg-WxgT0U4ZfkyQbbjd .label text,#mermaid-svg-WxgT0U4ZfkyQbbjd span{fill:#333;color:#333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .node rect,#mermaid-svg-WxgT0U4ZfkyQbbjd .node circle,#mermaid-svg-WxgT0U4ZfkyQbbjd .node ellipse,#mermaid-svg-WxgT0U4ZfkyQbbjd .node polygon,#mermaid-svg-WxgT0U4ZfkyQbbjd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WxgT0U4ZfkyQbbjd .rough-node .label text,#mermaid-svg-WxgT0U4ZfkyQbbjd .node .label text,#mermaid-svg-WxgT0U4ZfkyQbbjd .image-shape .label,#mermaid-svg-WxgT0U4ZfkyQbbjd .icon-shape .label{text-anchor:middle;}#mermaid-svg-WxgT0U4ZfkyQbbjd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WxgT0U4ZfkyQbbjd .rough-node .label,#mermaid-svg-WxgT0U4ZfkyQbbjd .node .label,#mermaid-svg-WxgT0U4ZfkyQbbjd .image-shape .label,#mermaid-svg-WxgT0U4ZfkyQbbjd .icon-shape .label{text-align:center;}#mermaid-svg-WxgT0U4ZfkyQbbjd .node.clickable{cursor:pointer;}#mermaid-svg-WxgT0U4ZfkyQbbjd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .arrowheadPath{fill:#333333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WxgT0U4ZfkyQbbjd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WxgT0U4ZfkyQbbjd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WxgT0U4ZfkyQbbjd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WxgT0U4ZfkyQbbjd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WxgT0U4ZfkyQbbjd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WxgT0U4ZfkyQbbjd .cluster text{fill:#333;}#mermaid-svg-WxgT0U4ZfkyQbbjd .cluster span{color:#333;}#mermaid-svg-WxgT0U4ZfkyQbbjd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WxgT0U4ZfkyQbbjd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WxgT0U4ZfkyQbbjd rect.text{fill:none;stroke-width:0;}#mermaid-svg-WxgT0U4ZfkyQbbjd .icon-shape,#mermaid-svg-WxgT0U4ZfkyQbbjd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WxgT0U4ZfkyQbbjd .icon-shape p,#mermaid-svg-WxgT0U4ZfkyQbbjd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WxgT0U4ZfkyQbbjd .icon-shape .label rect,#mermaid-svg-WxgT0U4ZfkyQbbjd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WxgT0U4ZfkyQbbjd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WxgT0U4ZfkyQbbjd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WxgT0U4ZfkyQbbjd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 没有 key
按位置识别项
顶部插入 → 状态错位到错误数据
重排 → 无动画过渡(位置突变)
同类项互相复用 → 闪烁/错状态
有 key = {it.id}
按身份识别项
插入 → 状态跟随正确数据
重排 → 项平滑移动到新位置
不同类型按 contentType 复用
进阶:key 与动画、与 contentType 的协同
- 动画连续性 (3.8):
animateItem()依赖 key 才能识别"哪个项从哪移到哪",实现平滑位移。 - 复用正确性 :key 配合
contentType(3.5)让"相同类型的项"才互相复用,避免把 A 类型的组合错误地复用到 B 类型上。
实战排错:key 失效的三种情形
光知道"要设 key"不够,key 设错了等于没设。三种最隐蔽的失效,按出现频率排序:
情形一:key 用了会变的字段(最常见)。 比如用列表下标 it.position 当 key------插入一条后所有 key 都变了,等于全没 key,状态整体错位。
情形二:key 闭包捕获了会变的外部状态。
kotlin
// 错误:key 里拼了 selectedTab,切 tab 时所有 key 都变 → 整个列表全量重组、丢失滚动位置
items(list, key = { it.id + selectedTab }) { item -> Row { /* ... */ } }
// 正确:key 只反映"项自身的稳定身份"
items(list, key = { it.id }) { item -> /* 需要 tab 时读 state 即可 */ }
情形三:乐观更新的 id 中途变化(真实线上 bug)。 聊天室里本地先发消息、用负数临时 id,服务端确认后换成正式正数 id。如果 key = { it.id },确认那一刻 key 从 -1 变成 123,Lazy 会判定"旧消息消失、新消息出现"------这条消息先闪退再淡入,体验崩坏:
kotlin
// 出错代码
items(messages, key = { it.id }) { msg -> MessageBubble(msg) }
// 修复:用全程不变的"逻辑身份" clientId 当 key,只让 id 变、身份不变
items(messages, key = { it.clientId }) { msg -> MessageBubble(msg) }
判定法则 :一个合格的 key 必须同时满足三条------(a) 在项的生命周期内不变 ;(b) 能唯一 区分不同项;© 是稳定类型(String / Long / Int,而非每次重组新建的对象)。拿不准时先问自己:"这个值会不会在我不希望它变的时候变?"
#mermaid-svg-l1oz2he9CN2tcMXm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-l1oz2he9CN2tcMXm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l1oz2he9CN2tcMXm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l1oz2he9CN2tcMXm .error-icon{fill:#552222;}#mermaid-svg-l1oz2he9CN2tcMXm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l1oz2he9CN2tcMXm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l1oz2he9CN2tcMXm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l1oz2he9CN2tcMXm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l1oz2he9CN2tcMXm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l1oz2he9CN2tcMXm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l1oz2he9CN2tcMXm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l1oz2he9CN2tcMXm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l1oz2he9CN2tcMXm .marker.cross{stroke:#333333;}#mermaid-svg-l1oz2he9CN2tcMXm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l1oz2he9CN2tcMXm p{margin:0;}#mermaid-svg-l1oz2he9CN2tcMXm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-l1oz2he9CN2tcMXm .cluster-label text{fill:#333;}#mermaid-svg-l1oz2he9CN2tcMXm .cluster-label span{color:#333;}#mermaid-svg-l1oz2he9CN2tcMXm .cluster-label span p{background-color:transparent;}#mermaid-svg-l1oz2he9CN2tcMXm .label text,#mermaid-svg-l1oz2he9CN2tcMXm span{fill:#333;color:#333;}#mermaid-svg-l1oz2he9CN2tcMXm .node rect,#mermaid-svg-l1oz2he9CN2tcMXm .node circle,#mermaid-svg-l1oz2he9CN2tcMXm .node ellipse,#mermaid-svg-l1oz2he9CN2tcMXm .node polygon,#mermaid-svg-l1oz2he9CN2tcMXm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-l1oz2he9CN2tcMXm .rough-node .label text,#mermaid-svg-l1oz2he9CN2tcMXm .node .label text,#mermaid-svg-l1oz2he9CN2tcMXm .image-shape .label,#mermaid-svg-l1oz2he9CN2tcMXm .icon-shape .label{text-anchor:middle;}#mermaid-svg-l1oz2he9CN2tcMXm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-l1oz2he9CN2tcMXm .rough-node .label,#mermaid-svg-l1oz2he9CN2tcMXm .node .label,#mermaid-svg-l1oz2he9CN2tcMXm .image-shape .label,#mermaid-svg-l1oz2he9CN2tcMXm .icon-shape .label{text-align:center;}#mermaid-svg-l1oz2he9CN2tcMXm .node.clickable{cursor:pointer;}#mermaid-svg-l1oz2he9CN2tcMXm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-l1oz2he9CN2tcMXm .arrowheadPath{fill:#333333;}#mermaid-svg-l1oz2he9CN2tcMXm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-l1oz2he9CN2tcMXm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-l1oz2he9CN2tcMXm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l1oz2he9CN2tcMXm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-l1oz2he9CN2tcMXm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l1oz2he9CN2tcMXm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-l1oz2he9CN2tcMXm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-l1oz2he9CN2tcMXm .cluster text{fill:#333;}#mermaid-svg-l1oz2he9CN2tcMXm .cluster span{color:#333;}#mermaid-svg-l1oz2he9CN2tcMXm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-l1oz2he9CN2tcMXm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-l1oz2he9CN2tcMXm rect.text{fill:none;stroke-width:0;}#mermaid-svg-l1oz2he9CN2tcMXm .icon-shape,#mermaid-svg-l1oz2he9CN2tcMXm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l1oz2he9CN2tcMXm .icon-shape p,#mermaid-svg-l1oz2he9CN2tcMXm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-l1oz2he9CN2tcMXm .icon-shape .label rect,#mermaid-svg-l1oz2he9CN2tcMXm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l1oz2he9CN2tcMXm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-l1oz2he9CN2tcMXm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-l1oz2he9CN2tcMXm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 合格 key 的三条 ✅
生命周期内不变
唯一区分不同项
稳定类型
String/Long/Int
key 失效的三种情形 ❌
key 用会变的字段
如 index / position
插入后全部错位
key 闭包捕获外部状态
如 it.id + selectedTab
切状态 → 全列表重组
乐观 id 中途变化
-1 变 123
消息闪退再淡入
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
DiffUtil 的 areItemsTheSame |
key = { it.id } |
身份标识 |
notifyItemInserted 动画 |
animateItem + key |
自动 |
原理简析 🔬
Lazy 内部维护"位置 → 组合槽位"的映射。无 key 时,映射基于索引 ;有 key 时,基于key 值。当数据列表变化(增删/重排),基于索引的映射会把"旧位置的状态"带到"新位置的数据"上(错位);基于 key 的映射则能通过 key 匹配,把"旧数据的状态"正确迁移到"新位置上的同一数据"。
常见坑
- key 用了会变的字段(如列表位置
index)------等于没设 key。 - key 不唯一(两条数据 id 相同)------复用错乱,且 Compose 会报错。
- 用
Object引用当 key(每次重组是新对象)------身份每次都变,复用失效。用稳定值(String/Long/id)。
自检
为什么 key 不能用 item 在列表中的 index?
因为 index 是"位置"而非"身份"。当列表在头部插入新项,原来 index=3 的数据变成了 index=4,但它的身份没变。如果用 index 当 key,Lazy 会认为"index=3 这个槽位的数据换了",把旧状态(原 index=3 的展开态)错误地带到新数据上。用稳定 id 当 key,Lazy 通过 id 匹配到"还是原来那条数据",状态正确跟随。一句话:key 必须回答"你是谁",而不是"你在哪"。
3.5 contentType:复用效率的关键
一句话 :
contentType给每一项标注"类型标签",Lazy 据此只在同类型项之间复用组合槽位。多类型信息流(图文/视频/广告混排)不设 contentType,会导致类型错配复用、重组浪费、甚至布局闪烁。
从一个真实的别扭说起
你的信息流有 4 种卡片:Banner、图文、视频、广告。没设 contentType 时,Lazy 的复用池是"一锅粥"------一个刚滚走的视频卡片槽位,可能被拿来放一个图文卡片,框架得把整个视频布局拆掉重建成图文布局,白白浪费。设了 contentType = { it.type },Lazy 保证"视频槽位只复用到视频项",复用零损耗。
它是什么
kotlin
LazyColumn {
items(
items = feed,
key = { it.id },
contentType = { it.type } // "banner" / "image_text" / "video" / "ad"
) { item ->
when (item.type) {
"banner" -> BannerCard(item)
"video" -> VideoCard(item)
else -> TextImageCard(item)
}
}
}
contentType 返回的值只要能区分"布局结构是否兼容复用"即可(字符串/枚举/类)。结构相同才能复用------所以"都是文字卡片、只是数据不同"的应归为同一 contentType。
运行时发生了什么
#mermaid-svg-BkCJGOaPLpLAkftk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BkCJGOaPLpLAkftk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BkCJGOaPLpLAkftk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BkCJGOaPLpLAkftk .error-icon{fill:#552222;}#mermaid-svg-BkCJGOaPLpLAkftk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BkCJGOaPLpLAkftk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BkCJGOaPLpLAkftk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BkCJGOaPLpLAkftk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BkCJGOaPLpLAkftk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BkCJGOaPLpLAkftk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BkCJGOaPLpLAkftk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BkCJGOaPLpLAkftk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BkCJGOaPLpLAkftk .marker.cross{stroke:#333333;}#mermaid-svg-BkCJGOaPLpLAkftk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BkCJGOaPLpLAkftk p{margin:0;}#mermaid-svg-BkCJGOaPLpLAkftk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BkCJGOaPLpLAkftk .cluster-label text{fill:#333;}#mermaid-svg-BkCJGOaPLpLAkftk .cluster-label span{color:#333;}#mermaid-svg-BkCJGOaPLpLAkftk .cluster-label span p{background-color:transparent;}#mermaid-svg-BkCJGOaPLpLAkftk .label text,#mermaid-svg-BkCJGOaPLpLAkftk span{fill:#333;color:#333;}#mermaid-svg-BkCJGOaPLpLAkftk .node rect,#mermaid-svg-BkCJGOaPLpLAkftk .node circle,#mermaid-svg-BkCJGOaPLpLAkftk .node ellipse,#mermaid-svg-BkCJGOaPLpLAkftk .node polygon,#mermaid-svg-BkCJGOaPLpLAkftk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BkCJGOaPLpLAkftk .rough-node .label text,#mermaid-svg-BkCJGOaPLpLAkftk .node .label text,#mermaid-svg-BkCJGOaPLpLAkftk .image-shape .label,#mermaid-svg-BkCJGOaPLpLAkftk .icon-shape .label{text-anchor:middle;}#mermaid-svg-BkCJGOaPLpLAkftk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BkCJGOaPLpLAkftk .rough-node .label,#mermaid-svg-BkCJGOaPLpLAkftk .node .label,#mermaid-svg-BkCJGOaPLpLAkftk .image-shape .label,#mermaid-svg-BkCJGOaPLpLAkftk .icon-shape .label{text-align:center;}#mermaid-svg-BkCJGOaPLpLAkftk .node.clickable{cursor:pointer;}#mermaid-svg-BkCJGOaPLpLAkftk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BkCJGOaPLpLAkftk .arrowheadPath{fill:#333333;}#mermaid-svg-BkCJGOaPLpLAkftk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BkCJGOaPLpLAkftk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BkCJGOaPLpLAkftk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BkCJGOaPLpLAkftk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BkCJGOaPLpLAkftk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BkCJGOaPLpLAkftk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BkCJGOaPLpLAkftk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BkCJGOaPLpLAkftk .cluster text{fill:#333;}#mermaid-svg-BkCJGOaPLpLAkftk .cluster span{color:#333;}#mermaid-svg-BkCJGOaPLpLAkftk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BkCJGOaPLpLAkftk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BkCJGOaPLpLAkftk rect.text{fill:none;stroke-width:0;}#mermaid-svg-BkCJGOaPLpLAkftk .icon-shape,#mermaid-svg-BkCJGOaPLpLAkftk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BkCJGOaPLpLAkftk .icon-shape p,#mermaid-svg-BkCJGOaPLpLAkftk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BkCJGOaPLpLAkftk .icon-shape .label rect,#mermaid-svg-BkCJGOaPLpLAkftk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BkCJGOaPLpLAkftk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BkCJGOaPLpLAkftk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BkCJGOaPLpLAkftk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无
有
项滑出,槽位回收
有 contentType?
所有项混在一个池
复用需重建布局
按 contentType 分池
video 槽位 → 只给 video 项
滑入 video 项
直接复用,零重建
进阶:contentType 与 key 的配合
key管"状态跟随哪条数据"(身份)。contentType管"哪个槽位能被复用给哪种结构"(布局兼容)。
两者正交:一个视频项(key=video123)滑走,它的槽位(contentType="video")只会被下一个视频项(key=video456)复用,状态各自独立、布局零重建。
真实案例:contentType 用"每条数据一个类型"导致滑动仍掉帧
一个社交信息流,开发者为了"精细区分",写成:
kotlin
items(
feed,
key = { it.id },
contentType = { "${it.type}_${it.id}" } // ❌ 每条数据一个 contentType
) { ... }
本意是"每条都独立",结果滑动时依然频繁重建布局、掉帧。原因:contentType 的粒度决定了"复用池有多少种"。他把池子碎成了 N 种(每条一种),每个池只有 1 个槽位------滑走的项回不来时就只能新建,且相邻不同类型永远无法互相复用。contentType 的契约是"结构相同才能复用",而他的结构其实只有 4 种(banner/图文本/视频/广告),id 不同不改变结构。
修法:contentType = { it.type },池子回到 4 种。验证方法:用 Layout Inspector 看滚动时 Recomposition 计数,或开 Debug 模式的重组高亮,对比修改前后"同一类型 item 滑出再滑入"是否不再触发布局重建。
进阶:contentType 返回什么类型都行,但必须稳定
contentType 的返回值只要能 equals 比较、能区分结构即可------String、Int、枚举、KClass 都行。关键是相同结构的项必须返回相同值 。推荐直接用"结构种类"本身(如枚举 CardType.VIDEO),语义最清晰,也避免上面那种"拼字符串拼出无限种"的低级错误。
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
Adapter.getItemViewType() |
contentType = { } |
语义更直接 |
原理简析 🔬
RecyclerView 的 getItemViewType 本质就是 contentType:它决定"从哪个复用池取 ViewHolder"。Compose 把这套机制用 lambda 暴露出来,且和重组系统对齐------contentType 不同意味着组合树结构不同,框架必须重建而非复用。
常见坑
- 多类型列表不设 contentType------所有类型共池,滑动时频繁重建布局,掉帧。
- contentType 划分过细(每个 item 一种类型)------池子碎片化,复用率反而下降。按"布局结构"而非"数据种类"划分。
自检
"图文卡片"和"广告卡片"布局完全一样只是文案不同,contentType 该设成两种还是一种?
设成一种。contentType 决定的是"布局结构能否复用",两者结构相同,复用零成本;强行分成两种只会让复用池碎片化、降低复用率。正确划分粒度是"结构是否兼容",而非"业务语义是否相同"。
3.6 stickyHeader / contentPadding / reverseLayout / GridCells
一句话 :Lazy 提供一组"布局修饰"参数与变体:
stickyHeader让分组标题吸顶、contentPadding给列表首尾留白、reverseLayout反转滚动方向(聊天场景)、LazyVerticalGrid的GridCells控制列数自适应。
从一个真实的别扭说起
做一个通讯录,按字母分组,希望滚动时当前组的标题"钉"在顶部------View 里得用 ItemDecoration 或自定义 RecyclerView.OnScrollListener 手搓吸顶。Compose 里一行 stickyHeader 搞定。同理,聊天界面要从底部往上滚、网格要"每列至少 120dp 自适应列数",都是 Lazy 的内置能力。
它是什么
stickyHeader(需 lazyList 的 sticky 支持,Compose Foundation 提供)
kotlin
LazyColumn {
groupedData.forEach { (letter, items) ->
stickyHeader { SectionTitle(letter) } // 吸顶,直到下一组顶上来
items(items, key = { it.id }) { ContactRow(it) }
}
}
contentPadding / reverseLayout
kotlin
LazyColumn(
contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp),
reverseLayout = true // 第一项在底部,向上滚(聊天)
) { items(messages, key={it.id}) { MessageBubble(it) } }
LazyVerticalGrid 的列
kotlin
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp), // 每列至少 120dp,自动算列数
// 或 GridCells.Fixed(3) 固定 3 列
contentPadding = PaddingValues(8.dp)
) {
items(photos, key = { it.id }) { PhotoCell(it) }
}
运行时发生了什么
#mermaid-svg-r5aVxX023fZxgNrT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r5aVxX023fZxgNrT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r5aVxX023fZxgNrT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r5aVxX023fZxgNrT .error-icon{fill:#552222;}#mermaid-svg-r5aVxX023fZxgNrT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r5aVxX023fZxgNrT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r5aVxX023fZxgNrT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r5aVxX023fZxgNrT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r5aVxX023fZxgNrT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r5aVxX023fZxgNrT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r5aVxX023fZxgNrT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r5aVxX023fZxgNrT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r5aVxX023fZxgNrT .marker.cross{stroke:#333333;}#mermaid-svg-r5aVxX023fZxgNrT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r5aVxX023fZxgNrT p{margin:0;}#mermaid-svg-r5aVxX023fZxgNrT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-r5aVxX023fZxgNrT .cluster-label text{fill:#333;}#mermaid-svg-r5aVxX023fZxgNrT .cluster-label span{color:#333;}#mermaid-svg-r5aVxX023fZxgNrT .cluster-label span p{background-color:transparent;}#mermaid-svg-r5aVxX023fZxgNrT .label text,#mermaid-svg-r5aVxX023fZxgNrT span{fill:#333;color:#333;}#mermaid-svg-r5aVxX023fZxgNrT .node rect,#mermaid-svg-r5aVxX023fZxgNrT .node circle,#mermaid-svg-r5aVxX023fZxgNrT .node ellipse,#mermaid-svg-r5aVxX023fZxgNrT .node polygon,#mermaid-svg-r5aVxX023fZxgNrT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r5aVxX023fZxgNrT .rough-node .label text,#mermaid-svg-r5aVxX023fZxgNrT .node .label text,#mermaid-svg-r5aVxX023fZxgNrT .image-shape .label,#mermaid-svg-r5aVxX023fZxgNrT .icon-shape .label{text-anchor:middle;}#mermaid-svg-r5aVxX023fZxgNrT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-r5aVxX023fZxgNrT .rough-node .label,#mermaid-svg-r5aVxX023fZxgNrT .node .label,#mermaid-svg-r5aVxX023fZxgNrT .image-shape .label,#mermaid-svg-r5aVxX023fZxgNrT .icon-shape .label{text-align:center;}#mermaid-svg-r5aVxX023fZxgNrT .node.clickable{cursor:pointer;}#mermaid-svg-r5aVxX023fZxgNrT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-r5aVxX023fZxgNrT .arrowheadPath{fill:#333333;}#mermaid-svg-r5aVxX023fZxgNrT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-r5aVxX023fZxgNrT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-r5aVxX023fZxgNrT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r5aVxX023fZxgNrT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-r5aVxX023fZxgNrT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r5aVxX023fZxgNrT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-r5aVxX023fZxgNrT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-r5aVxX023fZxgNrT .cluster text{fill:#333;}#mermaid-svg-r5aVxX023fZxgNrT .cluster span{color:#333;}#mermaid-svg-r5aVxX023fZxgNrT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-r5aVxX023fZxgNrT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-r5aVxX023fZxgNrT rect.text{fill:none;stroke-width:0;}#mermaid-svg-r5aVxX023fZxgNrT .icon-shape,#mermaid-svg-r5aVxX023fZxgNrT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r5aVxX023fZxgNrT .icon-shape p,#mermaid-svg-r5aVxX023fZxgNrT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-r5aVxX023fZxgNrT .icon-shape .label rect,#mermaid-svg-r5aVxX023fZxgNrT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r5aVxX023fZxgNrT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-r5aVxX023fZxgNrT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-r5aVxX023fZxgNrT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} stickyHeader
标题随组滚动
到顶时固定
被下一组顶替
contentPadding
在列表内容外
加固定留白
不计入 item 测量
reverseLayout=true
布局镜像
首项在底部
滚动方向相反
GridCells.Adaptive(120dp)
按宽度算列数
列宽 >=120 且尽量满
进阶:contentPadding vs Modifier.padding
contentPadding 是列表内容区 的内边距,它不计入 item 的测量,且滚动能滚到 padding 区域(即 padding 内可短暂显示内容)。而给 LazyColumn 根加 Modifier.padding() 是给整个滚动容器加外边距,item 不会滚进那个区域。要"列表首尾留白且可滚动进入",用 contentPadding。
真实案例:用 Modifier.padding 加底部留白,最后一项死活滑不到底
一个聊天页面,开发者想让消息列表底部留 16dp 间距,于是:
kotlin
LazyColumn(
Modifier.fillMaxSize().padding(bottom = 16.dp) // ❌
) { items(messages, key = { it.id }) { MessageBubble(it) } }
结果:最后一条消息的底部 16dp 被裁在屏幕外,永远滑不到------因为 Modifier.padding 是给整个滚动容器加外边距,滚动内容区的可视高度变小,但列表的"可滚动范围"仍然是内容总高,最后一项的底部正好落在被 padding 挤出去的那 16dp 里。
修法:用 contentPadding = PaddingValues(bottom = 16.dp)。它的语义是"列表内容区内部的留白,且滚动能进入该区域",最后一条消息能完整滚到可视区底部之上 16dp 处。一句话区别:想让 item 能滚进留白区 → contentPadding;想给整个列表框一块外边距(item 进不去)→ Modifier.padding。
进阶:stickyHeader 与 reverseLayout 的协同坑
reverseLayout = true(聊天)配合 stickyHeader 时,吸顶会变成"吸底"------因为逻辑首项是视觉底项,stickyHeader 钉在"逻辑顶"即视觉底。如果你的分组标题想钉在视觉顶,在 reverseLayout 下列表里用 stickyHeader 会得到反直觉效果。解决方案通常是:聊天场景一般不用 stickyHeader(按时间倒序本就最新在底),或改用非 reverseLayout + 手动滚到指定位置。
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
ItemDecoration 吸顶 |
stickyHeader |
内置 |
padding 在 RecyclerView |
contentPadding |
语义更准 |
GridLayoutManager |
LazyVerticalGrid + GridCells |
自适应列 |
原理简析 🔬
stickyHeader 的实现是:Lazy 在布局时检测 header 项的位置,若它该"钉"在视口顶部且尚未被下一组推出,则将其 offset 固定在顶部。reverseLayout 则是把"首项索引=0"映射到布局的底部,并反转滚动 delta 的符号------统一了聊天/时间线的反向滚动需求。
常见坑
- 用
Modifier.padding给列表加首尾留白,结果最后一项被裁掉一部分看不到------应用contentPadding。 - 在
reverseLayout列表里仍按"index=0 在顶部"的逻辑判断"回到底部",方向反了。
自检
聊天界面用 reverseLayout=true,怎么判断"已经滚到最新消息(底部)"来隐藏"回到底部"按钮?
reverseLayout=true 时,列表首项(index=0,最新消息)在底部。判断是否到底部,读 listState.firstVisibleItemIndex == 0(最新项可见)即代表在底部。注意和正向列表"index==0 在顶部"的语义区分------reverseLayout 把"逻辑首"放到了视觉底。
3.7 LazyLayout 底层 API 概念
一句话 :
LazyLayout是 LazyColumn/Row/Grid 的共同底层 。当你需要完全自定义的虚拟化布局(如瀑布流、横向环形轮播),可以基于LazyLayout+LazyLayoutItemProvider直接控制"测量哪些项、放在哪",这是 W10 自定义 Layout 在列表维度的延伸。
从一个真实的别扭说起
LazyColumn 只能做"单向线性"。想做瀑布流(Pinterest 式,两列高度不等、各自独立追加) ,标准 Lazy 不支持------因为 LazyColumn 要求所有项在同一轴线上对齐测量。这时需要 LazyLayout 自己决定"哪些 item 进可见区、各自放什么坐标"。
它是什么
LazyLayout 把"虚拟化"的核心抽象出来,交给你两个职责:
kotlin
LazyLayout(
itemProvider = { constraints ->
// 告诉框架:当前有多少项、每项的 contentType
object : LazyLayoutItemProvider { /* ... */ }
},
measurePolicy = { constraints, itemProvider ->
// 你决定:测量哪些 item、放在什么位置
// 返回 Placeable 列表与布局尺寸
layout(width, height) { /* placeables.forEach { it.place(x, y) } */ }
}
)
关键区别:标准 Lazy 的"测量+摆放"是内部写死的线性逻辑;LazyLayout 把这部分开放给你 ,你拿到 itemProvider 后可以只测量可见 item、按任意几何规则摆放。
与标准 Lazy 的关系
#mermaid-svg-O6uYhI7Kbc14IkHU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-O6uYhI7Kbc14IkHU .error-icon{fill:#552222;}#mermaid-svg-O6uYhI7Kbc14IkHU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-O6uYhI7Kbc14IkHU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-O6uYhI7Kbc14IkHU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-O6uYhI7Kbc14IkHU .marker.cross{stroke:#333333;}#mermaid-svg-O6uYhI7Kbc14IkHU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-O6uYhI7Kbc14IkHU p{margin:0;}#mermaid-svg-O6uYhI7Kbc14IkHU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-O6uYhI7Kbc14IkHU .cluster-label text{fill:#333;}#mermaid-svg-O6uYhI7Kbc14IkHU .cluster-label span{color:#333;}#mermaid-svg-O6uYhI7Kbc14IkHU .cluster-label span p{background-color:transparent;}#mermaid-svg-O6uYhI7Kbc14IkHU .label text,#mermaid-svg-O6uYhI7Kbc14IkHU span{fill:#333;color:#333;}#mermaid-svg-O6uYhI7Kbc14IkHU .node rect,#mermaid-svg-O6uYhI7Kbc14IkHU .node circle,#mermaid-svg-O6uYhI7Kbc14IkHU .node ellipse,#mermaid-svg-O6uYhI7Kbc14IkHU .node polygon,#mermaid-svg-O6uYhI7Kbc14IkHU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-O6uYhI7Kbc14IkHU .rough-node .label text,#mermaid-svg-O6uYhI7Kbc14IkHU .node .label text,#mermaid-svg-O6uYhI7Kbc14IkHU .image-shape .label,#mermaid-svg-O6uYhI7Kbc14IkHU .icon-shape .label{text-anchor:middle;}#mermaid-svg-O6uYhI7Kbc14IkHU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-O6uYhI7Kbc14IkHU .rough-node .label,#mermaid-svg-O6uYhI7Kbc14IkHU .node .label,#mermaid-svg-O6uYhI7Kbc14IkHU .image-shape .label,#mermaid-svg-O6uYhI7Kbc14IkHU .icon-shape .label{text-align:center;}#mermaid-svg-O6uYhI7Kbc14IkHU .node.clickable{cursor:pointer;}#mermaid-svg-O6uYhI7Kbc14IkHU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-O6uYhI7Kbc14IkHU .arrowheadPath{fill:#333333;}#mermaid-svg-O6uYhI7Kbc14IkHU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-O6uYhI7Kbc14IkHU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-O6uYhI7Kbc14IkHU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-O6uYhI7Kbc14IkHU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-O6uYhI7Kbc14IkHU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-O6uYhI7Kbc14IkHU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-O6uYhI7Kbc14IkHU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-O6uYhI7Kbc14IkHU .cluster text{fill:#333;}#mermaid-svg-O6uYhI7Kbc14IkHU .cluster span{color:#333;}#mermaid-svg-O6uYhI7Kbc14IkHU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-O6uYhI7Kbc14IkHU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-O6uYhI7Kbc14IkHU rect.text{fill:none;stroke-width:0;}#mermaid-svg-O6uYhI7Kbc14IkHU .icon-shape,#mermaid-svg-O6uYhI7Kbc14IkHU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-O6uYhI7Kbc14IkHU .icon-shape p,#mermaid-svg-O6uYhI7Kbc14IkHU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-O6uYhI7Kbc14IkHU .icon-shape .label rect,#mermaid-svg-O6uYhI7Kbc14IkHU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-O6uYhI7Kbc14IkHU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-O6uYhI7Kbc14IkHU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-O6uYhI7Kbc14IkHU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} LazyLayout(底层)
LazyColumn
LazyRow
LazyVerticalGrid
你的瀑布流 / 环形轮播
(自定义 measurePolicy)
进阶:何时值得用
绝大多数业务用 LazyColumn/LazyVerticalGrid 足矣。LazyLayout 适用于:瀑布流、时间轴(非线性的二维排布)、特殊滚动容器。但它的复杂度高,且要自己处理 item 的测量缓存、可见性判断------先确认标准 Lazy 组合(如 Grid + 自定义 item)真的满足不了再上。
真实案例:误用 LazyLayout 做"横向日历",反而比 LazyRow 复杂三倍
一个需求:首页显示一个可横向滑动的"未来 30 天日期条",每天一个方块。开发者听说 LazyLayout "更灵活",直接上:自己写 itemProvider 算 30 个 item、写 measurePolicy 把每个方块 place(x = index * 64, y = 0)。结果 200 行代码,还要自己处理"可见性裁剪""横向滚动偏移传递""复用池",bug 频出。
复盘:这本质就是"固定数量、单向线性、等宽"的列表------LazyRow { items(30) { DayCell(it) } } 三行搞定,虚拟化、复用、滚动全有。LazyLayout 只有在"标准 Lazy 的线性测量假设不成立"时才需要(如瀑布流的非对齐列)。用 LazyLayout 前先问:LazyRow/Column/Grid 真的不行吗? 本例明显行。教训:不要为了"显得高级"提前透支复杂度。
度量:怎么判断该不该上 LazyLayout
| 你的布局 | 用 |
|---|---|
| 单向线性、等宽/等高、数量多 | LazyColumn / LazyRow |
| 网格、每列宽自适应 | LazyVerticalGrid(GridCells.Adaptive) |
| 二维非对齐(瀑布流、时间轴) | LazyLayout + 自定义 measurePolicy |
| 环形/轮播/特殊几何 | LazyLayout 或自定义 Layout(W10) |
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
自定义 LayoutManager |
LazyLayout + measurePolicy |
能力对等 |
原理简析 🔬
LazyLayout 把"虚拟化"拆成两层:① item 元数据与内容提供(LazyLayoutItemProvider);② 测量与摆放策略(MeasurePolicy)。标准 Lazy 是"② 固定为线性"的特例。理解这层抽象,你就能把 RecyclerView 时代"自定义 LayoutManager"的能力完整迁移到 Compose。
常见坑
- 在
measurePolicy里测量了所有 item(失去虚拟化意义),等于退化成普通 Layout。 - 忘记只测量"可见区 + 缓冲"的项,长列表仍全量创建。
自检
瀑布流为什么不能直接用 LazyColumn + 两个内部 LazyColumn 模拟?
那会变成"两个独立竖向 Lazy 各管一半数据",但数据是按"哪列更矮就追加到哪列"动态分配的,两个独立 Lazy 无法跨列共享"列高"状态,且整列作为一个虚拟化单元,无法做到"单条 item 级"的复用与回收,滚动性能和内存优势大打折扣。真正瀑布流需要 LazyLayout 在 measurePolicy 里按列高动态摆放单 item。
3.8 animateItem + PullToRefreshBox
一句话 :
animateItem()(Modifier)让列表项在增删/重排/位置变化 时播放平滑移动/淡入动画,依赖key;PullToRefreshBox(Material3)是内置下拉刷新容器,几行代码接入状态驱动的刷新 UI。
从一个真实的别扭说起
View 里 DiffUtil + notifyItemInserted 能自动播放插入动画。Compose 的 Lazy 默认增删是"硬切"------项直接出现/消失。想恢复那种"项平滑滑入、其余项让位"的体验,只需给 item 的 Modifier 加 .animateItem(),且必须有 key。
它是什么
animateItem(Compose 1.6+ 推荐写法)
kotlin
LazyColumn {
items(items, key = { it.id }) { item ->
Row(Modifier.animateItem()) { // 关键:配合 key 才生效
ListItemContent(item)
}
}
}
animateItem() 会在项的位置/存在性变化时,自动用动画过渡(位移 + 淡入淡出),无需手动写 AnimatedVisibility。
PullToRefreshBox(Material3)
kotlin
val refreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val state = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { viewModel.refresh() },
state = state
) {
LazyColumn(/* ... */) { /* 列表内容 */ }
}
下拉时显示 Material 刷新指示器,onRefresh 触发你的刷新逻辑,isRefreshing 控制指示器显隐。
运行时发生了什么
#mermaid-svg-bhNOxYy0QxDqtF6a{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bhNOxYy0QxDqtF6a .error-icon{fill:#552222;}#mermaid-svg-bhNOxYy0QxDqtF6a .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bhNOxYy0QxDqtF6a .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bhNOxYy0QxDqtF6a .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bhNOxYy0QxDqtF6a .marker.cross{stroke:#333333;}#mermaid-svg-bhNOxYy0QxDqtF6a svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bhNOxYy0QxDqtF6a p{margin:0;}#mermaid-svg-bhNOxYy0QxDqtF6a .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bhNOxYy0QxDqtF6a .cluster-label text{fill:#333;}#mermaid-svg-bhNOxYy0QxDqtF6a .cluster-label span{color:#333;}#mermaid-svg-bhNOxYy0QxDqtF6a .cluster-label span p{background-color:transparent;}#mermaid-svg-bhNOxYy0QxDqtF6a .label text,#mermaid-svg-bhNOxYy0QxDqtF6a span{fill:#333;color:#333;}#mermaid-svg-bhNOxYy0QxDqtF6a .node rect,#mermaid-svg-bhNOxYy0QxDqtF6a .node circle,#mermaid-svg-bhNOxYy0QxDqtF6a .node ellipse,#mermaid-svg-bhNOxYy0QxDqtF6a .node polygon,#mermaid-svg-bhNOxYy0QxDqtF6a .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bhNOxYy0QxDqtF6a .rough-node .label text,#mermaid-svg-bhNOxYy0QxDqtF6a .node .label text,#mermaid-svg-bhNOxYy0QxDqtF6a .image-shape .label,#mermaid-svg-bhNOxYy0QxDqtF6a .icon-shape .label{text-anchor:middle;}#mermaid-svg-bhNOxYy0QxDqtF6a .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bhNOxYy0QxDqtF6a .rough-node .label,#mermaid-svg-bhNOxYy0QxDqtF6a .node .label,#mermaid-svg-bhNOxYy0QxDqtF6a .image-shape .label,#mermaid-svg-bhNOxYy0QxDqtF6a .icon-shape .label{text-align:center;}#mermaid-svg-bhNOxYy0QxDqtF6a .node.clickable{cursor:pointer;}#mermaid-svg-bhNOxYy0QxDqtF6a .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bhNOxYy0QxDqtF6a .arrowheadPath{fill:#333333;}#mermaid-svg-bhNOxYy0QxDqtF6a .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bhNOxYy0QxDqtF6a .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bhNOxYy0QxDqtF6a .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bhNOxYy0QxDqtF6a .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bhNOxYy0QxDqtF6a .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bhNOxYy0QxDqtF6a .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bhNOxYy0QxDqtF6a .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bhNOxYy0QxDqtF6a .cluster text{fill:#333;}#mermaid-svg-bhNOxYy0QxDqtF6a .cluster span{color:#333;}#mermaid-svg-bhNOxYy0QxDqtF6a div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bhNOxYy0QxDqtF6a .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bhNOxYy0QxDqtF6a rect.text{fill:none;stroke-width:0;}#mermaid-svg-bhNOxYy0QxDqtF6a .icon-shape,#mermaid-svg-bhNOxYy0QxDqtF6a .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bhNOxYy0QxDqtF6a .icon-shape p,#mermaid-svg-bhNOxYy0QxDqtF6a .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bhNOxYy0QxDqtF6a .icon-shape .label rect,#mermaid-svg-bhNOxYy0QxDqtF6a .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bhNOxYy0QxDqtF6a .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bhNOxYy0QxDqtF6a .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bhNOxYy0QxDqtF6a :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据列表变化(增/删/移)
Lazy 通过 key 识别
哪些项身份变化
animateItem 捕获
项的前后位置
播放位移动画
其余项平滑让位
用户下拉
PullToRefreshBox 显示指示器
onRefresh 触发
isRefreshing=true
数据回来
isRefreshing=false 收起
进阶:动画与重组的边界
animateItem 的动画在重组之外运行(由 Lazy 的动画系统驱动),所以不会每帧触发你的 Composable 重组------这正是它流畅的原因。但前提是 key 稳定;key 变了等于"旧项消失+新项出现",不会播放移动动画而是淡入淡出。
真实案例:列表重排时项"闪一下"而非平滑移动,原因是 key 拼了时间戳
一个待办应用,item 的 key 写成 key = { "${it.id}_${it.updatedAt }" }------开发者想"更新时间变了就让该项重播动画"。结果每次任意项更新,所有项都闪一下(淡入淡出)而非平滑移动。
排查:key 里拼了 updatedAt,任何一项更新都会让它的 key 变化,Lazy 判定"旧项消失、新项出现"→ 淡入淡出而非位移。更糟的是,如果列表整体重排,大量 key 同时变化,整屏闪烁。
修法:key 只反映"项的稳定身份",把 updatedAt 这类会变的属性从 key 里拿掉:
kotlin
items(todos, key = { it.id }) { todo -> // ✅ 只认 id
// updatedAt 的变化在 item 内部读 state 即可,不进 key
TodoRow(todo)
}
要让"某一项更新时高亮",应在 item 内部用 LaunchedEffect(todo.updatedAt) 触发局部高亮动画,而非改 key。结论:key 是身份锚点,动它就等于告诉框架"这不是同一条数据了"------除非你确实想表达"这是新数据"。
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
notifyItemInserted 动画 |
animateItem() + key |
自动 |
SwipeRefreshLayout |
PullToRefreshBox |
Material3 内置 |
原理简析 🔬
animateItem 在底层维护每个 key 对应的项的"动画状态",当 Lazy 因数据变化重新布局时,对比项的新旧位置,用 Animatable 对 offset 做插值。它和 4.x 的动画体系(W9)同源------列表项动画只是动画系统的一个特化入口。
常见坑
- 加了
animateItem()但没设key------动画不生效(框架无法识别"谁是谁")。 - 在
PullToRefreshBox里给LazyColumn又套一层verticalScroll------滚动冲突。
自检
为什么 animateItem() 必须配合 key 才能播放"移动"动画?
因为"移动动画"的前提是框架能回答"这个项之前在哪、现在去了哪"。没有 key 时,Lazy 按位置识别项,数据重排后它看到的是"位置 3 的内容换了",而非"某条数据从位置 3 移到了位置 5",于是只能做淡入淡出(视为新项出现)。有 key 后,框架通过 key 匹配到"还是同一条数据,只是位置变了",才能插值出位移动画。
3.9 Paging3 分页加载
一句话 :
Paging 3是 Google 官方分页库,配合collectAsLazyPagingItems()把"分页数据流"直接喂给LazyColumn------自动处理页码加载、加载状态、重试、列表 diff,省去手写"滑到底加载下一页"的全部样板。
从一个真实的别扭说起
你手写"监听 listState 滚到倒数第 5 项就 viewModel.loadNextPage()",还要管 loading 状态、避免重复触发、错误处理、上拉"加载更多" footer。这些样板 Paging3 全包了:你只定义"怎么取一页",库负责何时取、怎么拼、怎么 diff。
它是什么
kotlin
// ViewModel 暴露 PagingData 流
val flow = Pager(PagingConfig(pageSize = 20)) {
MyPagingSource(api)
}.flow.cachedIn(viewModelScope)
// Composable 里
val lazyPagingItems = flow.collectAsLazyPagingItems()
LazyColumn {
items(
count = lazyPagingItems.itemCount,
key = lazyPagingItems.itemKey { it.id }
) { index ->
val item = lazyPagingItems[index]
item?.let { ListRow(it) }
}
// 加载状态 footer
lazyPagingItems.loadState.append.takeIf { it is LoadState.Loading }?.let {
item { LoadingFooter() }
}
}
collectAsLazyPagingItems() 把 Paging 的 PagingData 流转成 Lazy 友好的 LazyPagingItems,自动触发分页加载、暴露 itemCount/loadState/itemKey。
运行时发生了什么
#mermaid-svg-DErHcMat42UoIajC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DErHcMat42UoIajC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DErHcMat42UoIajC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DErHcMat42UoIajC .error-icon{fill:#552222;}#mermaid-svg-DErHcMat42UoIajC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DErHcMat42UoIajC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DErHcMat42UoIajC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DErHcMat42UoIajC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DErHcMat42UoIajC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DErHcMat42UoIajC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DErHcMat42UoIajC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DErHcMat42UoIajC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DErHcMat42UoIajC .marker.cross{stroke:#333333;}#mermaid-svg-DErHcMat42UoIajC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DErHcMat42UoIajC p{margin:0;}#mermaid-svg-DErHcMat42UoIajC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DErHcMat42UoIajC .cluster-label text{fill:#333;}#mermaid-svg-DErHcMat42UoIajC .cluster-label span{color:#333;}#mermaid-svg-DErHcMat42UoIajC .cluster-label span p{background-color:transparent;}#mermaid-svg-DErHcMat42UoIajC .label text,#mermaid-svg-DErHcMat42UoIajC span{fill:#333;color:#333;}#mermaid-svg-DErHcMat42UoIajC .node rect,#mermaid-svg-DErHcMat42UoIajC .node circle,#mermaid-svg-DErHcMat42UoIajC .node ellipse,#mermaid-svg-DErHcMat42UoIajC .node polygon,#mermaid-svg-DErHcMat42UoIajC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DErHcMat42UoIajC .rough-node .label text,#mermaid-svg-DErHcMat42UoIajC .node .label text,#mermaid-svg-DErHcMat42UoIajC .image-shape .label,#mermaid-svg-DErHcMat42UoIajC .icon-shape .label{text-anchor:middle;}#mermaid-svg-DErHcMat42UoIajC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DErHcMat42UoIajC .rough-node .label,#mermaid-svg-DErHcMat42UoIajC .node .label,#mermaid-svg-DErHcMat42UoIajC .image-shape .label,#mermaid-svg-DErHcMat42UoIajC .icon-shape .label{text-align:center;}#mermaid-svg-DErHcMat42UoIajC .node.clickable{cursor:pointer;}#mermaid-svg-DErHcMat42UoIajC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DErHcMat42UoIajC .arrowheadPath{fill:#333333;}#mermaid-svg-DErHcMat42UoIajC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DErHcMat42UoIajC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DErHcMat42UoIajC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DErHcMat42UoIajC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DErHcMat42UoIajC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DErHcMat42UoIajC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DErHcMat42UoIajC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DErHcMat42UoIajC .cluster text{fill:#333;}#mermaid-svg-DErHcMat42UoIajC .cluster span{color:#333;}#mermaid-svg-DErHcMat42UoIajC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DErHcMat42UoIajC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DErHcMat42UoIajC rect.text{fill:none;stroke-width:0;}#mermaid-svg-DErHcMat42UoIajC .icon-shape,#mermaid-svg-DErHcMat42UoIajC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DErHcMat42UoIajC .icon-shape p,#mermaid-svg-DErHcMat42UoIajC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DErHcMat42UoIajC .icon-shape .label rect,#mermaid-svg-DErHcMat42UoIajC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DErHcMat42UoIajC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DErHcMat42UoIajC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DErHcMat42UoIajC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Lazy 接近列表尾部
Paging 自动请求下一页
PagingSource 取数据
新页 diff 并入
(基于 key)
Lazy 增量显示
无闪烁
loadState 变化
footer 显示
Loading/Error/End
进阶:为什么比手写好
Paging3 的 PagingSource 用"上一页的锚点"算下一页参数,天然支持按 key 分页(游标/offset)。cachedIn 让分页结果在配置变更(旋转)后保留。错误重试、刷新失效(invalidate)都有标准 API。手写这套极易漏掉边界(如快速滚动连发多次请求)。
真实案例:分页请求失败只显示空白,用户以为"没更多了"
一个列表用 Paging3,但开发者只处理了 loadState.append is LoadState.Loading 显示 footer,没处理 Error 状态。结果某一页请求超时,footer 直接消失,用户滑到底看到空白,以为列表结束了,不知道可以重试。
完整处理应覆盖三种状态,并暴露重试入口:
kotlin
lazyPagingItems.loadState.append.let { state ->
when (state) {
is LoadState.Loading -> item { LoadingFooter() }
is LoadState.Error -> item {
// 关键:提供"点击重试",调用 retry()
ErrorFooter(
message = "加载失败",
onRetry = { lazyPagingItems.retry() } // retry() 重放失败的那一页
)
}
is LoadState.NotLoading -> if (state.endOfPaginationReached) {
item { EndFooter("没有更多了") }
}
}
}
lazyPagingItems.retry() 会重新执行失败的那一页的 PagingSource.load,无需手动清状态。同理 refresh 状态(下拉刷新/首次加载)也要单独处理------首次加载失败要显示"整页错误 + 重试",而不是只塞个 footer。
进阶:Paging 与弱网(你业务的核心场景)
在弱网环境下,分页最常见的两类问题:(1) 请求超时率高 → 给 PagingSource 的底层 OkHttp 设合理超时与重试(指数退避),而非依赖 Paging 默认;(2) 用户快速滚动连发多页 → Paging 默认已做"只加载可见附近页"的节流,但 prefetchDistance 可调(默认 2,弱网下可加大到 4-5 提前预取,牺牲一点流量换流畅)。这两点对出海弱网项目尤其关键,属于"Paging 之上的网络层调优"。
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
PagingDataAdapter |
collectAsLazyPagingItems |
同源库 |
原理简析 🔬
Paging 的核心是"分页数据 + 占位"。PagingData 是惰性的:只有 Lazy 请求到某 index,Paging 才确保那一页已加载。itemKey/itemContentType 自动从 PagingData 的 key 派生,和 3.4/3.5 的 key/contentType 机制无缝衔接。
常见坑
- 忘记
cachedIn(viewModelScope),旋转后分页从头加载、丢失滚动位置。 - 在
items里用lazyPagingItems[index]却没判空(加载中该项为 null)。
自检
为什么 Paging3 的列表滚动位置在旋转后能恢复,而自己手写的"滚到底加载"容易丢位置?
因为 cachedIn(viewModelScope) 把分页结果缓存在 ViewModel 作用域内,配置变更(旋转)时 ViewModel 不销毁,缓存的页数据还在,collectAsLazyPagingItems 重建后直接复用,滚动位置(基于 item 索引)得以保持。手写方案若把数据存在 Activity/Composable 局部,旋转即丢失,需额外用 ViewModel + Saveable 手动恢复。
3.10 nestedScroll 嵌套滚动 + 性能
一句话 :
Modifier.nestedScroll+NestedScrollConnection让你协调"外层容器"与"内层 Lazy"的滚动------典型场景是"外层折叠标题栏 + 内层列表",滚到顶后把后续滚动交给外层。性能上:Lazy 项里别做重活、图片加载要管好协程取消。
从一个真实的别扭说起
你要做"顶部大图,往下滚时大图收起成小条,同时下面列表上移"的折叠效果。默认内层 LazyColumn 会把所有滚动都吃掉,外层收不起来。需要 nestedScroll 告诉系统:"内层先滚,滚到顶了剩下的滚动量交给外层处理"------这是 View 里 NestedScrollingParent/Child 的 Compose 版。
它是什么
kotlin
val nestedScrollConnection = remember {
object : NestedScrollConnection {
// 内层消费前:先让外层有机会(如收起标题栏)
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return Offset.Zero // 这里可拦截部分滚动量
}
// 内层消费后:剩余滚动量交给外层
override fun onPostScroll(
consumed: Offset, available: Offset, source: NestedScrollSource
): Offset {
// available 是内层没吃完的,外层(如标题栏)在此收起
return Offset.Zero
}
}
}
Column(Modifier.nestedScroll(nestedScrollConnection)) {
CollapsibleHeader(/* 随滚动收起 */)
LazyColumn(/* 内层列表 */) { /* ... */ }
}
嵌套滚动的协商流程
#mermaid-svg-6UswgyFiyrfc1wGK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6UswgyFiyrfc1wGK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6UswgyFiyrfc1wGK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6UswgyFiyrfc1wGK .error-icon{fill:#552222;}#mermaid-svg-6UswgyFiyrfc1wGK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6UswgyFiyrfc1wGK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6UswgyFiyrfc1wGK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6UswgyFiyrfc1wGK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6UswgyFiyrfc1wGK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6UswgyFiyrfc1wGK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6UswgyFiyrfc1wGK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6UswgyFiyrfc1wGK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6UswgyFiyrfc1wGK .marker.cross{stroke:#333333;}#mermaid-svg-6UswgyFiyrfc1wGK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6UswgyFiyrfc1wGK p{margin:0;}#mermaid-svg-6UswgyFiyrfc1wGK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6UswgyFiyrfc1wGK .cluster-label text{fill:#333;}#mermaid-svg-6UswgyFiyrfc1wGK .cluster-label span{color:#333;}#mermaid-svg-6UswgyFiyrfc1wGK .cluster-label span p{background-color:transparent;}#mermaid-svg-6UswgyFiyrfc1wGK .label text,#mermaid-svg-6UswgyFiyrfc1wGK span{fill:#333;color:#333;}#mermaid-svg-6UswgyFiyrfc1wGK .node rect,#mermaid-svg-6UswgyFiyrfc1wGK .node circle,#mermaid-svg-6UswgyFiyrfc1wGK .node ellipse,#mermaid-svg-6UswgyFiyrfc1wGK .node polygon,#mermaid-svg-6UswgyFiyrfc1wGK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6UswgyFiyrfc1wGK .rough-node .label text,#mermaid-svg-6UswgyFiyrfc1wGK .node .label text,#mermaid-svg-6UswgyFiyrfc1wGK .image-shape .label,#mermaid-svg-6UswgyFiyrfc1wGK .icon-shape .label{text-anchor:middle;}#mermaid-svg-6UswgyFiyrfc1wGK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6UswgyFiyrfc1wGK .rough-node .label,#mermaid-svg-6UswgyFiyrfc1wGK .node .label,#mermaid-svg-6UswgyFiyrfc1wGK .image-shape .label,#mermaid-svg-6UswgyFiyrfc1wGK .icon-shape .label{text-align:center;}#mermaid-svg-6UswgyFiyrfc1wGK .node.clickable{cursor:pointer;}#mermaid-svg-6UswgyFiyrfc1wGK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6UswgyFiyrfc1wGK .arrowheadPath{fill:#333333;}#mermaid-svg-6UswgyFiyrfc1wGK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6UswgyFiyrfc1wGK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6UswgyFiyrfc1wGK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6UswgyFiyrfc1wGK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6UswgyFiyrfc1wGK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6UswgyFiyrfc1wGK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6UswgyFiyrfc1wGK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6UswgyFiyrfc1wGK .cluster text{fill:#333;}#mermaid-svg-6UswgyFiyrfc1wGK .cluster span{color:#333;}#mermaid-svg-6UswgyFiyrfc1wGK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6UswgyFiyrfc1wGK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6UswgyFiyrfc1wGK rect.text{fill:none;stroke-width:0;}#mermaid-svg-6UswgyFiyrfc1wGK .icon-shape,#mermaid-svg-6UswgyFiyrfc1wGK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6UswgyFiyrfc1wGK .icon-shape p,#mermaid-svg-6UswgyFiyrfc1wGK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6UswgyFiyrfc1wGK .icon-shape .label rect,#mermaid-svg-6UswgyFiyrfc1wGK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6UswgyFiyrfc1wGK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6UswgyFiyrfc1wGK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6UswgyFiyrfc1wGK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户滚动手势
内层 Lazy 先接到滚动量
onPreScroll:外层可先拦截一部分
内层消费自己能消费的
onPostScroll:
剩余量 available 交给外层
外层据此收起/展开(如标题栏)
进阶:Lazy 项内的性能纪律
| 反模式 | 正确做法 |
|---|---|
在 items lambda 里做重计算/排序 |
计算提到 ViewModel,项只展示 |
| 图片加载不取消协程 | 用 Coil/Glide(自动随 item 销毁取消) |
item 内 LaunchedEffect(Unit) 发请求 |
请求在列表级/ViewModel 发起 |
长列表用 verticalScroll |
改用 LazyColumn(3.1) |
| 多类型不设 contentType | 设 contentType(3.5) |
实战:折叠标题栏完整示例
把 3.10 开头的设想落成可运行代码。核心是一个由 mutableStateOf 维护、随滚动增减的 headerHeight,onPostScroll 里减、graphicsLayer 调整透明度(平移同理)。
kotlin
@Composable
fun CollapsibleFeed() {
val headerMax = 200.dp
val headerHeight = remember { mutableStateOf(headerMax) } // 当前高度
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset, available: Offset, source: NestedScrollSource
): Offset {
// available.y > 0 表示向外(向下)滚 → 收起;< 0 滚回顶部 → 展开
val delta = available.y
val newHeight = (headerHeight.value - delta.dp)
.coerceIn(0.dp, headerMax) // 夹在 [0, max] 之间
headerHeight.value = newHeight
return Offset.Zero // 外层自己消费了,不再外传
}
}
}
Box(Modifier.nestedScroll(nestedScrollConnection).fillMaxSize()) {
// 关键:给列表 contentPadding.top = headerMax,让首屏内容从标题栏下方开始
LazyColumn(contentPadding = PaddingValues(top = headerMax)) {
items(50) { i -> Text("Feed item #$i", Modifier.padding(16.dp)) }
}
Box( // 标题栏:高度随 headerHeight 变,透明度同步收起
Modifier
.fillMaxWidth()
.height(headerHeight.value)
.graphicsLayer { alpha = headerHeight.value / headerMax }
.background(MaterialTheme.colorScheme.primary)
) { Text("Header", Modifier.padding(16.dp), color = Color.White) }
}
}
三个要点:
headerHeight用mutableStateOf而非Animatable------这里要的是"跟手"而非"缓动动画",跟手反而更跟手;想要松手回弹再叠 4.x 的动画即可。- 标题栏高度变化本身会触发重组 。若想彻底零重组,可改为只改
graphicsLayer { translationY },把位移交给 GPU,让 Compose 只重绘不重组------生产环境推荐后者。 contentPadding(top = headerMax)是让列表内容不藏在标题栏后面的关键,漏掉这一行会让首屏第一条被标题栏盖住。
#mermaid-svg-V7Qe25vwGw9K6ljL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-V7Qe25vwGw9K6ljL .error-icon{fill:#552222;}#mermaid-svg-V7Qe25vwGw9K6ljL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-V7Qe25vwGw9K6ljL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-V7Qe25vwGw9K6ljL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-V7Qe25vwGw9K6ljL .marker.cross{stroke:#333333;}#mermaid-svg-V7Qe25vwGw9K6ljL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-V7Qe25vwGw9K6ljL p{margin:0;}#mermaid-svg-V7Qe25vwGw9K6ljL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-V7Qe25vwGw9K6ljL .cluster-label text{fill:#333;}#mermaid-svg-V7Qe25vwGw9K6ljL .cluster-label span{color:#333;}#mermaid-svg-V7Qe25vwGw9K6ljL .cluster-label span p{background-color:transparent;}#mermaid-svg-V7Qe25vwGw9K6ljL .label text,#mermaid-svg-V7Qe25vwGw9K6ljL span{fill:#333;color:#333;}#mermaid-svg-V7Qe25vwGw9K6ljL .node rect,#mermaid-svg-V7Qe25vwGw9K6ljL .node circle,#mermaid-svg-V7Qe25vwGw9K6ljL .node ellipse,#mermaid-svg-V7Qe25vwGw9K6ljL .node polygon,#mermaid-svg-V7Qe25vwGw9K6ljL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-V7Qe25vwGw9K6ljL .rough-node .label text,#mermaid-svg-V7Qe25vwGw9K6ljL .node .label text,#mermaid-svg-V7Qe25vwGw9K6ljL .image-shape .label,#mermaid-svg-V7Qe25vwGw9K6ljL .icon-shape .label{text-anchor:middle;}#mermaid-svg-V7Qe25vwGw9K6ljL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-V7Qe25vwGw9K6ljL .rough-node .label,#mermaid-svg-V7Qe25vwGw9K6ljL .node .label,#mermaid-svg-V7Qe25vwGw9K6ljL .image-shape .label,#mermaid-svg-V7Qe25vwGw9K6ljL .icon-shape .label{text-align:center;}#mermaid-svg-V7Qe25vwGw9K6ljL .node.clickable{cursor:pointer;}#mermaid-svg-V7Qe25vwGw9K6ljL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-V7Qe25vwGw9K6ljL .arrowheadPath{fill:#333333;}#mermaid-svg-V7Qe25vwGw9K6ljL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-V7Qe25vwGw9K6ljL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-V7Qe25vwGw9K6ljL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V7Qe25vwGw9K6ljL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-V7Qe25vwGw9K6ljL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V7Qe25vwGw9K6ljL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-V7Qe25vwGw9K6ljL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-V7Qe25vwGw9K6ljL .cluster text{fill:#333;}#mermaid-svg-V7Qe25vwGw9K6ljL .cluster span{color:#333;}#mermaid-svg-V7Qe25vwGw9K6ljL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-V7Qe25vwGw9K6ljL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-V7Qe25vwGw9K6ljL rect.text{fill:none;stroke-width:0;}#mermaid-svg-V7Qe25vwGw9K6ljL .icon-shape,#mermaid-svg-V7Qe25vwGw9K6ljL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V7Qe25vwGw9K6ljL .icon-shape p,#mermaid-svg-V7Qe25vwGw9K6ljL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-V7Qe25vwGw9K6ljL .icon-shape .label rect,#mermaid-svg-V7Qe25vwGw9K6ljL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V7Qe25vwGw9K6ljL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-V7Qe25vwGw9K6ljL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-V7Qe25vwGw9K6ljL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户向下滚
内层 Lazy 消费部分
onPostScroll
available.y > 0
headerHeight -= delta
coerceIn(0, max)
标题栏 height 变矮
alpha 变淡
到 0 时完全收起
后续滚动全给列表
View 体系对照表
| View | Compose | 备注 |
|---|---|---|
NestedScrollingParent/Child |
nestedScroll + NestedScrollConnection |
协商机制一致 |
原理简析 🔬
嵌套滚动的核心是"滚动量的协商":onPreScroll 在子消费前 给父一个拦截机会,onPostScroll 在子消费后 把剩余量交给父。通过返回不同 Offset 分配"谁吃多少滚动"。这套机制和 RecyclerView 的 NestedScrolling 一对一对等,只是 API 更函数式。
常见坑
- 折叠标题栏滚动"卡顿"------
onPostScroll里对标题栏偏移的修改触发了大量重组,应用graphicsLayer平移或derivedStateOf收窄。 - 内层 Lazy 和外层都设了
verticalScroll------双重滚动容器冲突,应外层用nestedScroll协调而非两个都 scroll。
自检
列表嵌列表(外层 LazyColumn 里某个 item 是内层 LazyRow),外层如何感知内层横向滚动?需要 NestedScrollConnection 吗?
横向内层 LazyRow 和纵向外层 LazyColumn 滚动轴不同,默认不互相争抢(一个吃横向、一个吃纵向),通常无需 NestedScrollConnection。只有当两个滚动在同一轴(如外层纵向、内层也是纵向可滚)时,才需要 nestedScroll 协调"谁先滚、剩下的给谁"。同轴的嵌套(如可滚动头 + 纵向列表)才是 NestedScrollConnection 的战场。
本章小结
这一章你完成了从 RecyclerView 思维到 Lazy 思维的迁移:
- virtualScroll vs Lazy :少量用
verticalScroll,大量用LazyColumn------核心是"全量创建" vs "虚拟化复用"。 - LazyListState 是列表与外部通信的桥梁:读位置、程序化滚动、恢复位置,读索引记得包
derivedStateOf。 - items/itemsIndexed/item :item 内
remember随滑出销毁,持久状态要提升或用 Saveable。 - key 不能省:用稳定 id 标识身份,否则状态错乱、动画断裂、复用错误。
- contentType:多类型列表用它分复用池,按"布局结构"而非"业务种类"划分。
- stickyHeader / contentPadding / reverseLayout / GridCells:吸顶、留白、反向、网格变体,都是内置能力。
- LazyLayout(P2):瀑布流等自定义虚拟化的底层,对应自定义 LayoutManager。
- animateItem + PullToRefreshBox(P2):列表项动画(靠 key)+ 下拉刷新,几行接入。
- Paging3(P2) :官方分页,省去手写加载样板,记得
cachedIn。 - nestedScroll(P2):同轴嵌套滚动的协商机制,配性能纪律(项内不做重活)。
下一章进入全书的分水岭------状态与重组(第 4 章),那些"界面什么时候变、什么时候不变"的诡异现象,根源都在那里。
本章自检(答案折叠)
Q1:一个信息流有 Banner/图文/视频/广告四种卡片,都不设 key 和 contentType,会有什么问题?
两点问题:(1) 不设 key ------ 在头部插入新卡片时,各卡片内的展开/选中状态会错位到错误数据上(按位置复用);且增删时无平滑动画。(2) 不设 contentType ------ 四种布局进同一个复用池,滚动时视频槽位可能被拿来放图文,框架得拆掉重建,频繁掉帧。应设 key={it.id} 和 contentType={it.type}。
Q2:Lazy 项的 remember 状态滑走就丢,这是 Bug 吗?怎么正确保留"已读"标记?
不是 Bug,是虚拟化的必然代价。正确做法:"已读"标记属于数据 的一部分,应由 ViewModel/数据层持有(如 item 模型里的 isRead 字段),UI 只是读取展示。这样无论 item 滑出重建多少次,状态都从数据恢复。把本应属于数据的状态放在 UI 的 remember 里,才是设计问题。
Q3:reverseLayout=true 的聊天列表,如何隐藏"回到底部"按钮?
reverseLayout=true 时逻辑首项(index=0,最新消息)在视觉底部。判断"在底部"即 listState.firstVisibleItemIndex == 0,据此控制 FAB 显隐即可。注意和正向列表语义相反。