尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Compose列表开发核心:LazyColumn用法与状态管理

Compose列表开发核心:LazyColumn用法与状态管理 第一次在项目里用 LazyColumn 替换 RecyclerView 时我的第一反应是这也太简单了。没有 Adapter没有 ViewHolder没有 LayoutInflater没有 onCreateViewHolder 和 onBindViewHolder 的回调只是把数据放进去列表就出来了。但真正写了几个页面之后我又意识到一件事简单只是表面。声明式 UI 的列表真正改变的并不是省了多少行代码而是开发者在写列表时思考的问题变了。以前想的是“怎么告诉界面更新这一行”现在想的是“数据是什么界面就该是什么”。这篇内容就把 Jetpack Compose 声明式 UI 里最常用的“列表”从头到尾讲透包括核心概念、真实页面的状态管理、常见坑和性能边界。可能会和你平时看到的“十分钟学会 Compose 列表”不太一样。1. 先搞清楚 Compose 列表真正改变的是什么1.1 旧模式不是代码繁琐而是“更新”需要自己管在开始写写法之前我想先说清楚一件事Compose 列表和 RecyclerView 列表之间的差别不只是代码长短。在 RecyclerView 时代列表开发的核心矛盾是“数据变化了怎么增量更新到界面上”。你要写 Adapter要处理 ViewHolder 复用要调 notifyItemInserted、notifyItemRemoved复杂一点的还要引入 DiffUtil 来计算新旧数据的差异。代码越多出错的概率就越高。常见的问题包括数据更新了但界面没变滑动时 ViewHolder 里的状态残留notify 方法调错导致闪屏或崩溃。这些问题的根源都一样界面更新这件事需要开发者显式、命令式地告诉系统。1.2 新模式数据变了界面就该跟着变Compose 换了一条路。你不再写“给这个 ViewHolder 设置什么数据”而是写“这个列表项长什么样”。数据源只要是可观察的数据一变框架就自己重新执行读取了那段数据的 Composable 函数再通过对比新旧 UI 的差异只更新真正有变化的部分。这就是声明式 UI 的核心界面是状态的函数。列表在这个模型里尤其典型。传统模式下列表的“行”是一个个可以被复用的 View在 Compose 里列表的“项”是一段可以被重新执行的 UI 描述。正因为每一项都是独立的组合函数框架才有能力做局部重组。你会发现Compose 列表里经常会出现一个概念状态变化会触发重组但重组不一定会重绘整棵 UI 树更不一定会重建整个列表。1.3 这套模型对开发者的真正意义这意味着你的工作重心从“操作界面”转移到了“管理状态”。在 Compose 里写列表难点不在于怎么画一行而在于怎么把列表的数据、加载状态、加载更多、下拉刷新、错误重试这些状态组织好。这也是为什么很多人从 RecyclerView 转到 Compose 之后一开始觉得简单写复杂页面后又觉得“怎么这么多状态要处理”。根本原因是以前这些状态散落在 Adapter、Fragment、接口回调里靠经验和习惯维护现在状态被集中到可组合函数里逻辑更透明也更考验你对状态建模的能力。2. 列表开发的第一组核心概念LazyColumn、items、item2.1 最小可用列表先跑通再理解先从最常用的写法开始。LazyColumn 是 Compose 里最基础的纵向列表容器它相当于 RecyclerView 的 LinearLayoutManager 模式但它不是 ViewGroup而是一个可组合函数。给它传一个数据列表再用 items 闭包生成每一项的 UI就是一个完整列表。Composable fun ArticleList(articles: ListArticle) { LazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues(16.dp) ) { items(articles) { article - ArticleItem(article) } } }这段代码里最关键的是 LazyColumn 的 content 是一个 LazyListScope而不是普通 Composable 作用域。这意味着在 LazyColumn 里不能直接调用任意 Composable 函数只能调用 items、item、header 这些 LazyListScope 的扩展函数。这是新手最容易困惑的地方之一。注意LazyColumn 里不能随意写 Column 或任意 Composable所有列表项都要通过 item / items 来声明。2.2 LazyColumn 与 Column 的本质区别很多人第一次接触 Compose 时会问为什么不用 Column 加 verticalScroll区别其实很明确Column 会把所有子项一次性全部组合进 UI 树不管屏幕能不能显示完。数据量小没问题数据量一大内存和首屏性能都会受影响。LazyColumn 则只在需要时组合可见区域附近的子项再通过按需组合来减少开销。所以它的名字里“Lazy”强调的是“延迟”和“按需”。对比点Column verticalScrollLazyColumn子项组合方式一次性组合所有子项按需组合可见区域适用数据量少、固定多、不确定滚动状态类型普通 ScrollStateLazyListState是否支持按类型复用不适用支持 contentType判断标准可以记住如果列表项数量固定且很少比如三五个设置项用 Column 就够了。如果数据来自网络、数量不确定、可能几十条甚至上千条用 LazyColumn。这个判断不复杂但很多人会在“我就一个列表不算多”的时候忽略它等到数据量上来后才去重构。2.3 滚动位置rememberLazyListState 的用途列表不只是展示数据很多时候还需要读取或设置滚动位置。比如用户滑到某个位置后切走再回来希望恢复原位或者点击某个按钮跳回顶部。这时需要 LazyListState。val listState rememberLazyListState() LazyColumn( state listState ) { // items } // 回到顶部 scope.launch { listState.animateScrollToItem(0) }LazyListState 还带一些有用属性比如 firstVisibleItemIndex可以用于实现“回到顶部按钮是否显示”这类逻辑。需要留意的是remember 的作用是让状态在重组时保留如果不用它滚动位置很可能在重组时丢失。如果希望 Activity 重建后仍然恢复位置可以配合 rememberSaveable 来保存LazyListState 本身就支持这种场景。3. 列表项设计从“能显示”到“像个真实页面”3.1 填充策略与内容内边距在 Compose 里列表项的外边距有两种常见做法。一是给 LazyColumn 设置 contentPadding二是给每一项的根 Composable 设置 Modifier.padding。两者效果不同。contentPadding 是列表内容区的内边距它会在列表的头部和尾部产生空间。item 级 padding 则更像传统 Item 的 margin每一项都有独立间距。实际开发里我一般会先确定“间距属于列表内容区还是属于每一项”。如果整个页面的边距统一优先用 contentPadding如果是像瀑布流卡片之间需要间距更适合给 item 加 padding。两种方式也可以组合使用不过要小心间距叠加比如每一项上下都有 padding最后视觉间距会是两倍。LazyColumn( contentPadding PaddingValues( start 16.dp, end 16.dp, top 8.dp, bottom 24.dp ), verticalArrangement Arrangement.spacedBy(12.dp) ) { items(articles) { article - ArticleCard(article) } }Arrangement.spacedBy 是给 item 之间统一间距的常用做法比在每个 item 里手动加 margin 更清晰。3.2 点击事件和分隔线列表项的点击可以直接在 item 的根组件上挂 Modifier.clickable比传统 adapter 里设置 OnClickListener 更直观。但要注意一点如果 item 内部还有可点击元素比如卡片右上角收藏按钮要保证点击事件嵌套层级清晰不要让父级 clickable 拦截掉子级点击。Compose 里 Modifier 的顺序会影响事件处理所以通常把 clickable 放在布局外层的 Modifier 链上。分隔线在 Compose 里可以叫 DividerMaterial 3 较新版本里也有 HorizontalDivider。常见写法是使用 Divider 组件。常见做法是在列表项之间插一条细线或者直接给列表项底部画一条线。我更推荐在 item 内部处理因为这样不会额外增加组合节点。注意如果分隔线是“每一项之间”而不是“每一项底部”直接用 Arrangement.spacedBy 配合 Divider 也是一种常见方案按视觉效果来选不要机械模仿。3.3 多类型列表项用 sealed class 管理而不是一串 when真实项目里的列表很少有纯单一类型的。常见情况是信息流里穿插广告、公告、阅读记录首页里有轮播图、宫格入口、新闻列表。这种多类型列表在传统 RecyclerView 里要写多套 viewType 逻辑在 Compose 里则要思考怎么组织数据源。我比较推荐用 sealed class或 Kotlin 2.x 里的 sealed interface来定义列表项类型。把每一种数据模型封装成一个子类然后在一个 when 表达式里为每个类型写对应的 Composable。这样做的好处是编译期就能检查所有分支是否处理完后续新增类型时编译器会提醒你补上 UI。3.4 多类型列表项完整示例sealed interface FeedItem { val id: String data class Article( override val id: String, val title: String, val summary: String ) : FeedItem data class Ad( override val id: String, val imageUrl: String ) : FeedItem data class Header( override val id: String, val title: String ) : FeedItem } Composable fun FeedList(items: ListFeedItem) { LazyColumn( contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(10.dp) ) { items(items, key { it.id }) { item - when (item) { is FeedItem.Article - ArticleCard(item) is FeedItem.Ad - AdBanner(item) is FeedItem.Header - SectionHeader(item.title) } } } }这种写法和传统 viewType 最大的差异是数据和 UI 的关系非常直接没有 ViewHolder 的类型转换也不用提前注册多套布局。代码量不一定最少但可读性要好很多。尤其是当列表里需要调整“第一项是 Header第三项是广告”时只需要调整 items 数据顺序即可这在 RecyclerView 里做起来就很繁琐。4. 真实页面里的列表状态加载、刷新、加载更多、空状态4.1 列表状态为什么要建模而不是用多个 Boolean单一列表的展示往往只是最基本的需求。真实列表通常要处理四种状态加载中、加载成功且有数据、加载成功但为空、加载失败。很多人在初学时会用三四个 Boolean 分别表示 isLoading、isError、isEmpty然后在组合函数里写一堆 if 判断。这样做在状态少时还算清晰状态一多就容易出现组合爆炸比如“isLoading 和 isError 同时为 true 应该显示什么”。更推荐的做法是把列表状态建模成一种可穷举的类型用 sealed interface 或数据类表示。加载中、成功、失败变成各自分支。sealed interface ArticleListState { object Loading : ArticleListState data class Success( val articles: ListArticle, val hasMore: Boolean false ) : ArticleListState data class Error( val message: String ) : ArticleListState }“空状态”其实可以看成 Success 的一种特殊情况articles 为空时显示空内容。这样状态机更集中分支也更少。4.2 下拉刷新的推荐做法下拉刷新在 Compose Material 3 里已经有现成组件通常会在 LazyColumn 外层包一层。var isRefreshing by remember { mutableStateOf(false) } PullToRefreshBox( isRefreshing isRefreshing, onRefresh { // 触发重新加载数据 } ) { LazyColumn { /* 列表内容 */ } }这个组件在不同版本里命名可能会有差异落地前先确认你使用的 Compose Material 版本。如果你用的版本比较老可能看到 SwipeRefresh 或 PullRefresh 的旧版 API。下拉刷新的本质是“重新执行一次数据请求”所以不要在 onRefresh 里直接改本地数据而是触发 Repository 或 ViewModel 里重新请求的方法等结果回来再更新状态。这样界面、状态、数据源的职责才清晰。4.3 加载更多监听滚动到底部加载更多在 Compose 里通常靠 LazyListState 来判断是否接近底部。常见思路是检查最后一个可见项索引是否接近总长度。val listState rememberLazyListState() val shouldLoadMore by remember { derivedStateOf { val lastVisible listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val total listState.layoutInfo.totalItemsCount lastVisible total - 3 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { viewModel.loadMore() } }用 derivedStateOf 是为了避免每次滚动都触发重组只有 shouldLoadMore 值变化时才触发 LaunchedEffect。这个细节值得留意很多列表卡顿就是在滚动回调里做了太多无谓状态更新。加载更多还有一个容易忽略的点触发加载后如果接口没有返回新数据shouldLoadMore 可能一直为 true导致重复请求。所以还需要配合一个 isLoadingMore 标记在请求进行中阻塞再次触发。注意不要把加载更多的判断写成直接在 scroll 回调里 setState高频状态更新会让列表滚动明显掉帧。优先用 derivedStateOf 做条件抽取。4.4 空状态与错误重试空状态和错误重试其实不是列表特有的但它们往往和列表状态绑定在一起。常见做法是当状态是空或错误时不渲染 LazyColumn而是渲染一块居中内容并提供重试按钮。when (state) { is Loading - LoadingView() is Error - ErrorView( message state.message, onRetry { viewModel.reload() } ) is Success - { if (state.articles.isEmpty()) { EmptyView(onRefresh { viewModel.reload() }) } else { LazyColumn { items(state.articles) { article - ArticleRow(article) } // 加载更多 footer } } } }这个结构看起来简单但它把“界面状态”和“数据状态”绑定在了一起。不需要额外维护一堆页面级布尔值列表页面逻辑会清晰很多。5. key 的作用为什么列表项会“闪”或状态错乱5.1 没有 key 时Compose 怎么判断 item 是否复用LazyColumn 在处理列表变化时会通过“位置”来匹配前后两次的 item。如果列表数据发生了插入、删除、排序但没有给 items 提供 keyCompose 会按位置匹配。这会导致明明某条数据没变但因为位置变了它对应的组合状态被错误地复用而原本那条数据对应的滚动位置、动画、焦点状态却错乱了。在传统 RecyclerView 里这个问题会以“item 状态复用错乱”的形式出现比如图片闪烁、输入框内容串行。在 Compose 里表现可能更隐蔽比如某条 item 内部的 remember 状态被带到了另一条 item 上。给 items 指定 key 就是解决这个问题的基础手段。key 的作用是给每个 item 一个稳定身份让 Compose 在数据变化时能识别“谁是谁”而不是“第几个是谁”。5.2 key 应该怎么选key 应该是一个稳定、唯一的标识通常用数据的主键或 id。不要用数组下标因为下标会随着插入删除变化那相当于没给 key。不要用接口返回的临时字段除非它确实稳定。items(articles, key { it.id }) { article - ArticleRow(article) }如果数据源里没有 id但每个 item 在逻辑上确实有唯一性也可以组合出稳定的 key。注意选 key 时要考虑同一数据在多次刷新后是否仍然保持一致这是 key 是否有效的核心条件。5.3 什么时候必须加 key不是所有列表都必须加 key。如果列表是静态的或者数据只是整体替换没有插入删除和排序不加 key 通常也能正常工作。但以下场景强烈建议加 key列表支持删除或插入尤其是用户操作触发的局部数据变化。item 内部有 remember 状态比如展开/收起、输入框内容、图片加载状态。使用 animateItem() 做列表增删动画时没有 key 几乎必然出问题。还有一个很实用的场景如果你的列表项里用了滚动类状态key 可以帮你在数据重排时保持状态归属正确。6. 性能与边界不是所有列表都要用 LazyColumn6.1 列表数量少时Column 反而更简单前面说过LazyColumn 是按需组合的。但“按需”也有代价LazyColumn 需要额外的布局、滚动状态管理和 item 复用机制。如果列表项只有几个且内容是固定的设置项直接用 Column verticalScroll 可能更简单代码也更直观没必要为了“响应式”这个词上 LazyColumn。工程里有一个简单判断标准如果数量已知且很少比如少于 10 个或者列表项差异很大、单个内容体积很大Column 更合适如果数量不确定、可能很多、内容类型相对整齐用 LazyColumn。6.2 item 里的重量级操作才是卡顿根源很多人以为“用了 LazyColumn 就万事大吉”其实不是。LazyColumn 只是避免了把所有 item 一次性组合但如果每个 item 的组合函数里做了重量级计算比如读取大文件、做复杂字符串解析、在组合阶段执行网络请求、创建大量中间对象那性能瓶颈依然存在而且更难排查。写 item 的 Composable 时尽量保持“轻量、纯函数”风格组合阶段只做 UI 描述不做耗时操作需要耗时的话放到 remember / LaunchedEffect 里或者放进 ViewModel 层。这一点和传统 RecyclerView 的 onBindViewHolder 里不做耗时操作是一个道理。6.3 contentType 的增量价值如果列表里有多种类型 item可以给 items 提供 contentType 参数。它让 LazyColumn 知道哪些 item 属于同一类型从而在滚动时更好地复用已组合的 item。items( items items, key { it.id }, contentType { item - when (item) { is FeedItem.Article - article is FeedItem.Ad - ad is FeedItem.Header - header } } ) { item - // ... }contentType 不是必须的但在列表类型多、item 数量大时它能避免不同类型的 item 互相复用导致的性能浪费。这和 RecyclerView 里按 viewType 复用 ViewHolder 是类似思想。6.4 嵌套滚动的坑另一个常见坑是“列表套列表”。比如在一个 Scroll 或 Column 里再放一个 LazyColumn或者在一个 LazyColumn 的 item 里再放一个同方向的 LazyColumn。这种情况下滚动事件会互相抢夺表现是列表滚动不流畅、手势冲突、甚至内容显示不全。正确做法通常是把子列表拍平或者在父列表中用 LazyRow 做横向列表避免同方向嵌套。如果确实需要嵌套要仔细确认子列表是否需要固定高度以及滚动策略
返回列表