Jetpack Compose 列表开发全攻略:LazyColumn 核心用法与性能优化 📅 发布时间:2026/8/31 1:57:39 👁 浏览次数: 做安卓开发的只要从传统 View 体系往 Jetpack Compose 迁移第一个绕不开的硬骨头就是列表。RecyclerView 时代我们习惯了 Adapter、ViewHolder、LayoutManager 这一整套模版代码到了 Compose 里发现这些东西全没了取而代之的是 LazyColumn、LazyRow 这类声明式 API。初次接触会有点不适应但只要理解了状态驱动 懒加载组合这套思路列表开发效率反而会提高不少。这次我们来看 Jetpack Compose 中列表开发的完整用法包括 LazyColumn / LazyRow 的基础写法、items / item 的区别、多样式条目、分割线、header / footer、滚动状态控制、下拉刷新、分页加载以及性能优化。文章会按照API 讲解 - 代码示例 - 性能与排错的顺序来写适合正在学习 Compose 的开发者也适合刚从 RecyclerView 转过来的同学。1. 核心能力速览能力项说明核心组件LazyColumn垂直列表、LazyRow水平列表、LazyVerticalGrid / LazyHorizontalGrid网格编程范式Kotlin DSL 声明式 UI无需 Adapter / ViewHolder数据加载仅组合可见区域 item天然支持大型列表条目 APIitem、items、itemsIndexed、items(count)多样式可在 LazyListScope 中按类型写多个 item 块滚动控制rememberLazyListState animateScrollToItem / scrollToItem状态监听snapshotFlow 监听滚动位置、LayoutInfo 获取可见条目刷新加载配合 pullRefresh 或第三方库实现下拉刷新与分页依赖要求compose-bom foundation material3Android Studio 建议使用较新版本适用场景消息列表、商品列表、好友列表、信息流、设置页、日历网格等从表格可以看出Compose 列表的能力覆盖了 RecyclerView 的绝大多数场景而且写法更加集中。不过要注意一点LazyColumn 并不是直接把 RecyclerView 的 API 翻译成 Kotlin它的组合模型和生命周期逻辑需要重新理解。2. 适用场景与使用边界2.1 适合哪些场景Jetpack Compose 列表适合绝大多数移动端列表场景尤其是数据量不确定、条目类型多样、需要频繁更新数据的界面。它把数据的声明、布局、点击事件、状态管理全部收敛到一个 Composable 函数里代码可读性比 RecyclerView Adapter 更直观。比如一个典型的消息列表只需要定义 List 然后在 LazyColumn 的 items 闭包里写清楚每个条目怎么渲染剩下的滑动回收和组合生命周期交给 Compose 处理。2.2 不适合哪些场景这里要明确一个边界Compose 列表虽然方便但并不是所有场景都应该无脑使用。如果你的项目还在用传统 View 体系只为了某一个列表引入 Compose成本和收益不一定成正比因为 Compose 的依赖和构建配置会拉大项目体积。另一个场景是超复杂嵌套滚动比如多层嵌套的滑动容器、嵌套可滚动列表Compose 处理起来需要仔细设计性能上很容易踩坑。最后对低端机兼容有严格要求、需要极致滑动帧率的场景传统 RecyclerView 的成熟优化手段仍然更可控。2.3 版权与合规说明列表数据来自网络接口时要注意接口数据的版权和使用范围。如果是用户生成内容需要做好内容审核与隐私保护。文本、图片、视频等素材的加载要遵循服务条款。本文中的代码示例仅用于本地功能验证接入真实项目时请按自己的实际数据源和业务场景进行合规审查。3. 环境准备与前置条件3.1 IDE 与 Android SDK开始前先保证 Android Studio 已经装好建议使用较新的稳定版本并且安装了对应版本的 Android SDK。不需要特殊硬件普通开发机能编译运行即可。模拟器和真机都行真机测试列表滑动的流畅度会更准确。3.2 创建支持 Compose 的项目在 Android Studio 中新建项目时选择 Empty Activity 模板并在配置页面勾选 Compose 支持。如果你的项目是旧的 View 工程需要手动开启 Composeandroid { buildFeatures { compose true } } // 如果项目还在用 Kotlin 2.0 之前的 Compose 编译器配置 // 还需要配置 composeOptions 里的 kotlinCompilerExtensionVersion。 // 从 Kotlin 2.0 开始推荐使用 compose-compiler-gradle-plugin 插件。需要说明的是Kotlin 2.0 之后 Compose Compiler 已经跟随 Kotlin 版本发布不需要再单独指定 kotlinCompilerExtensionVersion。具体插件配置方式要根据你的项目版本决定这里给的是通用模板。3.3 添加依赖在 build.gradle.kts 中添加 Compose BOM 和相关依赖dependencies { implementation(platform(androidx.compose:compose-bom:2024.09.00)) implementation(androidx.compose.ui:ui) implementation(androidx.compose.material3:material3) implementation(androidx.compose.foundation:foundation) implementation(androidx.compose.ui:ui-tooling-preview) debugImplementation(androidx.compose.ui:ui-tooling) }Compose BOM 的作用是统一管理 Compose 相关库的版本不指定具体依赖版本不容易出现版本冲突。如果你看到这里写的 BOM 版本号和你的项目不一致直接换成你当前工程的版本即可核心 API 差别不大。4. 列表核心 APILazyColumn 与 LazyRow 基础用法4.1 LazyColumn 最简示例传统 RecyclerView 需要先写 Adapter、ViewHolder、LayoutManager然后在 Activity / Fragment 里装配。Compose 里一个能跑的列表只需要几十行代码// 数据源 private val userList (1..100).map { 用户 $it } Composable fun SimpleListScreen() { LazyColumn { items(userList) { user - Text( text user, modifier Modifier .fillMaxWidth() .padding(16.dp) ) } } }这里 LazyColumn 负责纵向滚动的懒加载列表items 则是 LazyListScope 的扩展函数把 List 数据映射到列表条目。items 的第一个参数是数据集合第二个参数是每个条目对应的 Composable 渲染内容。这个 List 可以理解成一个延迟组合的列表容器它只组合当前屏幕可见的 item滑出去的 item 对应的组合状态会被释放这一点和 RecyclerView 的回收复用思路不同但目的是一样的控制内存和组合范围。4.2 LazyRow 横向列表横向列表用法几乎一样只需要把 LazyColumn 换成 LazyRowComposable fun HorizontalTagRow(tags: ListString) { LazyRow( contentPadding PaddingValues(horizontal 16.dp), horizontalArrangement Arrangement.spacedBy(8.dp) ) { items(tags) { tag - Surface( shape RoundedCornerShape(8.dp), color MaterialTheme.colorScheme.secondaryContainer ) { Text( text tag, modifier Modifier.padding(horizontal 12.dp, vertical 6.dp) ) } } } }contentPadding 用来控制整个列表的边距horizontalArrangement 可以设置条目之间的间距这在很多 UI 里比在每个 item 上单独加 padding 要方便得多。4.3 items 的多种重载items 不只是能接收 List它还有好几种重载形式适合不同场景。第一种接收 List 并带 keydata class Product(val id: Int, val name: String) items( items productList, key { product - product.id } ) { product - ProductItem(product) }第二种使用 itemsIndexed 拿到下标itemsIndexed( items list, key { index, _ - index } ) { index, item - Text($index: $item) }第三种使用 items(count) 直接生成指定数量的条目适合不需要真实数据集合的场景比如骨架屏items(count 10) { index - SkeletonItem(index) }4.4 item 添加单个条目除了列表数据LazyColumn 里还可以直接插入单个条目比如一个列表头部的 Banner 或者底部的加载中提示LazyColumn { item { BannerView() } items(productList) { product - ProductItem(product) } item { LoadingFooter() } }item 和 items 可以在同一个 LazyListScope 中混用LazyColumn 会自动按声明顺序组合这些条目。这个设计非常灵活想要在列表中间插入一个广告位或者模块入口直接在对应位置加一个 item 块即可不需要搞多类型 Adapter。5. 列表条目进阶多样式 item、header / footer 与分割线5.1 多样式条目写法在 RecyclerView 中多类型条目需要根据 getItemViewType 返回不同类型然后 onBindViewHolder 里做 if / else 分支整套逻辑比较繁琐。Compose 里最简单的方式就是用多个 item 块Composable fun MultiTypeList() { LazyColumn { item(key banner) { BannerView() } items( items feedList, key { it.id }, contentType { it.type } ) { feed - when (feed.type) { FeedType.IMAGE - ImageFeedItem(feed) FeedType.VIDEO - VideoFeedItem(feed) FeedType.TEXT - TextFeedItem(feed) } } } }contentType 参数也值得注意。它不直接控制 UI而是告诉 Compose 这一类条目在滚动复用时的类型标识不同类型的条目不建议复用组合状态这样能减少不必要的重组工作。给 contentType 传一个稳定分类值对复杂列表性能有实际帮助。5.2 Header 与 Footerheader 和 footer 本质上就是上面提到的 item 块。如果业务上经常需要可以封装一个小函数fun LazyListScope.header(title: String) { item(key header_$title) { Text( text title, style MaterialTheme.typography.titleMedium, modifier Modifier.padding(16.dp) ) } }这种封装的好处是每个 item 块都有明确的 key后续做状态保存和动画更新时不会出现错位问题。5.3 分割线分割线是列表里最常见的需求。最直接的方式是在 item 的 Modifier 上加一个底部边框或者用 Compose Material 提供的 HorizontalDividerLazyColumn { itemsIndexed(userList) { index, user - Column( modifier Modifier .fillMaxWidth() .padding(16.dp) ) { Text(user.name) Text(user.avatar) } // 最后一个条目不显示分割线 if (index ! userList.lastIndex) { HorizontalDivider() } } }HorizontalDivider 在 material3 里是以前 Divider 的替代组件。如果你用的是旧版本 material那对应的组件可能是 Divider具体看项目依赖版本。5.4 Sticky Header 吸顶效果如果要实现类似手机通讯录那样的字母吸顶头可以用 LazyColumn 的 stickyHeaderOptIn(ExperimentalFoundationApi::class) Composable fun ContactsList(contacts: ListContact) { val grouped contacts.groupBy { it.name.first().uppercase() } LazyColumn { grouped.forEach { (letter, contactList) - stickyHeader { Text( text letter, modifier Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant) .padding(8.dp) ) } items(contactList, key { it.id }) { contact - ContactRow(contact) } } } }stickyHeader 和 item 一样不过是 LazyListScope 中带吸顶能力的扩展。它目前仍然标记为 ExperimentalFoundationApi使用的时候需要加 OptIn 注解后续版本 API 可能会调整。6. 列表性能优化key、懒加载与避免重组6.1 为什么 key 很重要在 Compose 列表中key 的作用和 RecyclerView 里的稳定 ID 类似。如果没有为 item 指定稳定的 keyCompose 会使用 item 的位置来识别条目。一旦数据列表出现插入、删除或排序变化没有 key 的情况下 Compose 可能会错误地复用旧的组合状态导致界面显示错乱比如输入框内容错位、动画状态混乱。给数据加一个稳定唯一 id 是一种简单但收益很大的做法data class Task(val id: Long, val title: String, val done: Boolean) LazyColumn { items( items taskList, key { task - task.id } ) { task - TaskRow(task) } }6.2 避免条目内部重复重组列表滑动的流畅度不仅取决于 item 的数量还取决于每个条目重组时的开销。如果列表里每个条目都要读取一个会频繁变化的状态比如某个全局 ViewModel 里的时间戳那么整个列表都可能因为这个状态变化而反复重组。更稳妥的做法是缩小状态读取范围让状态尽量保持在 item 内部的局部作用域避免状态提升到列表层级后向下穿透。举一个常见的例子列表点赞效果的实现Composable fun FeedRow(item: Feed, onLikeChange: (Boolean) - Unit) { // 局部状态发生变化时只会重组 FeedRow不会影响整个列表 var liked by rememberSaveable { mutableStateOf(item.liked) } Row { Text(item.title) IconButton(onClick { liked !liked }) { Icon( imageVector if (liked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, contentDescription 点赞 ) } } }6.3 使用 derivedStateOf 避免无谓重组如果需要根据列表滚动状态显示一个回到顶部的按钮不要直接监听 state.firstVisibleItemIndex因为这个值在每次滚动时都会变化。更好的方案是用 derivedStateOf 派生出一个布尔值只有从不显示变成显示或从显示变成不显示时才发生重组val listState rememberLazyListState() val showBackToTop by remember { derivedStateOf { listState.firstVisibleItemIndex 10 } } LazyColumn(state listState) { items(1000) { index - Text(Item $index) } } AnimatedVisibility(visible showBackToTop) { FloatingActionButton( onClick { /* 回到顶部 */ } ) { Text(TOP) } }6.4 非组合型条目的优化如果列表里某些条目完全不包含状态只是一段纯展示内容可以考虑把它标记为不参与重组检查的稳定内容。Compose 中可以使用高内聚的 Composable 函数封装它们而不是在同一个 Composable 中写大量参数。当然最有效的手段还是数据侧优化比如减少每页加载的数据量、避免超大图片、使用懒加载图片库。7. 滚动状态管理与监听7.1 rememberLazyListStaterememberLazyListState 是列表滑动状态的载体。创建 LazyColumn 时把 state 传进去之后就能在任意地方读取当前滚动位置Composable fun ScrollPositionScreen() { val listState rememberLazyListState() Column { Text(当前可见第一个条目: $listState.firstVisibleItemIndex) LazyColumn(state listState, modifier Modifier.weight(1f)) { items(100) { index - Text(Item $index) } } } }7.2 滚动到指定位置通过 LaunchedEffect 和 snapshotFlow 可以在特定时机触发滚动。比如进入页面后默认滚动到中间的某个位置Composable fun AutoScrollScreen() { val listState rememberLazyListState() LaunchedEffect(Unit) { listState.scrollToItem(50) } LazyColumn(state listState) { items(100) { index - Text(Item $index) } } }scrollToItem 是瞬时定位animateScrollToItem 是带平滑动画的定位。常规 UI 交互里animateScrollToItem 体验更好val coroutineScope rememberCoroutineScope() Button(onClick { coroutineScope.launch { listState.animateScrollToItem(0) } }) { Text(回到顶部) }7.3 监听滚动方向使用 snapshotFlow 可以观察列表状态的变化比如判断用户是正在向上还是向下滚动val listState rememberLazyListState() LaunchedEffect(listState) { var previousIndex listState.firstVisibleItemIndex snapshotFlow { listState.firstVisibleItemIndex } .collect { currentIndex - if (currentIndex previousIndex) { // 向下滚动 } else if (currentIndex previousIndex) { // 向上滚动 } previousIndex currentIndex } }snapshotFlow 是 Compose 中把状态转换为 Flow 的桥梁。凡是 remember 创建的状态都可以用 snapshotFlow 包裹起来观察变化这比自定义回调要干净得多。7.4 获取当前可见条目信息如果要实现类似信息流中视频自动播放的效果需要拿到当前完全可见的条目索引可以用 listState.layoutInfo。layoutInfo.visibleItemsInfo 包含当前所有可见条目的信息比如 index、offset、sizeval visibleItems listState.layoutInfo.visibleItemsInfo // 找到最接近屏幕中心的条目 val centerIndex visibleItems .minByOrNull { abs(it.offset it.size / 2 - listState.layoutInfo.viewportEndOffset / 2) } ?.index这种写法在实现视频自动播放、懒加载图片曝光统计、广告曝光统计时经常用到。需要说明的是layoutInfo 的读取要放在 snapshotFlow 或 derivedStateOf 中直接读取只能在当前组合帧生效。8. 下拉刷新与分页加载8.1 下拉刷新Material3 中实现下拉刷新推荐使用 PullToRefreshBox 组件。这是 material3 compose 里比较新的 API会替代旧的 Material 组件 pullRefreshOptIn(ExperimentalMaterial3Api::class) Composable fun RefreshableList(onRefresh: suspend () - Unit) { var isRefreshing by remember { mutableStateOf(false) } PullToRefreshBox( isRefreshing isRefreshing, onRefresh { // 这里网络请求 } ) { LazyColumn { items(50) { index - Text(数据 $index) } } } }如果项目用的 material3 版本还没有 PullToRefreshBox可以回退到 material 里的 pullRefresh 实现或者使用第三方库。这部分要按项目实际版本来选择不需要在项目初始阶段就追求最新 API。8.2 分页加载分页加载的思路是监听列表滚动位置当滚动到接近底部时触发下一页请求。核心代码如下val listState rememberLazyListState() val shouldLoadMore by remember { derivedStateOf { val lastVisibleIndex listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalCount listState.layoutInfo.totalItemsCount lastVisibleIndex totalCount - 3 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore !isLoading) { loadNextPage() } }这里的关键是 lastVisibleIndex 和 totalCount 都是 listState 派生出来的信息它们会在滚动时自动更新。当最后可见条目接近列表末尾时shouldLoadMore 变成 trueLaunchedEffect 就会触发一次加载。实际项目中分页状态通常会放到 ViewModel 里class FeedViewModel : ViewModel() { var feedList by mutableStateOf(listOfFeed()) private set var isLoading by mutableStateOf(false) private set var hasMore by mutableStateOf(true) private set fun loadNextPage() { if (isLoading || !hasMore) return isLoading true // 调用仓库层加载下一页 // 成功后追加到 feedList更新 hasMore isLoading false } }8.3 加载状态 footer分页加载时列表底部通常要显示一个加载中或没有更多了的状态LazyColumn { items(feedList, key { it.id }) { feed - FeedItem(feed) } if (viewModel.isLoading) { item(key loading_footer) { CircularProgressIndicator(modifier Modifier.padding(16.dp)) } } else if (!viewModel.hasMore) { item(key no_more_footer) { Text( text 没有更多了, modifier Modifier .fillMaxWidth() .padding(16.dp), textAlign TextAlign.Center ) } } }给 footer 设置固定的 key 也很关键否则当数据列表长度变化时footer 的组合状态可能被错误复用。9. 常见问题与排查方法问题现象可能原因排查方式解决方案列表滑动卡顿item 内部状态读取范围过大、图片未压缩使用 CPU Profiler 或 Compose 重组计数缩小状态读取范围使用图片懒加载库列表条目位置错乱没有设置稳定 key查看 key 与数据 id 是否稳定给 items 传入 key 参数点击 item 后界面不刷新数据集合变化是无序的检查数据源类型是 MutableList 还是不可变 List使用 immutable 列表 mutableStateOf 包裹下拉刷新不触发滚动容器被嵌套检查是否有父级滚动冲突调整嵌套滚动策略或将刷新组件放到正确的层级分页重复加载shouldLoadMore 状态没有防抖检查 LaunchedEffect 和 isLoading 判断增加 isLoading / hasMore 前置判断LazyColumn 嵌套 LazyColumn 崩溃垂直方向嵌套同向滚动列表查看异常栈中的 Vertically scrollable component 信息使用平铺的 LazyListScope 或嵌套不同类型的列表stickyHeader 编译报错缺少 OptIn 注解查看编译日志中的 Experimental 提示添加 OptIn(ExperimentalFoundationApi::class)列表首次进入滚动位置丢失没有保存 LazyListState检查是否用了 rememberSaveable使用 rememberSaveable LazyListState.Saver9.1 数据集合变化不刷新这是新手最常见的问题。Compose 列表不会自动感知 List 内部元素的修改必须保证数据集合本身是一个 State。如果 ViewModel 里定义的是普通 MutableList然后直接 add 数据界面不会更新。正确做法是var items by mutableStateOf(listOfItem()) private set fun addItem(item: Item) { items items item }每次赋值都生成一个新的 ListCompsoe 就能检测到 items 这个 State 发生了变化从而触发列表重组。9.2 嵌套滚动列表问题如果在一个可垂直滚动的 Column 中再放一个 LazyColumn会抛出 Vertically scrollable component was measured with an infinity maximum height constraints 异常。因为 LazyColumn 在测量时不知道父容器能给它多少高度就不知道该懒加载多少内容。解决方案是给 LazyColumn 限定一个确定的高度或者把外层容器改成 LazyColumn将内部列表的 items 合并到同一个 LazyListScope 中。10. 最佳实践与使用建议到这里整个 Compose 列表的核心内容就梳理得差不多了。下面结合真实项目经验给出一套落地建议。第一列表的数据结构尽量用不可变对象配合 immutable 集合使用。Compose 的相等性判断在不可变数据下更高效界面刷新更容易预测。第二凡是列表中的图片加载优先使用 Coil、Glide 的 Compose 扩展不要自己写加载逻辑。图片尺寸要在数据层控制好列表条目中的大图不要超过实际显示尺寸太多。第三为常用列表封装业务组件比如把列表 下拉刷新 分页 空态 错误态封装成一个通用组件。这样各业务页面可以统一交互和视觉表现也方便后续做性能优化。第四使用 Stable 注解处理复杂条目类型。Compose 编译器会生成稳定性判断如果 item 数据类经常携带不稳定参数会导致列表重组合优化不到位。给关键数据类型添加 Stable 注解能在一定程度上减少重组。第五不要把列表 State 提到 Activity 级别尽量放在 ViewModel 或列表页面内部。这样列表在销毁重建时可以更快恢复状态管控范围也更清晰。第六自动化测试要尽早覆盖列表核心逻辑。Compose 提供了 compose ui test junit4可以用 SemanticsNodeInteractions 对列表进行测试比如测试分页触发条件、列表条目长度、点击事件是否生效。第七关于性能调优没有通用的银弹一定要先看实际数据。先用 Profiler 确认是重组问题、布局问题还是图片加载问题再针对性处理。Compose 列表优化手段很多但盲目优化反而会引入新的复杂度。11. 下一步可以做什么看完这篇文章你至少应该能写出一个带数据源、分页、下拉刷新、多样式条目的 LazyColumn。这是 Compose 开发里最核心的基础能力。建议先做一个简单项目验证一下创建一个 App首页是 LazyColumn展示 100 条数据每个条目包含一个标题和一个图片加上分割线、加载更多、下拉刷新。跑通之后再尝试把列表子项换成不同类型的 Composable并用 key 保证数据更新不错位。最后加一个滚动到顶部的按钮结合 animateScrollToItem 体验一下平滑滚动。最容易踩的坑有两个一个是忘记设置 key导致列表更新时界面闪烁或错乱另一个是把所有数据集合都定义成普通 MutableList结果数据更新了页面不刷新。这两个问题只要理解了 State 和 key 的原理后面写任何 Compose 列表都会顺手很多。后续如果想继续深入可以看 LazyVerticalGrid 实现网格布局、stickyHeader 实现吸顶效果、嵌套滚动与手势处理以及列表动画相关 APIanimateItem 移动动画。这些都属于 Compose 列表范围内建议一次只研究一个主题每个主题写完都要落到自己的 Demo 项目里跑一遍。