
简介面向 Android 开发者的 Kotlin Compose 列表交互代码包解决声明式 UI 中单选与多选列表实现。围绕 LazyColumn 展示高性能长列表写法单选用 RadioButton 组合、多选用 Checkbox 与可变集合适合想减少 RecyclerView 适配器代码的中级开发者。压缩包共 44 个文件含 11 个 kt 源码、11 个 xml 界面、10 张 webp 预览另有 gradle、properties、proguard、jar 等配置体积仅 113KB便于直接导入参考。已有 236 人学习浏览。代码结构完整、注释清晰能理解组件组合与状态更新逻辑可快速迁移到实际项目。1. 先从「列表」说起Compose 里的选择需求比想象中复杂做 Android 界面这些年我遇到最多的需求之一就是「列表加选择」收货地址单选、通讯录多选、标签批量管理。Kotlin Compose 写这类列表比 View 体系清爽但「清爽」只在界面层成立选择状态的设计才是真正决定代码活多久的部分。很多人以为官方有个 selection 包能直接扛下单选多选真落到 LazyColumn 里会发现它并不顺手。这篇按我实际项目的做法把列表、单选、多选的完整代码和踩坑点拆开讲写给正在做筛选、选人、选地址以及刚啃完 Kotlin 基础想快速掌握 Compose 列表用法的 Android 开发者。2. LazyColumn 列表与条目状态设计先决定状态放哪再写界面2.1 条目状态为什么不能放在 Item 的 remember 里新手最容易犯的错是把选中状态写在条目组合函数内部Composable fun BadSelectableItem(item: String) { var isSelected by remember { mutableStateOf(false) } ListItem( headlineContent { Text(item) }, trailingContent { Checkbox(checked isSelected, onCheckedChange { isSelected it }) } ) }这段代码在列表只有三五个条目、不会滚动时看着没问题。一旦条目超过一屏LazyColumn 的懒加载机制开始工作滚出视野的组合会销毁滚回来时重新组合remember的状态也随之重置。表现就是勾选几条后往上滚一下再回来勾选标记全部消失——这大概是所有 Compose 列表踩坑记录里出现频率最高的一个。原因在于 LazyColumn 只组合可见条目它复用的是「组合槽位」而不是「数据」。状态如果绑定在组合位置里就跟着位置一起生死。正确的做法是让状态与数据实体绑定而不是与 UI 组件绑定。状态提升到外层调用方、ViewModel 或数据类字段里界面层只负责读和回调。2.2 状态提升到 ViewModel单选集合与多选集合的最小模型我一般会把选择状态放进 ViewModel用mutableStateOf包一层不可变集合。这里有个设计习惯选中集合用Set而不是List。Set 天然去重contains判断是 O(1)列表项数量上到几百上千时差距非常明显。data class SelectableItem( val id: String, val name: String ) class SelectionViewModel : ViewModel() { var selectedIds by mutableStateOfSetString(emptySet()) private set fun toggleSingle(id: String) { selectedIds if (id in selectedIds) emptySet() else setOf(id) } fun toggleMulti(id: String) { selectedIds if (id in selectedIds) { selectedIds - id } else { selectedIds id } } }toggleSingle的逻辑是「单选永远只保留一个」传入新 id 时直接整体替换成setOf(id)比先清空再添加少一次状态写入。toggleMulti用的是集合加减法selectedIds - id返回新 SetselectedIds id也是新 Set整个过程没有修改原集合配合mutableStateOf的快照机制每次赋值都会触发正确的重组。这里有个参数层面的说明mutableStateOf的初始值emptySet()是只读的后续所有操作都基于返回的新集合。很多人会在这里换成mutableStateListOf觉得「可变的用起来方便」但可变集合在 Compose 里会引入快照系统对集合内部变更的跟踪开销而且在contains频繁调用的列表场景里性能更差。具体差异放到第五章避坑清单里展开。2.3 官方 selection 包能做什么、不能做什么Compose Foundation 里确实提供了SelectableGroup和Modifier.selectable它们解决的是「一组固定选项互斥选择」的问题典型场景是单选框组、Tab 切换、几个预设选项里选一个。配合Role.RadioButton语义系统无障碍服务能正确读出选中状态。但它不能直接套在 LazyColumn 上做长列表单选多选。原因有两个一是SelectableGroup希望所有子项在组合树里同时存在而 LazyColumn 的条目是虚拟化的不可见条目根本没有组合实例二是选中状态仍然需要外部持有官方包只是帮你统一了互斥逻辑和语义省不掉状态设计这一步。所以我的结论很直接列表单选多选自己维护状态用Modifier.selectable处理条目交互SelectableGroup留给数量少于十个且不需要滚动的场景。3. 单选列表落地从 RadioButton 到分组单选3.1 单选的三种交互形态跳转式、勾选式、Spinner 下拉式单选列表在实际业务里通常有三种形态。第一种是「跳转回填」比如从地址列表点进详情详情页返回后上一页选中该项这种场景下列表只负责展示选中态由外部传入。第二种是「行内勾选」整行可点选中行显示对勾或 RadioButton常见于设置页的语言选择、默认支付方式选择。第三种是下拉式单选对应以前 View 体系的 SpinnerCompose 里用ExposedDropdownMenuBox或DropdownMenu实现点击弹出选项列表选中后回填到文本框。先看第二种最常用的行内单选。核心写法是给整行加Modifier.selectable而不是给 RadioButton 单独加点击Composable fun SingleSelectList( items: ListSelectableItem, selectedId: String?, onSelect: (String) - Unit ) { LazyColumn { items(items, key { it.id }) { item - val isSelected item.id selectedId ListItem( headlineContent { Text(item.name) }, trailingContent { RadioButton( selected isSelected, onClick null ) }, modifier Modifier .fillMaxWidth() .selectable( selected isSelected, role Role.RadioButton, onClick { onSelect(item.id) } ) ) } } }两个参数值得单独说。RadioButton的onClick传null让它变成纯展示组件避免点击事件重复触发——这是很多教程没写清楚的地方默认写法里RadioButton(onClick { onSelect(item.id) })会和行的selectable一起响应点击极端情况下一次点击触发两次回调。Modifier.selectable的role参数用来告诉无障碍服务这个元素的语义类型传Role.RadioButton后 TalkBack 会播报「单选按钮已选中」不传的话它只是个普通可点击区域。第三种下拉式单选Compose 里实现起来比 View 的 Spinner 繁琐一些。常见的做法是把选中值和展开状态都提升到调用方ExposedDropdownMenuBox包裹TextField和DropdownMenu菜单项用DropdownMenuItem承载点击后onSelectedChange回传。这个交互模式在筛选类页面里很常见参数就两个当前选中值控制文本框显示内容展开状态控制菜单显隐。3.2 分组单选给列表加一个「当前分组」维度单选项多了以后自然要分组。比如选择国家亚洲、欧洲、美洲各一组选择商品分类每个类目下若干子项。分组列表在 Compose 里用stickyHeader实现组名吸顶组内条目正常滚动。分组单选的状态模型和扁平列表完全一样仍然是一个selectedId只是渲染结构变为两层。data class GroupItem( val groupName: String, val items: ListSelectableItem ) OptIn(ExperimentalFoundationApi::class) Composable fun GroupedSingleSelectList( groups: ListGroupItem, selectedId: String?, onSelect: (String) - Unit ) { LazyColumn { groups.forEach { group - stickyHeader { Text( text group.groupName, modifier Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface) .padding(horizontal 16.dp, vertical 8.dp) ) } items(group.items, key { it.id }) { item - SingleSelectRow( item item, isSelected item.id selectedId, onSelect onSelect ) } } } }注意forEach加stickyHeader的写法LazyColumn的内容 DSL 支持任意顺序插入不同类型的 itemstickyHeader和items交替排列即可。key { it.id }在这里比扁平列表更重要因为分组数据经常来自服务端组内顺序可能变化没有 key 的话状态错位的概率更高。分组单选在实际项目中还会遇到跨组互斥问题——不同分组里可能出现相同 id 的数据这时selectedId至少要用「组名 id」拼接或者直接改用selectedItem: SelectableItem做状态而不是只存 id。4. 多选列表落地全选、反选与批量操作栏4.1 多选状态模型Set 更新与 immutable 快照多选的状态容器从String?变成SetString这是整个多选功能的地基。前面 2.2 已经给了toggleMulti的写法这里补充一个关键设计决策为什么不用mutableStateListOfSelectableItem直接存被选中的对象。// 不推荐状态与对象实例强绑定 var selectedItems by mutableStateListOfSelectableItem() fun toggle(item: SelectableItem) { if (item in selectedItems) selectedItems.remove(item) else selectedItems.add(item) }这段代码的问题有两个。第一列表数据和选中状态是同一份对象引用如果数据源后续刷新下拉刷新、分页加载、服务端推送新列表里的SelectableItem实例大概率不是原来的对象item in selectedItems判断会失败已选中的项全部丢失。第二mutableStateListOf的contains和remove是线性扫描500 个条目全选时每个条目都要扫描一遍集合复杂度 O(n²)滚动时肉眼可见的掉帧。改用selectedIds: SetString之后列表刷新带来的对象更换不影响状态contains是 O(1)全选一次 build Set 是 O(n)数据量和性能解耦。这是我在多个项目里对比后的确定结论建议直接照搬这个模型。class MultiSelectViewModel : ViewModel() { var selectedIds by mutableStateOfSetString(emptySet()) private set val selectedCount: Int get() selectedIds.size fun toggle(id: String) { selectedIds if (id in selectedIds) selectedIds - id else selectedIds id } fun setSelected(ids: SetString) { selectedIds ids } }selectedCount用自定义 getter 而不是单独一个状态变量是为了避免计数和集合内容不一致。项目里如果同时存在selectedIds和selectedCount两个mutableStateOf手动更新时漏掉任何一个都会出现「角标显示 1 但实际没选中任何项」的玄学 bug。计算属性随状态自动更新没有同步问题。4.2 全选与反选的边界半选状态不需要钻牛角尖全选、取消全选是批量操作的两个基本动作。实现上注意一个边界列表数据可能是分页加载的当前页面显示的 20 条只是总数据的一部分全选按钮的语义应该是「选中当前全部可见项」还是「选中服务端全部数据」产品不明确时我默认做后者会出事故——用户以为全选了结果执行批量操作时漏了一大批。正确做法是把全选范围限定在当前列表数据的完整集合上fun toggleSelectAll(allIds: ListString) { selectedIds if (selectedIds.size allIds.size) { emptySet() } else { allIds.toSet() } } fun invertSelection(allIds: ListString) { val current selectedIds selectedIds allIds.filterNot { it in current }.toSet() }toggleSelectAll的判断条件不是allIds.all { it in selectedIds }而是比较 size。原因在于 Set 的 size 比较在这个场景下足够准确且开销小all加上contains虽然更严谨但多一次完整遍历列表项少时无所谓几千项时能感觉到差异。反选用filterNot生成新集合同样走 immutable 更新路径。半选状态TriState我一般不建议做进列表多选。Material 的 Checkbox 确实提供了triState参数支持 indeterminate 状态但维护这个状态需要额外判断「选中集是否真包含于全集且不等于全集」而且用户对半选的直觉理解差异很大——有人觉得半选就是「再次点击全选」有人觉得是「取消全选」。产品没有明确要求的情况下两态全选按钮加上角落里的「已选 N 项」文案信息量已经足够。4.3 批量操作栏的联动AnimatedVisibility 与条数角标多选的最终目的是批量操作删除、移动、分享、导出。操作栏一般放在底部选中数量大于 0 时滑出等于 0 时收起。这个联动效果不要自己写if加animateFloatAsState用AnimatedVisibility最省事Composable fun BatchActionBar( selectedCount: Int, onDelete: () - Unit, onShare: () - Unit ) { AnimatedVisibility( visible selectedCount 0, enter slideInVertically { it }, exit slideOutVertically { it } ) { Surface(shadowElevation 8.dp) { Row( modifier Modifier .fillMaxWidth() .padding(horizontal 16.dp, vertical 8.dp), horizontalArrangement Arrangement.SpaceAround ) { Text(已选 $selectedCount 项) TextButton(onClick onDelete) { Text(删除) } TextButton(onClick onShare) { Text(分享) } } } } }这里enter和exit用slideInVertically { it }{ it }表示位移距离取组件自身高度底部操作栏从视口外滑入的效果就是靠这个 lambda 生成的。AnimatedVisibility的visible直接绑定selectedCount 0不需要额外的布尔状态变量因为selectedCount变化时组合函数会重组AnimatedVisibility会自动执行进入或退出动画。角标数字「已选 N 项」的 N 来自selectedCount注意它和操作栏的显隐必须来自同一个状态源。项目里如果有人在 ViewModel 里维护一个isBatchMode布尔值又在界面层读selectedIds.size迟早会出现「角标显示 3 但操作栏已经收起来」的怪现象。这类联动界面状态一律从选中集合派生不要增加冗余状态。5. 单选多选列表的避坑清单五条血泪经验5.1 删除条目后选中态错位items 的 key 是后悔药现象多选列表删除条目后剩下的条目里有些莫名其妙处在选中状态勾选标记跑到了下一行。原因items(items)没传key参数时LazyColumn 用条目在列表中的位置索引作为标识。删除一条后后面所有条目的位置前移原本绑定在「位置 5」的选中状态跟着位置走到了「位置 4」视觉上就是选中项整体错位。这是 LazyColumn 最典型的黑匣子行为不写 key 时索引复用造成状态张冠李戴。解决items(items, key { it.id })给每个条目一个稳定且唯一的标识。有了 key 之后删除中间条目只会让前后条目做位移动画组合状态仍然锚定在数据实体上。这个规则对单选、多选、分组列表全部适用属于没有例外的基础约束。id 尽量用业务主键实在没有就用hashCode但要注意哈希碰撞的可能。5.2 Checkbox 和行点击双重触发点选变闪选现象勾选一个条目时Checkbox 的选中状态闪了一下又变回未选中第二次点击才稳定。原因行的clickable或selectable和 Checkbox 内部的onCheckedChange同时响应了同一次点击第一次回调选中第二次回调取消最终状态取决于执行顺序。解决整行用Modifier.selectable(selected ..., role Role.Checkbox, onClick ...)承载一切点击逻辑Checkbox 的onCheckedChange传null变成纯展示。这样点击事件只有一个入口行为可预期。顺带的好处是无障碍语义更正确屏幕阅读器会把整行识别为「复选框」而不是行内嵌套了一个复选框。5.3 mutableStateListOf 在全选时的性能黑洞现象列表数量 500 到 1000 之间点一次全选界面卡顿半秒以上滚动时持续掉帧。原因mutableStateListOf的快照系统会在每次结构变更时通知所有读取该集合的组合作用域。全选操作循环调用add每次add都触发一次快照失效广播N 次添加意味着 N 次重组通知再加上全选后每个条目都要contains检查一遍总复杂度接近 O(n²)。解决状态模型改成「不可变 Set 整体替换」全选一次selectedIds allIds.toSet()只触发一次状态写入和一次重组。同时每个条目的isSelected判断虽然仍然要扫 Set但 Set 的contains是 O(1)500 条也只是一次线性遍历的常数倍完全在可接受范围。5.4 旋转屏幕后选中态清零SavedStateHandle 兜底现象勾选几条数据旋转屏幕后选中状态全部丢失列表数据还在但选择没了。原因默认配置下 Activity 重建后 ViewModel 还在但 ViewModel 内存里的状态没有被持久化。如果项目里用了「进程被系统回收后恢复」的方案纯内存状态一定会丢。用户角度这就是「我选了半天白选了」。解决ViewModel 构造参数里加SavedStateHandle把选中集合写入其中进程重建后恢复class MultiSelectViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { var selectedIds by mutableStateOf( savedStateHandle.getListString(KEY_SELECTED)?.toSet() ?: emptySet() ) private set fun toggle(id: String) { selectedIds if (id in selectedIds) selectedIds - id else selectedIds id savedStateHandle[KEY_SELECTED] selectedIds.toList() } companion object { private const val KEY_SELECTED selected_ids } }注意SavedStateHandle对Set的支持在不同版本里行为不一致统一转成ListString存、取出来再转回 Set规避序列化边界问题。toggle里每次赋值后同步写入 handle 是有点啰嗦但换来的是「进程死了也不丢选择」的可靠兜底值得。5.5 列表项函数传 lambda 导致不必要的重组现象列表滚动时偶发卡顿Compose 编译器提示某些 Item 无法跳过重组但在扁平列表里这个现象不明显分组列表里更突出。原因Item(item: SelectableItem, onClick: (String) - Unit)这类签名里lambda 类型是不稳定参数。父组合重组时如果onClick每次都是新实例子组合的跳过优化直接失效。LazyColumn 的 item scope 已经隔离了滚动范围内的重组但这个隔离不解决 lambda 实例不稳定问题。解决在 ViewModel 里把回调定义为稳定引用比如 ViewModel 的fun onItemClick(id: String)方法引用直接传给子项或者把选中判断和点击逻辑封装到一个持有完整状态的子组合里让每个条目只接收Boolean和稳定回调。判断标准很简单条目数超过 200 且滚动卡顿先查 lambda 稳定性条目少不必过度优化写清楚比跑得快更重要。6. 进阶带搜索过滤的选人列表与条目动效6.1 检索过滤与选中态保持filter 不丢选择的写法选人、选标签这类场景通常要配一个搜索框。搜索过滤的本质是对原列表做切片只展示命中的子集。这里有个容易翻车的设计过滤后选中态跟着视图层走结果清除搜索词、列表恢复全量时选中项丢了。正确做法是选中状态永远挂在全量数据上过滤只影响可见条目Composable fun SearchableMultiSelectList( allItems: ListSelectableItem, selectedIds: SetString, onToggle: (String) - Unit ) { var query by remember { mutableStateOf() } val filteredItems remember(allItems, query) { val trimmed query.trim() if (trimmed.isBlank()) { allItems } else { allItems.filter { it.name.contains(trimmed, ignoreCase true) } } } LazyColumn { items(filteredItems, key { it.id }) { item - SelectableRow( item item, isSelected item.id in selectedIds, onToggle onToggle ) } } }remember(allItems, query)的键位声明很关键列表数据变化或搜索词变化时重新计算过滤结果其他情况直接复用。isSelected用item.id in selectedIds判断不依赖过滤后的列表做任何额外状态维护。这样即使搜索词把某个已选项过滤得看不见了它的选中状态仍然在集合里清除搜索词后勾选标记原样保留。6.2 自定义选择器外观与逐条动画默认的 RadioButton 和 Checkbox 在大多数业务里够用但选人列表里常见「头像 名称 选中勾」的组合自定义外观时记住沿用selectable和role不要退回clickableListItem( headlineContent { Text(item.name) }, leadingContent { Box { AsyncImage(model item.avatarUrl, contentDescription null) if (isSelected) { Icon( imageVector Icons.Default.Check, contentDescription null, tint MaterialTheme.colorScheme.primary, modifier Modifier .align(Alignment.BottomEnd) .size(20.dp) ) } } }, modifier Modifier .fillMaxWidth() .selectable(selected isSelected, role Role.Checkbox, onClick { onToggle(item.id) }) )条目删除或位移的动画在 Compose 1.7 之后统一走Modifier.animateItem()挂在 LazyColumn 的 item modifier 链上即可删除条目时自动执行收缩动画多选删除后列表的衔接观感会好很多。做选择类列表这些年我攒下最深的教训就一句话先设计状态容器再画界面。选中集合用什么类型、放在哪个作用域、生命周期怎么兜底这些问题想清楚了LazyColumn 和 Material 组件写起来都很快想不清楚再简单的单选都会冒出奇奇怪怪的玄学 bug。希望这份落地写法帮你在下一个筛选、选人、选地址的项目里少踩几个坑。本文还有配套的精品资源点击获取