
本文不是把视频教程的代码截图重新贴一遍而是把 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 与 items2.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: ListMessage) { 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) }BOMBill 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(): ListProduct { 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: ListProduct) { 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 都要响应点击。修改一下ProductItemComposable 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: ListProduct) { 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 { mutableStateListOfProduct() } // 在某个事件中添加数据 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 合理使用 animateItemCompose 在较新版本中提供了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 处理复杂列表的推荐方式也是很多项目里的实际需求。第二个方向是下拉刷新。官方推荐的实现是PullToRefreshBoxMaterial3 中可用可以配合rememberPullToRefreshState或者第三方库。把刷新状态和数据加载状态统一管理后列表体验会完整不少。第三个方向是搜索和过滤。列表数据来自用户输入时需要处理防抖、空结果、加载态等多个状态。这个场景能更好地理解 Compose 的“状态驱动 UI”理念。第四个方向是列表与 ViewModel 的结合。真实项目中列表页会引入 ViewModel、Repository、网络层。数据从StateFlow中收集然后在 Composable 中做状态转换。这个模式基本是现在 Compose 项目的标准架构。回到最初的问题学习 Jetpack Compose 列表重点不是记住 API而是理解“声明式UI”如何改变我们组织界面的方式。RecyclerView 时代我们在数据、Adapter、布局文件之间来回切换Compose 时代数据、布局、交互都在同一个 Composable 函数里直接表达。刚开始可能不习惯但一旦熟悉了LazyColumn、items、key这套组合你会觉得列表开发比以前更直接。如果你手头有项目正在迁移到 Compose建议不要一次性重写所有页面。先从一个最简单的列表页开始跑通数据加载、点击、刷新这三个核心链路确认团队已经理解了声明式写法再逐步扩展。列表永远是移动端 App 的最高频页面把列表吃透Compose 就等于学会了一半。