ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Compose列表单选多选:从RecyclerView迁移的状态管理与避坑指南

Compose列表单选多选:从RecyclerView迁移的状态管理与避坑指南 简介Android开发领域Kotlin Compose作为Google推出的声明式UI框架正逐步替代传统视图体系代码包聚焦列表场景集中展示单选与多选列表的实现方式适合已掌握基础Kotlin语法、希望快速上手Compose状态管理的移动端开发者。代码包共44个文件核心代码以11个Kotlin文件为主配合11个XML布局与资源文件、10个WebP示意图片以及Gradle构建脚本、ProGuard规则等工程配置整体仅113KB轻量便于直接导入项目或对照学习。其中包含LazyColumn高效长列表的完整示例结合RadioButton与Checkbox组件处理单选、多选的选中状态并提供可变数据集与点击回调的配套写法可迁移到实际业务开发中。目前已有236人在CSDN学习下载适合需要从RecyclerView转向Compose、或正在搭建列表交互基础模块的开发者。1. 从 RecyclerView 迁到 Compose列表的单选多选是最先撞上的墙团队从 RecyclerView 迁移到 Jetpack Compose 的那段时间列表是第一个让我停下来重新思考的东西。RecyclerView 的 Adapter、ViewHolder、DiffUtil 我闭着眼都能写但换到 Kotlin Compose 之后发现连「选中一个选项」这种最简单的交互都得换一套写法没有 RadioGroup没有 setOnCheckedChangeListener列表状态要么自己用 remember 管要么交给 ViewModel。这份资源里是一套完整的 Compose 列表实现覆盖 LazyColumn 渲染、单选RadioButton和多选Checkbox两种交互模式并且保留了完整的工程文件结构。适合刚上手 Compose、正准备把列表从 View 体系迁过来的 Android 开发者也适合那些列表能滚但状态一翻车就无从下手的同学拿来当参照。下面我按实现顺序把单选、多选、状态管理和常见的坑完整过一遍。2. LazyColumn 与状态管理为什么列表不能按老思路写2.1 LazyColumn 与 RecyclerView从 Adapter 到声明式组合RecyclerView 时代列表的核心是 ViewHolder 复用和 Adapter 的 getItemCount、onCreateViewHolder、onBindViewHolder外加一套 DiffUtil 做增量更新。这套东西本身没问题但样板代码多状态分散在 Adapter 和 ViewHolder 里想加一个选中态往往要往 ViewHolder 里塞字段、在 bind 的时候手动刷 UI。Compose 的 LazyColumn 把这一层完全收掉了它内部仍然做视图复用但对开发者是封闭的你只需要描述「这个位置渲染什么」剩下的交给运行时。Composable fun SimpleList(items: ListString) { LazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues(vertical 8.dp) ) { itemsIndexed(items) { index, item - ListItemCard( text item, index index ) } } }这段代码里itemsIndexed是 LazyColumn 最常用的子项发射器它把列表的每个元素绑定到一个 Composable 上。和 RecyclerView 的 Adapter 对比这里没有onCreateViewHolder、没有getItemCount、没有notifyDataSetChanged索引到 UI 的映射关系完全由声明式描述完成。contentPadding对应原来 RecyclerView 的addItemDecoration的边距作用但写法上要直观得多。LazyColumn 另一个和 RecyclerView 拉开差距的地方是 item 的 key 机制。RecyclerView 的 item 位置是稳定索引Compose 里如果不显式给 key滚动复用时会按位置来对应状态后面避坑章节我会专门讲这个问题。这里先记住结论只要列表数据里有稳定的 id 字段items发射器里就一定要带key { it.id }。2.2 remember、rememberSaveable 与 mutableStateOf选中状态存在哪Composable 的函数体每次重组都会重新执行如果你直接写一个普通变量来存选中项重组发生时这个变量会被重新初始化状态瞬间丢干净。所以 Compose 提供了 remember让变量在重组之间保留。Composable fun SelectionScreen(items: ListString) { // 只在当前组合生命周期内保留 var selectedIndex by remember { mutableIntStateOf(-1) } // 在配置变更旋转屏幕后也能保留 var savedIndex by rememberSaveable { mutableIntStateOf(-1) } // 在 Activity 级保留进程被系统回收前都有效 // 一般由 ViewModel 持有 }这里remember的作用是「重组后仍然记住这个值」mutableIntStateOf返回一个可观察的状态容器当值被修改时Compose 会自动触发读取了这个状态的 UI 重组。rememberSaveable比remember多一层保障它会利用 SavedState 机制把值写入 Bundle旋转屏幕、系统回收 Activity 后能恢复。mutableIntStateOf是针对 Int 的优化版本避免装箱列表场景里如果一个 id 是 Int 类型用它比mutableStateOf更合适。实际项目里怎么选列表项的临时选中态放在rememberSaveable内可以应对大多数日常场景。如果选中态还要参与业务逻辑——比如提交到服务器、驱动其他列表的联动——那就直接放进 ViewModel用mutableStateOf或StateFlow暴露。放在 ViewModel 里最大的好处是进程被系统回收后重新创建时只要数据源还在状态就能重建这是rememberSaveable在多页面场景下的短板。3. 单选列表RadioButton 与三种状态持久化写法3.1 Compose 没有 RadioGroup用索引比较实现互斥传统 Android 里做单选列表第一反应是RadioGroup包住一组RadioButton由 Group 维护互斥。Compose 里没有 RadioGroup 这个组件RadioButton自己是纯粹的表达组件它不知道自己在哪个组里。互斥逻辑需要你自己用状态来比较判断。Composable fun SingleSelectList( options: ListString, selectedIndex: Int, onSelect: (Int) - Unit ) { LazyColumn { itemsIndexed( items options, key { index, item - $index-$item } ) { index, item - Row( modifier Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable { onSelect(index) } .padding(horizontal 16.dp, vertical 12.dp), verticalAlignment Alignment.CenterVertically ) { RadioButton( selected selectedIndex index, onClick { onSelect(index) } ) Spacer(modifier Modifier.width(12.dp)) Text( text item, style MaterialTheme.typography.bodyLarge, modifier Modifier.weight(1f) ) } } } }这段代码的关键逻辑在selected selectedIndex index这正是替代 RadioGroup 互斥机制的核心思路用一个外部的selectedIndex和当前项的index做比较相等就选中。RadioButton的onClick不需要再自己判断什么直接把 index 抛出去由外部的onSelect更新selectedIndex。把点击事件放在整行clickable上而不是只点在 RadioButton 上是为了扩大点击热区。移动端列表的点击目标是整个条目用户很少去精确点那个圆形按钮这个细节在列表交互里影响很大。3.2 状态的位置从 Composable 到 ViewModel单选状态放在哪直接决定旋转屏幕和进程回收时的表现。最轻量的写法是放在rememberSaveable里适合纯 UI 层面的选择场景。Composable fun SingleSelectScreen(options: ListString) { var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } SingleSelectList( options options, selectedIndex selectedIndex, onSelect { selectedIndex it } ) }rememberSaveable会在 Activity 重建时自动恢复代价是只能存简单类型。如果选中项不是 Int 而是对象需要额外写 Saver。这个写法适合设置项、筛选面板等页面状态不需要和业务层通信。class OptionViewModel : ViewModel() { var selectedIndex by mutableIntStateOf(-1) private set fun select(index: Int) { selectedIndex index } }再把 ViewModel 和 Compose 接起来Composable fun OptionScreen(viewModel: OptionViewModel) { val selectedIndex by viewModel.selectedIndex SingleSelectList( options options, selectedIndex selectedIndex, onSelect viewModel::select ) }这样写的优势有两个。第一selectedIndex的private set保证外部只能通过select()修改逻辑入口统一第二ViewModel 不随配置变更销毁旋转屏幕时状态天然保留。实际项目里只要有“选择完要提交”“选择影响其他模块”这些可能一律用 ViewModel 方案rememberSaveable留着给纯展示页面用。4. 多选列表Checkbox、集合管理与全选反选4.1 用 Set 记选中项把列表项和选中态解耦多选列表的状态管理第一原则是不要在每个 item 内部用一个var isChecked来记勾选状态。原因很简单LazyColumn 的 item 会随滚动销毁重建局部变量在 item 离开组合时就会被回收状态跟着丢。正确做法是把选中状态提升到列表层级用集合统一管理。data class ItemModel( val id: String, val name: String ) Composable fun MultiSelectList( items: ListItemModel, selectedIds: SetString, onItemToggle: (String) - Unit ) { LazyColumn { items( items items, key { it.id } ) { item - val checked selectedIds.contains(item.id) Row( modifier Modifier .fillMaxWidth() .clickable { onItemToggle(item.id) } .padding(horizontal 16.dp, vertical 8.dp), verticalAlignment Alignment.CenterVertically ) { Checkbox( checked checked, onCheckedChange { onItemToggle(item.id) } ) Spacer(modifier Modifier.width(12.dp)) Text( text item.name, modifier Modifier.weight(1f) ) } } } }这段代码里selectedIds是整个列表的「事实来源」每个 item 通过selectedIds.contains(item.id)判断自己是否被选中。点击整行和点击 Checkbox 都会调用onItemToggle(item.id)由外层统一决定是加还是减。key { it.id }在这里是必须的它保证了滚动复用时光标不会和 item 错位。外层控制状态时用mutableStateListOf或mutableStateSet这类 Compose 可观察集合而不是普通 MutableSet/ListComposable fun MultiSelectScreen(items: ListItemModel) { // 用空 Set 占位状态提升到父级 val selectedIds remember { mutableStateListOfString() } MultiSelectList( items items, selectedIds selectedIds.toSet(), onItemToggle { id - if (selectedIds.contains(id)) { selectedIds.remove(id) } else { selectedIds.add(id) } } ) }mutableStateListOf返回的是SnapshotStateList它既是 MutableList 又是 Compose 的观察源增删元素时只有依赖它的 UI 会重组不会波及整棵树。注意传给子组件时用了toSet()这样子组件只读不会绕过外层逻辑直接改集合。4.2 全选、反选与批量操作的实现边界多选列表一旦加上全选和反选状态的切换逻辑就值得单独抽出来不然后续按钮越加越乱。全选的语义是「所有未选中的都选中」反选是「选中的变成未选未选的变成选中」这两个操作都要基于当前完整集合去算。Composable fun MultiSelectToolbar( items: ListItemModel, selectedIds: SnapshotStateListString ) { val allIds items.map { it.id } val isAllSelected selectedIds.size allIds.size selectedIds.isNotEmpty() Row( modifier Modifier .fillMaxWidth() .padding(horizontal 16.dp, vertical 8.dp) ) { Button( onClick { if (isAllSelected) { selectedIds.clear() } else { selectedIds.addAll(allIds) } } ) { Text(if (isAllSelected) 取消全选 else 全选) } Spacer(modifier Modifier.width(8.dp)) Button( onClick { val currentIds selectedIds.toList() selectedIds.clear() selectedIds.addAll(allIds.filter { it !in currentIds }) } ) { Text(反选) } } }全选按钮的逻辑很好理解已经是全选状态就清空否则把所有 id 加进去。反选稍微绕一点先toList()把当前选中的集合拍个快照然后清空再把原来没选中的 id 加进去顺序不能反否则allIds.filter { it !in currentIds }会因为你边删边查而出错。SnapshotStateList的addAll和clear都会触发依赖它的 UI 重组所以在MultiSelectScreen里这几个操作可以在 Composable 中直接调用。批量操作的边界在于如果列表数据是分页加载的全选到底选「当前已经加载出来的」还是「服务端全量数据」这个必须提前定好。一般移动端列表的全选就语义而言只覆盖当前列表已加载项这一点建议在需求沟通时就确认清楚不然交付后会被当作 bug 打回来。5. 单选多选避坑清单五个让列表翻车的具体场景5.1 Checkbox 没有 label 参数照着旧代码抄直接编译失败现象网上抄了一段多选列表代码Checkbox 里写了label { Text(item) }编译器直接报错找不到参数。原因Compose Material 早期版本里 Checkbox 确实有label参数来展示旁边文本后来 API 收敛Checkbox 变成一个纯粹的状态表达组件文本内容由调用方自由组装。现在看到的教程如果还带label基本是过时内容。解决把文本单独放在 Checkbox 旁边的Text里外面用Row包裹。Checkbox( checked checked, onCheckedChange { onItemToggle(item.id) } ) Spacer(modifier Modifier.width(12.dp)) Text(text item.name)5.2 不写 key 的 LazyColumn滑动回来选中状态全部串位现象一个多选列表滚动到底再滚回来发现勾选状态出现在别的行上选了三项变成了八九项。原因LazyColumn 默认按位置索引关联 item 状态。滚动复用后原来第 3 行的 Composable 实例被拿去渲染第 15 行如果选中状态存在 remember 里它就跟着复用了。解决给items传key { it.id }。key 让 Composable 和具体数据项绑定即使位置变了状态也能跟随数据项走。如果你的数据没有天然 id可以用索引拼接字符串做 key。5.3 remember 扛不住旋转屏幕rememberSaveable 和 ViewModel 怎么选现象列表选好了三项转一下屏幕全部清空。用remember存的状态恢复不了。原因remember只在配置变更旋转屏幕、深色模式切换时被重置因为它没有持久化能力。配置变更会销毁整个 Activity 重建Composable 的组合树跟着重建remember里存的什么都归零。解决列表选中的临时状态用rememberSaveable需要跨页面共享或参与业务的状态用 ViewModel。一个快速判断标准Activity 死了状态能不能丢能丢就用remember不能丢先在rememberSaveable和 ViewModel 里选后者。5.4 旧版 RadioButton APIonChange 改成了 onClick现象照着一篇两三年前的 Compose 教程写单选列表RadioButton(value item, onChange { ... })编译不过IDE 提示onChange不存在。原因Compose Material 早期实验版里 RadioButton 的 API 是value加onChange: (Boolean) - Unit稳定版之后改成selected: Boolean加onClick: () - Unit。实验版时期 API 改得很频繁网上大量文章早就失效了。解决当前稳定版统一用selected和onClick两个参数。遇到编译不过的代码优先去开发者官网查当前文档而不是在搜索引擎里翻老文章。5.5 重组风暴把状态写太大列表卡到掉帧现象列表本身只有几十项滑动却明显掉帧偶尔看到相邻 item 的背景一闪一闪。原因选中状态粒度太粗比如整个列表用一个大的data class包住所有选中项任何一项变化都把整个状态对象替换掉LazyColumn 需要重组所有读取了这个对象的 item。解决把状态拆细。选中集合用SnapshotStateList只保存选中项 iditem 内部只读取selectedIds.contains(item.id)这一个条件这样单个 item 的选中变化不会波及其他行。需要派生数据时用derivedStateOf隔离读取范围val selectedCount by remember { derivedStateOf { selectedIds.size } }derivedStateOf只有在读取selectedIds快照发生变化时才重新计算可以把对集合的读取限制在最小的依赖范围内。6. 从工程文件说起ComposeRecyclerView 结构与一个选择容器封装6.1 工程文件清单这个压缩包里是一个完整的 Gradle 工程不是零散 Demo 片段。关键文件按用途列一下文件/目录用途app/src/main/java主模块源码列表、单选、多选的 Composable 都在这里app/build.gradle应用模块依赖包括 compose 插件和 material3 版本声明gradle.properties构建参数比如 JVM 内存、AndroidX 开关gradlew/gradlew.batGradle wrapper跨平台跑构建用libs/本地依赖库目录里面有需要单独引用的 aar 包proguard-rules.pro混淆规则Keeps 相关配置libs目录这种情况常见于依赖某个内部封装的 aar导入工程后要用implementation(files(libs/xxx.aar))或compileonly fileTree(dir: libs, include: [*.aar])的方式声明依赖。因为compileOnly在构建时不会打入最终包如果实际运行报类找不到要改成implementation。6.2 封装一个泛型 SelectionContainer最后的进阶技巧是把单选和多选的公共逻辑抽成一个泛型容器后续任何列表只要关心数据渲染不用再重复写状态管理。一个常见做法是这样Composable fun T SelectionContainer( items: ListT, keySelector: (T) - String, selectedIds: SetString, onToggle: (T) - Unit, itemContent: Composable (T, Boolean) - Unit ) { LazyColumn { items( items items, key { keySelector(it) } ) { item - itemContent(item, selectedIds.contains(keySelector(item))) } } }用的时候只需要把「渲染什么」传进去单选多选的差异在调用方决定SelectionContainer( items items, keySelector { it.id }, selectedIds selectedIds, onToggle { item - toggle(item.id) } ) { item, selected - Row( modifier Modifier .fillMaxWidth() .clickable { toggle(item.id) } .padding(16.dp) ) { Checkbox(checked selected, onCheckedChange null) Text(text item.name) } }这个模式把状态容器和 UI 解耦比每写一个列表就重新抄一遍状态管理要省事得多。但也别过度抽象只有当你确实有多个列表页面出现类似交互时才值得做这一层封装不然泛型参数带来的阅读成本比复制的成本更高。从那以后我每次开工写列表第一件事不是排版布局而是先确认「状态放在哪、key 用什么、选中集合什么结构」这三个问题定了再写 UI。顺序反了后面大概率要回头重构这是我踩了多次坑之后总结出的习惯。希望帮到你。本文还有配套的精品资源点击获取
返回列表