Compose 列表
LazyColumn / LazyRow / LazyVerticalGrid、key 性能、LazyListState 控制滚动、stickyHeader、animateItemPlacement、嵌套滚动、contentDescription 与性能优化清单
学习目标
- 熟练使用 LazyColumn / LazyRow / LazyVerticalGrid 渲染列表与网格
- 理解 key 参数在重组与动画中的作用
- 用 LazyListState 控制与监听滚动
- 用 stickyHeader 与 animateItemPlacement 增强列表体验
- 掌握无障碍 contentDescription 与列表性能优化清单
学习目标
列表是任何 App 最常见的 UI 模式:聊天、Feed、设置、商品流……传统 View 时代我们要写 RecyclerView + Adapter + ViewHolder,少说几百行;Compose 用 LazyColumn 一段代码就搞定了。
读完本章你将能够:
- 用
LazyColumn/LazyRow/LazyVerticalGrid渲染任意列表与网格; - 用
key/contentType提升重组与动画性能; - 用
LazyListState控制滚动、监听滚动到顶 / 底; - 用
stickyHeader实现粘性分组标题; - 用
Modifier.animateItemPlacement()让项目移动有动画; - 按性能优化清单检查列表是否达标。
LazyColumn 与 LazyRow
1. 基础 API
@Composable
fun SimpleList(items: List<String>) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items) { item ->
Text(text = item)
}
}
}
LazyColumn 是 Compose 的“懒加载垂直列表”,与传统 RecyclerView 类似:只渲染可见项 + 缓冲区。
注意点:
- 不需要 Adapter,用
LazyListScope.() -> UnitDSL 描述内容; - 直接子级是
LazyListScope,不能直接放 Composable,必须通过item { }/items { }; - 默认有 16dp 的“preload buffer”,滑出可见区的项目会被复用 / 释放。
2. items DSL
LazyListScope 提供三种 API:
LazyColumn {
// 单个 item
item {
Text("顶部 Header")
}
// items(count) :已知数量但无列表
items(100) { index ->
Text("行 $index")
}
// items(list) :直接传集合,lambda 拿到元素
items(userList) { user ->
UserRow(user)
}
// itemsIndexed:要索引
itemsIndexed(userList) { index, user ->
Text("$index: ${user.name}")
}
}
3. contentPadding:边距不消失
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 80.dp, // 给底部 FAB 留位置
),
)
contentPadding 与 Modifier.padding 的差异:前者让内容可滚到 padding 区域,最后一条能滚到顶部状态栏下;后者直接裁切。列表容器用 contentPadding。
4. reverseLayout:聊天倒序
LazyColumn(reverseLayout = true) {
items(messages) { msg -> MessageRow(msg) }
}
reverseLayout = true 让列表从底部开始,新消息从底部插入,常用于聊天界面。
LazyVerticalGrid
@Composable
fun PhotoGrid(photos: List<Int>) {
LazyVerticalGrid(
columns = GridCells.Fixed(3), // 固定 3 列
contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(photos) { resId ->
Image(
painter = painterResource(resId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(1f),
)
}
}
}
GridCells 两种:
| 类型 | 含义 |
|---|---|
GridCells.Fixed(n) |
固定 n 列 |
GridCells.Adaptive(minSize) |
每列至少 minSize,自动算列数 |
Adaptive(160.dp) 适合“卡片宽度最少 160dp,多出的空间塞下更多列”。
LazyHorizontalGrid 同理,行数为 columns。
key:性能与动画的关键
1. 默认行为
不传 key 时,Compose 用“位置索引”作为标识。如果列表中插入 / 删除一条,所有后续位置的“身份”都变了,导致:
- 重组过宽:本来没变的项目也被当作“可能变了”重新计算;
- 动画异常:
animateItemPlacement无法判断哪条移动了。
2. 给稳定 key
items(userList, key = { it.id }) { user ->
UserRow(user)
}
key lambda 返回 Any,但建议用稳定值(如 user.id)。类型上必须是 Any,且不能用 LazyListScope 的内部实例。
3. contentType:异构列表复用池
items(list, key = { it.id }, contentType = { it.type }) { item ->
when (item.type) {
"header" -> HeaderRow(item)
"user" -> UserRow(item)
"ad" -> AdRow(item)
}
}
contentType 让 Compose 维护多个“复用池”,相同类型的项复用同一池。异构列表(聊天消息 / 时间戳 / 系统提示混排)必填。
LazyListState:控制滚动
val state = rememberLazyListState()
LazyColumn(state = state) {
items(userList) { UserRow(it) }
}
// 控制滚动
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
state.animateScrollToItem(0)
}
}) { Text("回到顶部") }
// 监听是否到顶
val atTop by remember {
derivedStateOf { state.firstVisibleItemIndex == 0 }
}
if (!atTop) {
// 显示"回到顶部"按钮
}
常用 API:
| API | 作用 |
|---|---|
scrollToItem(index, offset) |
立即跳转 |
animateScrollToItem(index, offset) |
带动画跳转 |
firstVisibleItemIndex |
第一个可见项的索引 |
firstVisibleItemScrollOffset |
偏移 |
layoutInfo |
当前可见项快照 |
1. derivedStateOf:减少重组
val shouldShowButton by remember {
derivedStateOf {
state.firstVisibleItemIndex > 5
}
}
derivedStateOf 把多个 state 合成一个“派生状态”,只有派生结果变化时才触发重组。比直接读 state.firstVisibleItemIndex 高效得多。
2. 分页加载
val shouldLoadMore by remember {
derivedStateOf {
val last = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
last >= userList.size - 5
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) viewModel.loadNext()
}
监听 shouldLoadMore 派生状态,靠近底部时触发分页。
stickyHeader:粘性标题
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GroupedList(grouped: Map<String, List<Item>>) {
LazyColumn {
grouped.forEach { (group, items) ->
stickyHeader {
Text(
text = group,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(12.dp),
)
}
items(items, key = { it.id }) { item ->
ItemRow(item)
}
}
}
}
stickyHeader 滚动时会“粘”在顶部直到下个 header 接管,类似 iOS 表格的 section header。当前还需 @OptIn(ExperimentalFoundationApi::class)。
animateItemPlacement:拖动排序动画
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReorderableList(items: MutableList<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.animateItemPlacement()
.padding(8.dp),
) {
Text(item.name)
Spacer(Modifier.weight(1f))
IconButton(onClick = { /* 上移 */ }) {
Icon(Icons.Default.ArrowUpward, contentDescription = "上移")
}
}
}
}
}
Modifier.animateItemPlacement() 让项目在列表中位置变化时自动播放位移动画(淡入 / 滑动)。前提是给了稳定的 key,否则 Compose 无法判断“哪条移动了”。
M3 / Compose 1.7+ 起 API 改名
Modifier.animateItem(),行为类似但功能更全(含进入 / 退出动画)。
嵌套滚动
不同方向天然支持嵌套:
LazyColumn {
item { Banner() } // 顶部 Banner
item {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(tabs) { tab -> TabItem(tab) }
}
}
items(posts) { post -> PostRow(post) }
}
同方向嵌套是反模式:LazyColumn 套 LazyColumn 会报“Vertically scrollable component was measured with an infinity maximum height”。
替代方案:把内层改为
Column+ 固定高度,或用Modifier.heightIn(max = ...)限定。
contentDescription:无障碍
items(photos) { photo ->
Image(
painter = painterResource(photo.resId),
contentDescription = photo.title, // 让 TalkBack 念出来
modifier = Modifier.size(120.dp),
)
}
原则:
- 纯装饰图像(按钮内已有文字说明)的
contentDescription填null,避免 TalkBack 重复念; - 承载信息的图标 / 图片应给可读描述,如“用户头像”;
- 列表项整体可用
Modifier.semantics { contentDescription = "用户:${user.name}" }合并,让 TalkBack 一次读完。
@Composable
fun UserRow(user: User) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.semantics(mergeDescendants = true) {
contentDescription = "${user.name},${user.age} 岁"
},
) {
Image(painter = ..., contentDescription = null) // 已合并到父级
Column {
Text(user.name)
Text("${user.age} 岁")
}
}
}
性能优化清单
1. 给稳定 key
items(list, key = { it.id }) { ... } // ✅
items(list) { ... } // ❌ 用位置索引,性能差
2. contentType 异构列表
items(list, key = { it.id }, contentType = { it.type }) { ... }
3. 用 derivedStateOf 派生状态
val atTop by remember { derivedStateOf { state.firstVisibleItemIndex == 0 } }
避免每次滚动都让父 Composable 重组。
4. 避免不必要的 lambda 创建
@Composable
fun UserRow(user: User) { // ✅ 整行是 Composable,参数稳定
Text(user.name)
}
// ❌ 列表项里放嵌套的 Composable lambda
items(list) { user ->
val itemComposable = @Composable { UserRow(user) }
itemComposable()
}
5. 避免在大列表里读 StateFlow
每次 collect 都会触发重组。建议把列表数据写到 ViewModel 的 StateFlow<List<Item>>,UI 用 collectAsStateWithLifecycle() 一次性收集。
6. 不稳定类型加 @Stable / @Immutable
@Immutable
data class User(val id: Long, val name: String) // ✅ 所有字段 val + 不可变
data class MutableUser(var id: Long, var name: String) // ❌ Compose 视为不稳定
Compose 默认对 List<Item> 视为不稳定,每次 collect 都触发整列重组。可包成 @Immutable data class UserList(val items: List<User>) 提示编译器。
7. 谨慎使用 Modifier 链中的 remember
// ❌ 每次重组 new 一个 Modifier
Text("hi", modifier = Modifier.background(Color.Red).padding(8.dp))
// ✅ 顶层缓存
val baseModifier = Modifier.background(Color.Red).padding(8.dp)
Text("hi", modifier = baseModifier)
8. 关闭 LazyListState 跟踪
如果不需要监听滚动状态,可以不创建 rememberLazyListState(),用默认实例。derivedStateOf 只在你需要派生时才用。
9. 滚动时不要触发重组
把滚动监听写在 LaunchedEffect 或 snapshotFlow:
LaunchedEffect(state) {
snapshotFlow { state.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> Log.d("Scroll", "$index") }
}
snapshotFlow 把读到的 state 包装成 Flow,只在状态变化时触发回调,比每帧 lambda 调用高效。
10. 用 Release / R8
Compose 编译器在 release 模式下会做大量优化(条件删除、内联)。生产环境必须开 minifyEnabled = true 并配 R8 规则。
综合示例:聊天列表
@Immutable
data class ChatItem(
val id: Long,
val type: String, // "msg" / "system"
val text: String,
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatList(items: List<ChatItem>) {
val state = rememberLazyListState()
val scope = rememberCoroutineScope()
val showJump by remember {
derivedStateOf { state.firstVisibleItemIndex > 5 }
}
Box {
LazyColumn(
state = state,
reverseLayout = true,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = items,
key = { it.id },
contentType = { it.type },
) { item ->
when (item.type) {
"msg" -> MessageRow(item)
"system" -> SystemRow(item)
}
}
}
if (showJump) {
FloatingActionButton(
onClick = {
scope.launch { state.animateScrollToItem(0) }
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(Icons.Default.ArrowDownward, contentDescription = "回到底部")
}
}
}
}
@Composable
private fun MessageRow(item: ChatItem) {
Row(
modifier = Modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {
contentDescription = item.text
},
) {
Text(item.text)
}
}
@Composable
private fun SystemRow(item: ChatItem) {
Text(
text = item.text,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
)
}
观察:
reverseLayout = true+ 顶部插入,符合聊天 UX;key用id保证动画稳定;contentType让系统消息与聊天消息分别复用;derivedStateOf减少“回到底部按钮”显示的重组频率;semantics(mergeDescendants = true)让整行被 TalkBack 一次读完。
常见坑与最佳实践
- 不写
key:列表动画异常、重组过宽、animateItemPlacement失效。所有items(...)都加key = { it.id }。 key用索引或可变值:key = { it.position }、key = { it.hashCode() }都不稳定,用业务唯一 ID。- 同方向嵌套
LazyColumn:编译器警告 + 高度测量爆炸。换成Column + heightIn或重写为单一LazyColumn。 Modifier.padding替代contentPadding:内容会被裁切到 padding 外,最后一条滚不到顶。列表用contentPadding。- 滚动监听里直接读 state:每帧触发父级重组,UI 卡顿。改用
derivedStateOf或snapshotFlow。 LazyColumn数据量小却用 Lazy:少于 30 项的简单列表,直接Column + verticalScroll性能更好(少一层 lazy 调度)。@Stable/@Immutable用错:把有var字段的类标@Immutable会让重组判断错误,引发 UI 不更新。只有真不可变才加。stickyHeader忘加@OptIn(ExperimentalFoundationApi::class):API 还在实验阶段,必须显式 OptIn,否则编译失败。animateItemPlacement没给key:动画完全不生效,因为 Compose 不知道哪条移动了。- 列表项内读全局 State:如直接
LocalContext.current+ 检查权限,每次重组都跑一次,慢且容易错。把外部依赖通过参数传入。
章节小结
LazyColumn/LazyRow是默认懒加载列表,items(count)/items(list)/itemsIndexed(list)/item { }四种 DSL;LazyVerticalGrid用GridCells.Fixed(n)或Adaptive(minSize),LazyHorizontalGrid同理;contentPadding不裁切内容、reverseLayout倒序适合聊天;key必填:稳定 ID 让重组精准、animateItemPlacement才能正常工作;contentType让异构列表分组复用;LazyListState用rememberLazyListState()创建,animateScrollToItem控制滚动;监听用derivedStateOf/snapshotFlow;stickyHeader实现粘性分组(需 OptIn);Modifier.animateItemPlacement()让项移动有动画;- 同方向嵌套
LazyColumn是反模式,改为单一列表或固定高度Column; - 无障碍:纯装饰图标
contentDescription = null,业务图标给可读描述,列表项用Modifier.semantics(mergeDescendants = true)合并; - 性能优化清单:稳定 key、contentType、derivedStateOf、@Immutable、缓存 Modifier、
collectAsStateWithLifecycle、release + R8。
下一章预告
至此 Part 8-10 前 6 章已经覆盖了 Kotlin 基础、协程与 Flow、Compose 入门、布局、组件与列表。接下来 Part 8-10 的后续章节会进入更高级的话题:状态管理(remember / derivedStateOf / snapshotFlow)、Navigation Compose、与 ViewModel + Room + Retrofit 的端到端整合、自定义 Composable 与 Modifier、动画系统(AnimatedVisibility / animateContentSize / Animatable)、测试与无障碍深度优化。把这些掌握后,你就能独立交付一个完整的 Compose App。