A
第 47 章KOTLIN45 分钟

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.() -> Unit DSL 描述内容;
  • 直接子级是 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 用“位置索引”作为标识。如果列表中插入 / 删除一条,所有后续位置的“身份”都变了,导致:

  1. 重组过宽:本来没变的项目也被当作“可能变了”重新计算;
  2. 动画异常: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 一次读完。

常见坑与最佳实践

  1. 不写 key:列表动画异常、重组过宽、animateItemPlacement 失效。所有 items(...) 都加 key = { it.id }。
  2. key 用索引或可变值:key = { it.position }、key = { it.hashCode() } 都不稳定,用业务唯一 ID。
  3. 同方向嵌套 LazyColumn:编译器警告 + 高度测量爆炸。换成 Column + heightIn 或重写为单一 LazyColumn。
  4. Modifier.padding 替代 contentPadding:内容会被裁切到 padding 外,最后一条滚不到顶。列表用 contentPadding。
  5. 滚动监听里直接读 state:每帧触发父级重组,UI 卡顿。改用 derivedStateOf 或 snapshotFlow。
  6. LazyColumn 数据量小却用 Lazy:少于 30 项的简单列表,直接 Column + verticalScroll 性能更好(少一层 lazy 调度)。
  7. @Stable / @Immutable 用错:把有 var 字段的类标 @Immutable 会让重组判断错误,引发 UI 不更新。只有真不可变才加。
  8. stickyHeader 忘加 @OptIn(ExperimentalFoundationApi::class):API 还在实验阶段,必须显式 OptIn,否则编译失败。
  9. animateItemPlacement 没给 key:动画完全不生效,因为 Compose 不知道哪条移动了。
  10. 列表项内读全局 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。