本文不是把视频教程的代码截图重新贴一遍,而是把 Jetpack Compose 中列表开发的完整思路拆给你看。你跟着文章写完一个 LazyColumn,基本就能迁移到项目里的任何列表场景。如果你最近在看 Compose 列表相关的中文讲解视频,但对LazyColumn、items、key这些概念还是一知半解,这篇文章可以帮你补上最后一块拼图。
很多人刚开始接触 Jetpack Compose 时,会觉得它和传统的 View 体系差别很大。尤其是列表,以前用 RecyclerView 加 Adapter 写习惯了,刚切到 Compose 时满脑子都是问题:Adapter 去哪里了?ViewHolder 还要不要写?列表项复用是怎么实现的?带着这些问题看视频教程,往往看懂了 Demo,回到自己的项目里还是写不出来。这篇文章会用中文把列表这块的原理、写法、性能陷阱和排查思路完整讲一遍,代码全部可以直接复制运行。
1. 为什么列表是 Compose 入门的第一道坎
Jetpack Compose 是 Android 官方推出的声明式 UI 框架,核心思想是“UI 状态驱动”,也就是界面完全由状态决定。写界面的时候不再是一步步findViewById然后手动设置内容,而是描述“状态长什么样,界面就长什么样”。这种思路在静态界面、表单页面、详情页里非常顺畅,但一碰到列表,很多人会卡住。
原因很简单:列表是最接近真实业务场景的组件。聊天记录、商品列表、订单列表、消息通知、搜索结果,几乎每个 App 都有列表页面。在过去,RecyclerView + Adapter + ViewHolder 这套组合拳虽然繁琐,但至少已经被讲烂了。到了 Compose 时代,写法完全换了一套,却没有一个和 Adapter 直接对位的概念。
大家真正想搞清楚的事情其实只有几件:
- 列表怎么写?
- 数据变了界面能自动更新吗?
- 列表项很多的时候会不会卡?
- 点击、滑动、刷新这些交互怎么做?
- 和 RecyclerView 相比,哪个更好用?
这篇文章全部覆盖。对于已经在项目里用 Compose 写过几个静态页面的开发者,列表就是进入真实业务开发的第一道关卡;对于还在观望、简历上想写 Compose 的开发者,列表也是最适合拿来练手和理解声明式 UI 的切入点。
2. 列表核心概念:LazyColumn、LazyRow 与 items
2.1 LazyColumn:垂直列表
Compose 里做垂直滚动列表,首选组件是LazyColumn。它对应传统 Android 开发中 RecyclerView 的LinearLayoutManager垂直排列效果,但使用方式发生了根本变化。
LazyColumn { item { Text("第一行") } item { Text("第二行") } item { Text("第三行") } }LazyColumn最大特点是“懒加载”。所谓懒加载,不是指滚动时才加载,而是指只组合当前屏幕可见区域内的列表项。屏幕外的项不会被创建,滑出屏幕的项会被回收。这样可以承载成千上万条数据而不会一次性把所有 item 都绘制出来。
在 Compose 里,LazyColumn的作用域中提供了几个关键函数:
| 函数 | 作用 |
|---|---|
item | 添加单个列表项 |
items(count) | 添加固定数量列表项 |
items(list) | 根据 List 数据源列表项 |
itemsIndexed(list) | 根据 List 数据源列表项,并携带下标 |
stickyHeader | 添加粘性头部,类似分组列表的吸顶效果 |
2.2 LazyRow:水平列表
LazyRow是水平方向的滚动列表,和LazyColumn原理一致。常见的使用场景包括横向翻页的 Banner、标签栏、分类栏。
LazyRow { items(categories) { category -> Text( text = category, modifier = Modifier.padding(horizontal = 12.dp) ) } }2.3 items 与 LazyListScope
可以这样理解:LazyColumn { }的括号里是一个LazyListScope作用域,这个作用域决定了“我的列表由哪些部分组成”。item和items就是往这个作用域里添加内容的关键函数。
items(list)是日常开发中使用频次最高的一个函数,它接收一个 List,并自动为每个元素生成一个列表项。完整写法是:
@Composable fun MessageList(messages: List<Message>) { LazyColumn { items(messages) { message -> MessageItem(message) } } }需要注意的是,items(messages)这里的messages必须是稳定可遍历的集合,一般传入List、Array。如果你需要下标,可以使用itemsIndexed。
2.4 对比 RecyclerView:本质差异在哪里
RecyclerView 时代,我们写完列表通常要做三步:
- 编写 Adapter,重写
onCreateViewHolder、onBindViewHolder、getItemCount。 - 创建 ViewHolder,持有 item 布局中的 View 引用。
- 手动处理数据刷新,调用
notifyDataSetChanged()或DiffUtil。
Compose 里这些都不需要了。LazyColumn的声明式写法把布局和数据的绑定关系压缩到了一个items函数里,页面刷新变成了“状态变化后重新组合”的自动过程。
这里有个容易误会的点:很多人以为 Compose 取消了列表复用。实际上不是。Compose 内部仍然有类似 ViewHolder 的复用机制,只是不再需要开发者手动维护。key就是控制复用行为的核心参数,后面会重点讲。
3. 环境准备:跑通第一个 Compose 列表项目
3.1 开发工具与版本
开发 Compose 项目,推荐使用 Android Studio。具体版本没有固定要求,但建议使用较新的稳定版本,因为 Compose 插件和构建工具一直在迭代。文章中的演示代码只使用核心 API,不依赖特殊版本,在主流 Compose 版本下都能运行。
创建项目时需要注意一点:如果选择 Empty Activity(不是 Empty Compose Activity),需要手动添加 Compose 相关配置。最简单的方式是创建项目时直接选择Empty Activity,然后在模块的build.gradle.kts里开启 Compose 支持。
3.2 确认核心依赖
在app/build.gradle.kts中,确认以下依赖存在:
android { buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } } dependencies { implementation(platform("androidx.compose:compose-bom:2024.06.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.activity:activity-compose:1.9.0") }BOM(Bill of Materials)的作用是统一管理 Compose 相关库的版本,避免手动写一堆互相兼容的版本号。如果你的项目网络环境正常,同步后就能直接使用 Compose API。
3.3 最小运行验证
先写一个最简单的 LazyColumn,验证环境是否正常:
package com.example.listdemo import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.ui.Modifier class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { LazyColumn( modifier = Modifier.fillMaxSize() ) { items(100) { index -> Text(text = "第 $index 行") } } } } }这段代码运行后,屏幕会出现一个可以滚动的列表,包含 100 个“第 X 行”文本项。注意这里的items(100) { index -> }是数量版items,不需要集合,直接生成固定数量的列表项。如果你看到列表能滚动,说明 Compose 环境已经正常。
4. 数据源准备与列表项设计
4.1 准备一个真实感的数据模型
Demo 里只用 Text 看不出列表的完整能力。我们定义一个接近真实业务的数据模型,模拟一个简单的商品列表。
data class Product( val id: Int, val name: String, val price: String, val tag: String ) fun generateProducts(): List<Product> { return List(50) { index -> Product( id = index, name = "商品${index + 1}", price = "¥${(index + 1) * 10}", tag = if (index % 2 == 0) "热卖" else "新品" ) } }4.2 列表项 UI 拆分
真实项目中列表项往往是一个独立的 Composable 函数。拆分的好处是结构清晰,方便复用,也方便后续对单个 item 做预览(@Preview)。
@Composable fun ProductItem(product: Product) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(56.dp) .background(Color(0xFFE3F2FD), shape = RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center ) { Text( text = product.name.take(1), color = Color(0xFF1565C0), fontWeight = FontWeight.Bold ) } Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = product.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) Text( text = product.tag, style = MaterialTheme.typography.bodySmall, color = Color(0xFF757575) ) } Spacer(modifier = Modifier.width(12.dp)) Text( text = product.price, style = MaterialTheme.typography.titleMedium, color = Color(0xFFE53935) ) } }这段代码展示了一个典型列表项的三种信息布局:左侧图片占位区、中间主标题加副标题、右侧价格。这种“左中右”结构在移动端列表里非常常见,也是面试时最容易被拿来问你 Compose 基本功的例子。
4.3 把数据源接入 LazyColumn
有了数据模型和列表项 UI,剩下的组装就非常简单了:
@Composable fun ProductList(products: List<Product>) { LazyColumn { items( items = products, key = { product -> product.id } ) { product -> ProductItem(product) } } }注意这里的key参数。它告诉 Compose:“这个列表项的唯一标识是product.id”。当列表数据发生增删、排序时,Compose 可以根据 key 精确定位哪些 item 移动了、哪些是新插入的、哪些需要回收,避免整个列表重新组合。
5. 完整示例:带点击、分割线和空状态的列表
如果把前面几部分整合起来,一个接近真实业务场景的 Compose 列表页面长这样。
5.1 主界面代码
@Composable fun ProductListPage() { val products = remember { generateProducts() } var selectedProductName by remember { mutableStateOf("") } val snackbarHostState = remember { SnackbarHostState() } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { Text( text = "商品列表", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp) ) LazyColumn(modifier = Modifier.weight(1f)) { // 顶部加入一个 Header item { Box( modifier = Modifier .fillMaxWidth() .background(Color(0xFFFFF3E0)) .padding(16.dp) ) { Text( text = "今日推荐 ${products.size} 件商品", color = Color(0xFFE65100) ) } } items( items = products, key = { it.id } ) { product -> ProductItem(product = product) HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp, color = Color(0xFFEEEEEE) ) } } } } }5.2 给列表项添加点击事件
只有展示的列表不够,真实业务中每个 item 都要响应点击。修改一下ProductItem:
@Composable fun ProductItem( product: Product, onClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { // 省略左侧占位、中间信息、右侧价格代码,和上节保持一致 } }调用处:
ProductItem( product = product, onClick = { selectedProductName = product.name scope.launch { snackbarHostState.showSnackbar("点击了 ${product.name}") } } )5.3 空状态处理
列表页经常会遇到数据为空的情况。空状态不应该展示一个空白屏,而是给出用户可感知的提示。可以在LazyColumn内部或外部做条件判断:
@Composable fun ProductListPage(products: List<Product>) { if (products.isEmpty()) { EmptyView(message = "暂无商品") } else { LazyColumn { items(products, key = { it.id }) { product -> ProductItem(product = product) } } } } @Composable fun EmptyView(message: String) { Box( modifier = Modifier .fillMaxSize() .padding(32.dp), contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = "数据为空", style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(8.dp)) Text( text = message, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF9E9E9E) ) } } }到这里,你已经拥有了一个可以展示数据、处理点击、显示空状态、带分割线的完整 Compose 列表页面。
6. 运行结果与验证方式
6.1 如何运行
用 Android Studio 打开项目,连接模拟器或真机,点击 Run 按钮。运行时需要注意:
- 模拟器建议使用 API 30 及以上版本,兼容性更好。
- 真机调试需要开启开发者选项和 USB 调试。
6.2 预期效果
运行后,页面顶部显示“今日推荐 50 件商品”,下面是可以滚动的商品列表。每项左侧是一个带首字母的色块,中间是商品名和标签,右侧是价格。点击任意一行,底部出现 Snackbar,显示点击了哪个商品。
滑动列表时,LazyColumn的表现应该是流畅的。如果出现明显的卡顿或抖动,大概率是下面这些原因:
- 列表项中执行了耗时操作,比如频繁的磁盘读取或复杂计算。
- 列表项布局层级过深,导致测量耗时。
- 没有设置
key,数据变化时整个列表发生了不必要的重组。 - 在
items作用域内直接声明了非稳定的可变数据。
6.3 如何判断列表真的“懒加载”
可以用一个简单的方法验证:在ProductItem内部打印日志。
@Composable fun ProductItem(product: Product) { Log.d("LazyColumn", "组合了商品:${product.name}") // 其余 UI 代码 }运行后观察 Logcat。你会发现并不是 50 条数据全部打日志,而是只组合了屏幕可见范围内的几个 item。往下滑动时,新的 item 才被组合。这就是懒加载的直接证据。
7. 列表开发中的常见问题与排查方法
7.1 问题排查表
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 列表不显示数据 | 数据源为空 | 检查传入items的 List 是否为空 | 增加空状态判断或检查数据加载逻辑 |
| 滑动后 item 内容错乱 | 没有给 item 设置 key,或 key 不稳定 | 检查items的 key 参数 | 使用唯一的稳定 id 作为 key |
| 列表整体卡顿 | item 内有耗时操作或布局层级过深 | Systrace 或 Log 打点观察组合耗时 | 将耗时操作移出组合阶段,简化布局 |
| 列表项点击没有响应 | 点击事件被父容器拦截 | 检查父级是否消费了触摸事件 | 使用clickable并确保事件未被父级消费 |
| 更新数据后界面不刷新 | 数据源不是可变状态 | 检查数据是否用mutableStateListOf持有 | 使用mutableStateListOf或触发重组 |
| 嵌套滚动体验异常 | 垂直方向嵌套了两个 LazyColumn | 检查布局中是否存在多层滚动容器 | 改为单列表或根据滚动方向调整布局 |
7.2 为什么 key 这么重要
key是 Compose 列表里最容易忽视却影响最大的参数。没有 key 时,Compose 按位置识别列表项。当数据插入或删除,位置变化会导致后续所有 item 被错误复用。举例来说:
数据是[A, B, C, D],删掉 B 后变成[A, C, D]。没有 key 的情况下,Compose 可能把原本的 C 复用到新的第二行,虽然没有视觉错误,但 C 的局部状态会被错误地保留或重置。
设置了key = { it.id }后,Compose 可以精确知道“B 被移除了,C 和 D 保持原样”。这能显著减少不必要的重组。
items( items = products, key = { it.id } ) { product -> ProductItem(product) }key 的选择规则是:使用数据中真正唯一的字段。不要用下标 index,因为列表项移动后 index 会变;也不要用 toString() 这种可能重复的字段。
7.3 mutableStateListOf 与数据刷新
Compose 中列表数据如果只是普通的List,修改后界面不会自动更新。应该使用可观察的集合:
val products = remember { mutableStateListOf<Product>() } // 在某个事件中添加数据 products.add(newProduct) // 或删除 products.removeAt(0)mutableStateListOf内部的数据变更会自动触发重组。这一点和传统 View 体系中的notifyDataSetChanged类似,但 Compose 的粒度更细:只有受影响的位置会重新组合。
8. 列表性能优化与工程最佳实践
8.1 列表项要尽量独立
一个ProductItem最好只负责“展示一个 Product”,不要在 item 内部写网络请求、数据库查询、复杂计算。这些逻辑应该放在 ViewModel 或数据层,UI 层只做状态渲染。
8.2 使用 stable 数据类型
Compose 编译器在判断是否需要重组时,会检查函数参数是否 stable。如果你自定义的数据类包含很多可变字段,Compose 可能认为它不稳定,从而在每次重组时都重新执行整个 item 的代码。
最简单的做法是:数据类尽量使用val定义不可变字段。
data class Product( val id: Int, val name: String, val price: String, val tag: String )8.3 避免没有必要的嵌套滚动
不要在 LazyColumn 中嵌套同向的另一个 LazyColumn。这会造成滚动事件冲突,也很难保证性能。如果需要多类型列表,可以用items的多个重载,或者用key区分类型。
8.4 合理使用 animateItem
Compose 在较新版本中提供了animateItem()修饰符,可以让 item 在增删、移动时播放动画。体验很好,但注意不要对数据量极大的列表默认启用所有动画,否则可能造成动画帧率下降。
8.5 延迟初始化与占位符
对于数据需要从网络加载的列表,建议在数据未完成加载时显示骨架屏(Skeleton),而不是直接显示空白。骨架屏本质上也是列表项的一种,只是内容用占位色块代替。
8.6 正确处理分页加载
LazyColumn支持通过rememberLazyListState()监听滚动位置,从而在接近底部时触发加载更多。
val listState = rememberLazyListState() val shouldLoadMore by remember { derivedStateOf { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() lastVisibleItem?.index != null && lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 3 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { viewModel.loadMore() } } LazyColumn( state = listState ) { items(products, key = { it.id }) { product -> ProductItem(product) } if (viewModel.isLoading) { item { LoadingItem() } } }这段代码的含义是:当最后一个可见 item 的 index 距离总数量不到 3 时,触发加载更多。这个模式对应 RecyclerView 中的onScrollStateChanged加载更多逻辑,但实现起来更简洁。
9. 从列表到完整业务:接下来怎么进阶
列表写完之后,建议继续往下探索这几个方向:
第一个方向是多类型列表项。现在items只接收一种类型的数据。如果列表里既有 Banner 又有商品卡片还有广告位,可以先用 sealed class 封装列表项类型,再通过when在LazyColumn中分别渲染。这是目前 Compose 处理复杂列表的推荐方式,也是很多项目里的实际需求。
第二个方向是下拉刷新。官方推荐的实现是PullToRefreshBox(Material3 中可用),可以配合rememberPullToRefreshState或者第三方库。把刷新状态和数据加载状态统一管理后,列表体验会完整不少。
第三个方向是搜索和过滤。列表数据来自用户输入时,需要处理防抖、空结果、加载态等多个状态。这个场景能更好地理解 Compose 的“状态驱动 UI”理念。
第四个方向是列表与 ViewModel 的结合。真实项目中列表页会引入 ViewModel、Repository、网络层。数据从StateFlow中收集,然后在 Composable 中做状态转换。这个模式基本是现在 Compose 项目的标准架构。
回到最初的问题:学习 Jetpack Compose 列表,重点不是记住 API,而是理解“声明式UI”如何改变我们组织界面的方式。RecyclerView 时代,我们在数据、Adapter、布局文件之间来回切换;Compose 时代,数据、布局、交互都在同一个 Composable 函数里直接表达。刚开始可能不习惯,但一旦熟悉了LazyColumn、items、key这套组合,你会觉得列表开发比以前更直接。
如果你手头有项目正在迁移到 Compose,建议不要一次性重写所有页面。先从一个最简单的列表页开始,跑通数据加载、点击、刷新这三个核心链路,确认团队已经理解了声明式写法,再逐步扩展。列表永远是移动端 App 的最高频页面,把列表吃透,Compose 就等于学会了一半。