
实际开发安卓列表时Jetpack Compose 带来的变化是明显的不需要再围绕 RecyclerView 的 Adapter、ViewHolder 和 notifyDataSetChanged 打转列表项从“被回收复用的 View”变成“由状态驱动的 Composable 函数”。在 Jetpack Compose 这个安卓声明式UI框架里列表是最能体现声明式开发威力的场景也是最容易出现“能跑但一加功能就乱套”的场景。这篇文章会从声明式列表的底层模型讲起依次完成 LazyColumn 基础列表、多类型列表项、滚动控制、加载更多、下拉刷新、空态和错误态最后给出性能优化和排错清单。适合已经有 Kotlin 基础、第一次用 Jetpack Compose 做完整列表的开发者。1. 先理解 Jetpack Compose 列表的声明式编程模型1.1 声明式 UI 与 RecyclerView 时代的差异在 RecyclerView 时代列表界面被拆成三部分数据集合、Adapter、ViewHolder。一次数据变化大约要经历“拿到新数据 - 修改数据源 - 调用 notifyDataSetChanged - Adapter 判断 position 范围 - 分配或回收 ViewHolder - 执行 bindViewHolder”这样一条链路。列表项复用的逻辑、增删改的动画、点击事件的绑定都分散在多个类里。列表逻辑一旦复杂Adapter 往往变成几十个方法堆积的地方。Jetpack Compose 的做法完全不同。开发者只需要描述“列表在当前状态下应该长什么样”系统负责在状态变化时找出前后差异并只更新发生变化的部分。放到列表场景里对应关系大致如下RecyclerView 时代的角色Compose 列表中的角色RecyclerViewLazyColumn、LazyVerticalGridAdapterLazyListScope 扩展函数例如 itemsViewHolder列表项 Composable 函数数据源变化State 变化触发重组notifyDataSetChanged自动 diff 和局部重组这里最容易误解的是“自动 diff”。Compose 并不是拿新数据和旧数据做一次深比较而是通过列表项的 key、输入的参数变化来判断哪个位置需要重建。换句话说列表项的稳定性和参数粒度直接决定了重组范围和性能。1.2 列表中的“状态 - 重组”工作链路Compose 列表的最小链路可以理解为三层数据层把列表数据放入 State例如mutableStateOf(listOf(...))。Composable 函数读取 State写成LazyColumn { items(list) { ... } }。数据变化时State 被框架标记为 dirty触发读取该 State 的 Composable 重组。看一个很小的例子var users by remember { mutableStateOf(listOf(Tom, Jerry, Alice)) } LazyColumn { items(users) { name - Text(text name) } }当users变成一个新集合时Compose 会重新执行LazyColumn的 content lambda比较新旧列表项索引对应的参数。如果只是索引 1 从Jerry变成Bob而 key 没有显式指定Compose 只能认为“索引 1 的内容变了”于是更新该位置。这在小例子里没问题但一旦 item 内部有输入框、有动画、有异步加载单靠 position 来做 diff 就会出现状态错位。所以理解声明式列表最重要的不是背 API而是记住列表项复用和更新的最小单位是 Composable 函数的输入参数和 key不是 ViewHolder 的 position。1.3 声明式列表解决什么问题没有解决什么问题声明式列表解决的是“状态与 UI 的同步”问题。数据变成 StateUI 自动响应不再需要手动维护“数据更新后要刷新哪一行”的逻辑。多类型列表、加载更多、下拉刷新本质上都变成对数据模型的状态切换代码更容易读也更容易测试。但它没有解决两个问题如果数据模型设计混乱每个 item 的参数过大Compose 每一次重组都会比较大量参数卡顿会重新出现。如果 key 设置不正确Diff 逻辑反而会引入比 RecyclerView 更隐蔽的复用问题例如输入框文字串行、item 动画错误。这也是下面所有章节都围绕“数据结构 key 状态”展开的原因。2. 环境和依赖准备用最少的配置把 Compose 列表跑起来2.1 工具版本和前置知识写 Compose 列表之前先确认开发环境满足基本要求工具作用建议Android Studio编写和调试 Kotlin、预览 Compose UI使用较新的稳定版自带 Compose 预览支持JDK编译 Kotlin 和 Android 代码建议使用项目要求的 JDK 17Android SDK编译目标版本建议与 AGP 要求的 compileSdk 对齐KotlinCompose 代码的宿主语言版本需要与 Compose 编译器插件匹配如果项目是从零开始推荐使用 Android Studio 自带的 “Empty Activity” 模板再手动加上 Compose 依赖。这样能减少很多初始化配置。2.2 Gradle 配置要点在app/build.gradle.kts中至少需要开启 Compose 编译开关并导入相关依赖。先看一个典型配置android { namespace com.example.composelist compileSdk 34 defaultConfig { applicationId com.example.composelist minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion 1.5.14 } kotlinOptions { jvmTarget 17 } } dependencies { implementation(platform(androidx.compose:compose-bom:2024.09.00)) implementation(androidx.compose.ui:ui) implementation(androidx.compose.material3:material3) implementation(androidx.compose.ui:ui-tooling-preview) debugImplementation(androidx.compose.ui:ui-tooling) implementation(androidx.activity:activity-compose:1.9.0) }这里有两个地方经常踩坑compose true没有开启Compose 依赖会无法编译报错提示往往不直观。Compose 编译器版本必须和 Kotlin 版本兼容。上面的kotlinCompilerExtensionVersion只是示例实际项目要先确认 Kotlin 版本再查对应的 Compose 编译器版本。如果项目使用较新的 Kotlin 2.0Compose 编译器已经合入 Kotlin 插件配置方式会不同。如果依赖版本不匹配常见现象是编译时报This version of the Compose Compiler requires Kotlin version X.Y.Z but you appear to be using Kotlin version A.B.C这时不要盲目升级依赖先按报错提示把 Kotlin 版本或 Compose 编译器版本对齐。2.3 最小项目结构和入口一个能运行 Compose 列表的最小项目只需要一个入口 Activity 和一个 Composable 函数class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ComposeListTheme { SimpleListScreen() } } } }这里setContent是 Compose 接入 Activity 的入口。后面的SimpleListScreen就是我们要写的列表页面。如果项目还没有主题文件可以先用 Material3 默认主题兜底避免因为主题资源缺失导致运行报错。3. 用 LazyColumn 实现一个基础列表3.1 最简单的 LazyColumn基础列表包含两个部分数据集合和列表项 Composable。假设有一个用户数据结构data class User( val id: String, val name: String, val avatarColor: Long )列表页可以写成Composable fun SimpleListScreen() { val users remember { List(20) { index - User( id user_$index, name 用户 $index, avatarColor 0xFF3F51B5 ) } } LazyColumn( modifier Modifier.fillMaxSize() ) { items(users) { user - UserListItem(user user) } } } Composable fun UserListItem(user: User) { Row( modifier Modifier .fillMaxWidth() .padding(horizontal 16.dp, vertical 12.dp), verticalAlignment Alignment.CenterVertically ) { Box( modifier Modifier .size(40.dp) .background(color Color(user.avatarColor), shape CircleShape), contentAlignment Alignment.Center ) { Text(text user.name.take(1)) } Spacer(modifier Modifier.width(12.dp)) Text(text user.name, style MaterialTheme.typography.bodyLarge) } }LazyColumn的核心特点是“懒加载”只在视口范围内组合可见的列表项。数据集合有 20 条屏幕上只能看到 6 条左右Compose 不会一次性创建 20 个 Composable 实例。这是它和直接写Column forEach的最大区别。3.2 item、items 和 itemsIndexed 的区别LazyColumn的 content 是一个LazyListScope常用方法有三个方法用途典型场景item { ... }添加一个固定 item标题、分割线、底部加载条items(list, key) { ... }遍历集合生成多个 item普通列表主体itemsIndexed(list, key) { index, item - ... }遍历时带索引需要根据位置控制展示逻辑item和items的区别很直接一个负责单条一个负责集合。不要用items(listOf(singleItem))来模拟item也不要在一个LazyColumn里用repeat加多个item直接使用items(list)更清晰也方便后续设置 key。3.3 列表项点击事件和参数传递列表项一般要接收数据和回调而不是直接把点击逻辑写在UserListItem内部。这样列表项更容易复用和测试Composable fun UserListItem( user: User, onClick: (User) - Unit, modifier: Modifier Modifier ) { Row( modifier modifier .fillMaxWidth() .clickable { onClick(user) } .padding(horizontal 16.dp, vertical 12.dp), verticalAlignment Alignment.CenterVertically ) { // 头像和文字 } }调用侧变成items(users) { user - UserListItem( user user, onClick { clickedUser - // 处理点击 } ) }点击事件以参数形式传入比直接在列表项内部写viewModel或全局回调更容易维护。3.4 contentPadding 与 Arrangement 控制边距和间距列表顶部往往要被状态栏遮挡底部还要给“加载更多”留出空间。直接给每个 item 加padding会让第一个 item 和最后一个 item 看起来特别拥挤。推荐使用contentPaddingLazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues( start 16.dp, end 16.dp, top 8.dp, bottom 80.dp ) ) { items(users) { user - UserListItem(user user) } }contentPadding是加在滚动内容外侧的内边距滚动到最后一条时底部会保留 80.dp 空间。item 之间的间距则用Arrangement.spacedByLazyColumn( modifier Modifier.fillMaxSize(), verticalArrangement Arrangement.spacedBy(8.dp) ) { ... }Arrangement.spacedBy会给相邻 item 之间增加固定间距。需要分隔线或更复杂的间距时再在 item 内部处理。4. 多类型列表项与 key 稳定性4.1 用 sealed class 建模列表项类型真实项目的列表很少只有一种 item。例如主列表可能包含 Header、普通内容、广告、加载状态。在 Compose 中推荐用sealed class描述所有可能的列表项sealed class ListItem { data class Header(val title: String) : ListItem() data class UserItem(val user: User) : ListItem() data class LoadingItem(val showRetry: Boolean) : ListItem() }这样描述列表数据时整个列表就是一个ListListItemitem 类型被明确编码在数据结构里。UI 层只需要按类型分发不需要再做复杂的类型判断。4.2 多类型 items 的写法使用when展开每种类型对应的 ComposableLazyColumn( modifier Modifier.fillMaxSize() ) { items(listItems) { item - when (item) { is ListItem.Header - HeaderItem(item.title) is ListItem.UserItem - UserListItem(item.user) is ListItem.LoadingItem - LoadingFooter(showRetry item.showRetry) } } }这种写法的优点是新增一种 item 类型时只需要新增一个数据类和一个when分支。不要把所有 item 合并成一个巨大的数据类然后用一堆if埋点式判断类型那种写法会让列表项之间的边界越来越模糊。4.3 key 和 contentType 对 Diff 的影响多类型列表出现后key的重要性立刻体现出来。Compose 默认按position比较 item但一旦列表头部插入或删除数据后续所有 index 都会变化原本稳定的 item 会被认为“位置变了”触发不必要的重组。给items指定keyitems( items listItems, key { item - when (item) { is ListItem.Header - header_${item.title} is ListItem.UserItem - user_${item.user.id} is ListItem.LoadingItem - loading } } ) { item - ... }key 的作用是给每个 item 一个稳定的身份。Compose 在做 diff 时会先比较 key相同 key 的 item 尽量复用状态。contentType则是告诉 Compose 某类 item 是否可以复用同一类型的 slot。当列表只有两种类型时可以写成items( items listItems, key { ... }, contentType { it::class.java.name } ) { item - ... }contentType返回值相同Compose 就知道这两个 item 可以走同一套组合缓存。注意key 必须稳定且唯一。不要用 index 当 key也不要用可能重复的 name 当 key。数据变化频繁的列表key 是防止串位和状态错乱的第一道防线。5. 滚动控制与加载更多5.1 rememberLazyListState 的滚动控制列表滚动位置保存在LazyListState中。通过rememberLazyListState()获取实例再传给LazyColumnval listState rememberLazyListState() val coroutineScope rememberCoroutineScope() LazyColumn( state listState, modifier Modifier.fillMaxSize() ) { ... } Button( onClick { coroutineScope.launch { listState.animateScrollToItem(0) } } ) { Text(回到顶部) }scrollToItem会直接跳到指定 indexanimateScrollToItem带滚动动画。回到顶部按钮通常用animateScrollToItem体验更好。5.2 在列表末尾自动加载更多分页加载的核心是判断“当前滚动位置是否接近末尾”。Compose 里的常见做法是配合derivedStateOf和snapshotFlow。先做判断逻辑val shouldLoadMore by remember { derivedStateOf { val layoutInfo listState.layoutInfo val lastVisibleItem layoutInfo.visibleItemsInfo.lastOrNull() ?: returnderivedStateOf false val totalCount layoutInfo.totalItemsCount lastVisibleItem.index totalCount - 3 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { onLoadMore() } }这段逻辑的含义是当最后可见 item 的 index 大于等于总数量减 3 时说明用户已经快滚到底部触发加载更多。预留 3 条是为了让请求提前发出避免用户看到底部空白等待。LaunchedEffect(shouldLoadMore)会在shouldLoadMore从false变成true时执行。如果加载后新的数据没有让shouldLoadMore变为false会重复触发所以数据层要保证加载结束后状态被重置。5.3 加载状态和失败重试加载更多不能只发请求还要在 UI 上展示“正在加载”“没有更多”“加载失败”。可以建模成枚举enum class LoadMoreState { Idle, Loading, Error, End }列表底部根据状态渲染不同 itemitems( items loadMoreItems, key { load_more } ) { loadMoreState - when (loadMoreState) { LoadMoreState.Loading - LoadingFooter(showRetry false) LoadMoreState.Error - LoadingFooter(showRetry true) LoadMoreState.End - EndFooter() LoadMoreState.Idle - {} } }loadMoreItems可以是只包含一个元素的ListLoadMoreState也可以直接使用item块item(key load_more) { when (loadMoreState) { LoadMoreState.Loading - LoadingFooter(showRetry false) LoadMoreState.Error - LoadingFooter(showRetry true, onRetry onRetry) LoadMoreState.End - Text(没有更多了) LoadMoreState.Idle - Unit } }加载失败时底部显示“重试”按钮点击后重新触发加载。注意重试逻辑要防止连续点击引发重复请求网络层和 ViewModel 都要做幂等控制。6. 下拉刷新、空状态和错误状态列表的完整状态机6.1 用 Material3 实现下拉刷新Material3 提供PullToRefreshBox可以把列表包在里面监听用户下拉手势OptIn(ExperimentalMaterial3Api::class) Composable fun RefreshableListScreen( isRefreshing: Boolean, onRefresh: () - Unit, content: Composable () - Unit ) { PullToRefreshBox( isRefreshing isRefreshing, onRefresh onRefresh, modifier Modifier.fillMaxSize() ) { content() } }不同版本的 Material3 中下拉刷新 API 命名可能不同。旧版本可能是pullRefresh和rememberPullRefreshState新版本推荐PullToRefreshBox。项目依赖确定后先以前缀搜索官方文档避免 API 写错。下拉刷新和“加载更多”不要互相冲突。刷新时会替换整个数据集合加载更多是在现有集合后面追加。两个状态要分开管理。6.2 让列表在 Loading、Empty、Error、Content 四态之间切换页面完整状态至少包含四种状态UI 展示Loading全屏加载框或骨架屏Empty空数据提示Error错误提示和重试按钮ContentLazyColumn 列表可以用一个密封类管理页面状态sealed class ListUiState { object Loading : ListUiState() object Empty : ListUiState() data class Error(val message: String) : ListUiState() data class Content(val items: ListListItem) : ListUiState() }列表页根据状态切换 UIwhen (val state uiState) { is ListUiState.Loading - LoadingScreen() is ListUiState.Empty - EmptyScreen(onRetry onRetry) is ListUiState.Error - ErrorScreen(message state.message, onRetry onRetry) is ListUiState.Content - LazyColumnContent(items state.items) }这个结构的好处是数据层只负责产生ListUiStateUI 层只负责按状态渲染。把“空数据”和“没有更多”分清楚空数据是列表本身没有内容没有更多是列表有内容但已经加载完所有数据。6.3 ViewModel 中的状态组合真实项目里列表逻辑一般放在 ViewModel 中。用一个简单的StateFlow暴露状态class UserListViewModel : ViewModel() { private val _uiState MutableStateFlowListUiState(ListUiState.Loading) val uiState: StateFlowListUiState _uiState.asStateFlow() fun loadData() { viewModelScope.launch { _uiState.value ListUiState.Loading runCatching { repository.getUsers() }.onSuccess { users - val items users.map { ListItem.UserItem(it) } _uiState.value if (items.isEmpty()) { ListUiState.Empty } else { ListUiState.Content(items) } }.onFailure { e - _uiState.value ListUiState.Error(e.message ?: 加载失败) } } } }Compose 侧用collectAsStateWithLifecycle收集状态而不是直接用collectAsState。前者能感知 Activity 生命周期避免后台时接收无用更新。这个 API 来自androidx.lifecycle:lifecycle-runtime-compose需要额外引入。7. 性能优化与常见坑7.1 列表性能优化该从哪儿下手列表性能问题主要集中在“不必要重组”和“列表项创建开销过大”两点。可以按优先级做以下优化优化手段作用使用说明给 items 设置稳定 key减少 diff 错误和状态错位key 要唯一且稳定不要用 index设置 contentType让 Compose 复用相同类型的 slot多类型列表必须设置列表项提取为独立 Composable缩小重组范围不要在 items lambda 里写太长布局使用稳定数据类型减少同一 item 内部参数变化data class 尽量不可变集合优先用 immutableList用 remember 缓存耗时计算避免重复执行适合头像颜色、时间格式化等用 derivedStateOf 收敛高频状态减少滚动时重组适合监听“是否接近底部”避免在 item 内部创建新对象减少 diff 开销点击回调尽量用 remember 包裹不要在滚动列表里直接写Color.parseColor这类耗时方法也不要在每个 item 里重新构建正则表达式。这些都是常见卡顿来源。7.2 常见坑item 状态错乱现象列表数据刷新后某些 item 的头像和名字对不上或者输入框里的文字跑到别的 item 上。原因没有设置 key 或 key 不稳定。Compose 按 position 比较 item删除中间某条时后面所有 position 都前移原来的 item 状态被错误复用。解决给items指定稳定唯一 key例如用户 iditems( items list, key { it.user.id } ) { item - ... }如果列表里存在多个 item 类型key 要能区分不同类型例如在 key 里加上前缀。7.3 常见坑滚动时掉帧现象列表滑动不跟手快速滑动时明显卡顿。原因item 内部做了大量非必要的计算或创建对象整个列表项 Composable 没有拆分父级一重组所有 item 跟着重组。排查先打开 Android Studio 的 Layout Inspector 查看重组次数再检查 item 内部是否有耗时计算。可以把 item 拆成独立 Composable并确认 image 加载库是否使用了占位图和缓存。图片加载建议使用 Coil 等库而不是在onCreate或组合阶段直接读文件。7.4 常见坑配置变更后滚动位置丢失现象旋转屏幕或切换深色模式后列表回到顶部。原因rememberLazyListState只能保存组合期间的状态Activity 重建后无法自动恢复。解决用rememberSaveable保存可序列化的滚动位置。LazyListState自带Saverval listState rememberLazyListState()由于rememberLazyListState内部已经用了rememberSaveable正常情况下配置变更后位置能恢复。真正丢位置往往是因为 Activity 重建时列表数据还没恢复Compose 无法定位到原来的 item index。这种情况要确保数据层在重建后能同步恢复必要时在 data class 上实现Parcelable或者用 Room 等本地缓存先展示再刷新。7.5 生产环境还需要额外做的事学习环境能跑通列表和生产环境能稳定运行还有距离。下列事情在正式项目里不能省网络请求要设置超时和重试上限分页要防止并发请求。给“加载失败”“数据为空”“加载更多失败”分别埋点。列表页使用协程取消机制页面不可见时取消无效加载。图片列表要处理内存缓存和磁盘缓存避免快速滑动时 OOM。日志不要直接打印完整列表大数据量时会拖慢主线程。8. 列表问题排查链路与自检清单8.1 从现象倒推原因的排查顺序列表出问题时先别急着改 UI。按下面的顺序检查通常能快速定位检查数据集合列表为空先确认 ViewModel 或数据源是否真的返回了数据。检查 keyitem 错乱、动画异常、输入框串位基本都在 key 设置上。检查状态切换下拉刷新一直转圈确认isRefreshing是否在请求结束后被置回false。检查滚动判断加载更多重复触发确认shouldLoadMore在加载过程中是否为false。检查重组范围卡顿先用 Layout Inspector 看哪些 item 在滚动时反复重组。检查日志异常如果列表页面崩溃先看崩溃栈中是否有IllegalArgumentException或 index 越界。问题现象优先检查处理建议列表空白数据源、item 类型判断先打印集合 size确认数据是否为空数据更新后错位key、contentType设置稳定唯一 key下拉刷新不停止isRefreshing 状态确保成功或失败回调都重置状态加载更多重复请求shouldLoadMore 判断加载中用 MutableStateFlow/标志位锁住滚动卡顿item 参数、图片加载拆 Composable、缩小重组范围、加图片缓存列表项输入框串内容key每个 item 必须绑唯一 key8.2 列表开发自检清单提交代码前可以对照这份清单过一遍所有items是否设置了稳定唯一 key。多类型列表是否设置了contentType。item 内部是否只是“接收数据 渲染”没有直接访问数据层。点击事件是否以回调参数传入而不是在 item 内部写业务逻辑。分页加载是否有并发保护是否有失败重试入口。页面是否处理了 Loading、Empty、Error、Content 四态。下拉刷新完成后刷新状态是否被正确重置。滚动位置是否需要保存Activity 重建后是否满足需求。图片列表是否使用占位图和缓存机制。全屏列表是否开启了contentPadding底部是否被导航栏遮挡。8.3 下一步可以扩展的方向掌握 LazyColumn 之后建议按以下路径继续深入使用LazyVerticalGrid实现多列网格列表。使用items搭配animateItem实现 item 增删动画。使用FlowRow处理流式标签布局。研究snapshotFlow和derivedStateOf的滚动节流机制。把列表 item 的加载和渲染抽成通用组件但要避免过度封装。列表是 Compose 里最常用也最容易忽视性能点的地方。先把数据模型、key 和状态切换这三件事做好后面的网格、动画和自定义布局都不会太难。实际项目里不要一上来就封装大而全的列表库先用一个简单的 LazyColumn 跑通数据流再逐步加入下拉刷新、加载更多和错误重试每加一层都验证一次这样定位问题会快很多。