简介这是一套面向Android开发者的Kotlin Compose列表交互示例代码重点解决单选与多选场景的实现问题适合正在学习声明式界面或需要在项目中快速集成列表选择的开发者。内容包括LazyColumn长列表的渲染方式、通过状态集合维护选中项、以及RadioButton与Checkbox的切换逻辑可直接借鉴到实际项目也可用于理解Compose的状态驱动界面更新机制。压缩包共44个文件核心代码集中在11个Kotlin源文件中另有11个XML资源文件用于界面与主题配置配合Gradle构建脚本和属性文件便于直接编译运行整体约113KB体量小巧。已有238人学习作者为wy313622821。通过这套代码读者可以掌握单选列表的互斥选中管理、多选列表的集合更新技巧以及列表项复用时的状态保持方法对构建功能完整且性能良好的Compose列表界面具有参考价值。1. 单选、多选列表Compose 里最容易被写崩的交互在 Jetpack Compose 里写一个带单选、多选的列表看起来是个入门级需求实际翻车率极高。很多人第一次写完发现滑出去再滑回来选中状态丢了列表中间插入一条数据所有勾选错位点了一行上一行的选中图标却没取消。这些问题的根源几乎都一样——状态放错了地方key 选错了对象。这篇文章要解决的就是「kotlin compose 代码的列表包括单选多选」这件事本身先讲清楚 LazyColumn 的状态归属逻辑再给出一套可以照着改的单选、多选实现最后把几个高频坑逐个拆开。适合正在用 Compose 写表单、选择器、批量操作页面的 Android 开发者也适合从 View 体系刚切过来、对「状态到底该谁持有」还没形成直觉的人。这套方案不依赖任何第三方库纯 Compose API 就能跑。2. 列表骨架与状态归属先决定「选中态」放在哪一层2.1 为什么是 LazyColumn 而不是 Column写 Compose 列表第一步是选容器。数据量小比如几十条以内用 Column 没问题但一旦条目数量不确定或者后面要接分页加载LazyColumn 是唯一合理的选择。它只组合当前可见范围内的 item滑出去的组合项会被回收和 RecyclerView 的 ViewHolder 复用是同一个思路。Composable fun SimpleList(items: ListString) { LazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(8.dp) ) { items(items) { item - Text( text item, modifier Modifier .fillMaxWidth() .padding(12.dp) ) } } }contentPadding控制列表整体与屏幕边缘的间距verticalArrangement控制条目之间的间隔。这两个参数在列表项很多时比在每个 item 上单独加 padding 更省事还能避免首尾条目贴边。items(items)是 LazyColumn 的扩展函数内部会为每个条目生成一个 item 组合返回值里没有 index如果后面需要根据位置做操作要改用itemsIndexed。2.2 选中状态放 item 内部是大多数翻车的起点刚接触 Compose 的人最容易写出这样的代码在每个 item 的 composable 里用remember { mutableStateOf(false) }存选中状态。运行起来第一次点击确实有效果但一旦列表滑动item 被回收重建状态就丢了。更隐蔽的问题是如果两个 item 内部各自存了一份状态它们之间无法互相通知——单选场景下点第 3 行时第 2 行根本不知道要取消选中。Compose 的状态管理核心原则是「状态提升」谁需要这份状态状态就放在谁的层级。单选、多选列表的选中态是整个列表的公共状态必须提升到 LazyColumn 的调用方持有item 只负责展示和触发回调。Composable fun ItemRow( text: String, selected: Boolean, onClick: () - Unit ) { Row( modifier Modifier .fillMaxWidth() .clickable(onClick onClick) .padding(16.dp) ) { Text(text text, modifier Modifier.weight(1f)) // 选中标识由 selected 决定item 自身不保存任何状态 } }这段代码里ItemRow是一个纯展示组件selected是外部传进来的onClick是外部定义的。它不remember、不mutableStateOf所有数据流都是单向的。这样做的好处是不管列表怎么滑动、item 怎么回收重建显示结果永远和调用方持有的状态一致。接下来单选、多选的实现都建立在这个基础上。3. 单选列表互斥逻辑与回调设计3.1 最小可用实现一个 selectedIndex 管住全部单选的核心诉求是「同时只能选中一个」。用selectedIndex: Int作为唯一状态源就能满足点击第 N 项时把状态改成 N列表重组后只有 index 等于 N 的条目显示选中。Composable fun SingleSelectList(options: ListString) { var selectedIndex by remember { mutableStateOf(-1) } LazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(8.dp) ) { itemsIndexed(options) { index, option - Row( modifier Modifier .fillMaxWidth() .clickable { selectedIndex index } .padding(horizontal 16.dp, vertical 12.dp) ) { Text( text option, modifier Modifier.weight(1f) ) RadioButton( selected selectedIndex index, onClick { selectedIndex index } ) } } } }selectedIndex初始值设为 -1表示「未选中任何项」。itemsIndexed提供了 index 参数点击回调里直接把 index 写入状态。RadioButton的selected判断条件是selectedIndex index因为状态只有一个所以天然互斥——点新的旧的自动就不满足了。3.2 参数说明为什么 RadioButton 的 onClick 可以留空这里有一个容易困惑的点RadioButton的onClick参数在 Compose 的 Material 组件里是可空的。我实际写的时候经常只给Row加clickableRadioButton的onClick传null让整行的点击统一处理。好处是点击区域变大不会出现「点文字没反应、必须点小圆点才行」的体验问题。如果不用 Material 的RadioButton想自己控制选中样式只需要把选中态映射到视觉上Row( modifier Modifier .fillMaxWidth() .clickable { selectedIndex index } .padding(16.dp) ) { Text(text option, modifier Modifier.weight(1f)) if (selectedIndex index) { Icon( imageVector Icons.Filled.CheckCircle, contentDescription 已选中, tint MaterialTheme.colorScheme.primary ) } }Icons.Filled.CheckCircle是 Material 内置图标tint改颜色就能适配主题。这个写法适合不想引入 RadioButton 视觉效果、想自定义选中图标的场景。核心逻辑没变变的只是「选中态长什么样」。3.3 用数据类承载选中项而不是只存 indexindex 方案在列表数据固定不变时够用但一旦列表支持增删、排序index 就不可靠了。更稳的做法是给数据加唯一 id用 id 记录选中项。data class SelectableItem( val id: Int, val name: String ) Composable fun SingleSelectById(items: ListSelectableItem) { var selectedId by remember { mutableStateOfInt?(null) } LazyColumn { items(items, key { it.id }) { item - Row( modifier Modifier .fillMaxWidth() .clickable { selectedId item.id } .padding(16.dp) ) { Text(text item.name, modifier Modifier.weight(1f)) RadioButton( selected selectedId item.id, onClick null ) } } } }selectedId的类型是Int?null表示未选中。items(items, key { it.id })给每个条目指定了稳定 key这个 key 后面会专门讲。用 id 而不是 index 之后列表做局部更新、删除、排序选中状态都不会错乱。这是我在实际项目里更推荐的单选写法。如果你的数据本来就没有 id 字段可以考虑用hashCode()或者给数据类加一个自增字段。4. 多选列表选中集合的增删与批量操作4.1 用 Set 存选中项天然去重多选和单选的差别在于状态从一个值变成一个集合。集合类型选什么很关键。有人用MutableListInt每次 toggle 先contains判断再 add/remove功能能跑但contains是 O(n) 的列表几百项时不明显上千项就开始卡。用Set更合适toggle 操作稳定 O(1)而且天然去重不会出现同一项被 add 两次的脏数据。Composable fun MultiSelectList(options: ListSelectableItem) { var selectedIds by remember { mutableStateOf(setOfInt()) } LazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(8.dp) ) { items(options, key { it.id }) { item - val isSelected item.id in selectedIds Row( modifier Modifier .fillMaxWidth() .clickable { selectedIds if (isSelected) { selectedIds - item.id } else { selectedIds item.id } } .padding(16.dp) ) { Text(text item.name, modifier Modifier.weight(1f)) Checkbox( checked isSelected, onCheckedChange null ) } } } }selectedIds用setOfInt()初始化每次 toggle 生成一个新的 Set 赋值给selectedIds。这里注意我用的是mutableStateOf(setOfInt())而不是mutableStateOf(mutableSetOfInt())。前者是不可变 Set 整体替换Compose 能正确检测到状态变化并触发重组后者如果直接addMutableState 默认的 equality check 可能检测不到变化导致界面不刷新。这是一个非常容易踩的坑后面避坑章节还会单独说。4.2 全选、反选与批量操作把集合运算写清楚多选列表的产品形态通常不止点一点还会带全选、反选、删除选中项这类批量操作。这些逻辑本质上都是集合运算写之前先在纸上列清楚全选 集合变成所有 id全不选 集合变空反选 在全集和当前集合之间取差集。Composable fun MultiSelectWithActions( options: ListSelectableItem, onDeleteSelected: (SetInt) - Unit ) { var selectedIds by remember { mutableStateOf(setOfInt()) } val allIds remember(options) { options.map { it.id }.toSet() } val allSelected selectedIds.size allIds.size Row( modifier Modifier .fillMaxWidth() .padding(16.dp) ) { TextButton(onClick { selectedIds if (allSelected) emptySet() else allIds }) { Text(if (allSelected) 取消全选 else 全选) } TextButton( onClick { selectedIds allIds - selectedIds }, enabled selectedIds.isNotEmpty() ) { Text(反选) } TextButton( onClick { onDeleteSelected(selectedIds) }, enabled selectedIds.isNotEmpty() ) { Text(删除选中 (${selectedIds.size})) } } LazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(8.dp) ) { items(options, key { it.id }) { item - // item 行布局同 4.1略 } } }allIds用remember(options)缓存options 不变时不会重复计算。allSelected的判断只看数量相等因为 Set 里不可能有重复 id数量相等就是全选。allIds - selectedIds是反选的集合运算从全集中去掉当前选中的剩下的就是该选中的。onDeleteSelected把选中集合抛给上层列表数据和选中状态怎么清理由调用方决定——这样多选列表组件本身不用关心数据源细节。4.3 横竖屏旋转与进程回收remember 不够rememberSaveable 才行remember只能保组合生命周期内的状态Activity 重建比如转屏时会丢。多选场景里用户勾到一半转个屏全没了这是最让人崩溃的体验。解决办法是把状态容器从remember换成rememberSaveable。var selectedIds by rememberSaveable { mutableStateOf(setOfInt()) }但直接这么写会编译报错SetInt不是Bundle能直接存的类型。需要给rememberSaveable配一个 Saver把 Set 转成 ArrayListvar selectedIds by rememberSaveable( stateSaver Saver( save { it.toList() }, restore { it.toSet() } ) ) { mutableStateOf(setOfInt()) }save把 Set 转成 ListBundle 能存 ArrayListrestore再从 List 恢复成 Set。rememberSaveable的保存时机是onSaveInstanceState覆盖转屏和进程被系统回收的场景。这套 Saver 写法记下来凡是遇到 Bundle 不支持的类型的自定义状态都用同样的模式处理。5. 单选多选列表的三个典型坑现象、原因、解决5.1 滑动后选中状态错乱、跑到别的行上现象列表滑出去再滑回来勾选标记出现在错误的条目上或者多个条目同时显示选中。原因item 组合被回收重建时如果用了itemsIndexed且没指定keyCompose 按位置复用组合对象。列表中间插入或删除数据后位置和数据的对应关系变了旧状态就粘到了新数据上。解决给items或itemsIndexed传key { it.id }用数据的稳定唯一标识而不是位置。加了 key 之后Compose 按 key 匹配组合对象数据位置怎么变状态都跟着正确的数据走。这条同样适用于LazyColumn之外的其他延迟布局组件。5.2 多选时用 mutableSetOf 直接 add界面不刷新现象点击 Checkbox 后数据变了日志打印集合里有值但 UI 上的勾选不出现。原因mutableStateOf(mutableSetOfInt())的默认 equality policy 是结构性比较。直接add是原地修改前后比较结果相同Compose 认为状态没变跳过重组。解决始终用不可变集合 整体赋值。选中用selectedIds id取消用selectedIds - id每次产生新集合。或者给mutableStateOf传入structuralEqualityPolicy()之外的策略但那样改不如换不可变集合来得干净。这条坑在 List 类型上同样存在mutableListOf加add一样不触发重组。5.3 点击选中区域小误触率高的体验问题现象测试反馈点文字没反应老是点不中那个圆圈。原因clickable只加在RadioButton或Checkbox上点击区域只有控件本身那么大选项文字区域没有点击响应。解决把clickable移到Row或外层Modifier上RadioButton和Checkbox的onClick传null。整行可点视觉上点击范围大了事件处理也统一。如果需要更宽的点击容错可以给Row加minimumInteractiveComponentSize()或直接加大padding。5.4 用 index 当 key列表删除后勾选错位现象多选删除了几项剩余项里有的勾选状态跑到别的行上。原因删除操作改变了列表顺序而 key 用的是 index删除后 index 全部前移Compose 复用旧组合时把旧数据和旧状态一起错位了。解决数据类必须带稳定 idkey 用 id 而不是 index。如果数据源来自后端id 用服务端主键如果是本地数据用自增字段或 UUID。另一个技巧是删除操作完成时把选中集合也做一次过滤去掉已删除的 id双保险防止脏状态残留。5.5 item 内部用 remember 存选中态父组件无法同步现象单选列表点击第 3 项第 1 项的勾选没取消或者弹窗关闭再打开勾选全没了。原因每个 item 用自己的remember维护了独立状态多个 item 之间互不可见弹窗关闭时整个列表组合销毁item 内部状态全部丢失。解决状态全部提升到列表调用方item 改成纯展示组件。单选用selectedId多选用SetIntitem 层只接收selected: Boolean和onClick: () - Unit。这条是做列表选择功能最核心的一条方法论前面所有实现都遵循这个原则。6. 进阶稳定 key、跨页面状态同步与条件更新单选多选列表做到能跑只是第一步真正接入业务后还有几个高频场景要处理列表数据从接口返回后需要回显选中态切到详情页再返回选中态不能丢列表更新时只刷新变化的条目不整页重组。回显的核心是初始化状态。接口数据加载完成后把返回的 id 集合赋值给selectedIds。注意这个赋值时机要在数据到齐之后不能在rememberSaveable初始化时设——那时列表还是空的。常见的做法是LaunchedEffect(loadedData) { selectedIds loadedData.filter { it.preSelected }.map { it.id }.toSet() }LaunchedEffect以loadedData为 key数据变化时执行一次把服务端返回的预选状态写入集合。跨页面状态同步则把选中集合提升到 ViewModel用StateFlow持有页面级 composable 通过collectAsStateWithLifecycle订阅。这样列表、详情页、批量操作栏读的都是同一份数据任何一处修改其他界面自动刷新。items的key参数除了保证状态稳定还能做细粒度更新。Compose 的 LazyColumn 在数据变化时只重组 key 变化或内容变化的条目而不是全部。这意味着列表项的数据类如果重写了equals字段变化时对应行自动刷新未变化的行跳过重组。所以数据类建议写成data class SelectableItem( val id: Int, val name: String, val enabled: Boolean true )enabled之类的附加字段不在 key 里不影响组合复用但会影响内容比较。这个细节对列表性能影响很大尤其是条目多、数据频繁刷新的场景。我自己吃过一次亏列表项内容没变但每次刷新整页闪一下后来发现是没加 key 导致全部重组。加了稳定 key、保证数据类是纯值对象之后闪烁问题就消失了。如果你正在做的列表出现「滚动卡顿」「刷新时整页跳一下」优先检查这两点。希望这些经验对你有帮助少走几个我已经走过的弯路。本文还有配套的精品资源点击获取