简介:无论你是刚接触 Compose 的新手,还是需要快速实现列表选择功能的开发者,这套 Kotlin Compose 示例都值得直接参考。它基于列表组件搭建了可高效滚动的长列表,并通过单选按钮组和复选框分别管理单选与多选状态,清晰展示了声明式 UI 中状态如何驱动界面更新。示例覆盖商品选择、筛选设置等常见交互场景,也方便开发者观察列表项点击反馈与选中集合的动态变化。资源压缩包共包含 44 个文件,以 11 个 Kotlin 源文件、11 个 XML 描述文件、10 个 WebP 图片文件为主,另附 Gradle 构建脚本、混淆规则和 Gradle 包装器,整体仅 113KB,目录结构完整,导入开发工具后可直接运行。目前已有 236 人学习,代码简洁易懂,适合学习 Compose 状态管理机制和列表单选/多选的标准写法,也可作为日常项目中的工具代码复用。
1. Kotlin Compose 做列表单选多选,难点从不在 UI 控件本身
Kotlin Compose 做列表单选多选,表面是 RadioButton 和 Checkbox 的选择问题,实际是状态模型的问题。我拆过一个叫 ComposeRecyclerView 的示例工程,发现单选多选真正难的不是 LazyColumn 渲染,而是状态放错层后,快速滑动选项错乱、旋转屏幕选中项丢失、整行点击和控件点击互相打架。这篇就用这个工程为底子,从状态设计、列表渲染到性能踩坑,讲一套可以直接抄的 Jetpack Compose 列表交互写法。适合正在迁移 Compose、或者准备重写列表模块的 Android 开发。
2. 选中状态模型设计:先决定用 Long? 还是 Set
2.1 单选和多选在状态形状上的差异
单选和多选在 UI 上只是 RadioButton 与 Checkbox 的差异,但状态模型完全不同。单选选中值只能有一个,用Long?表示最合适:空值代表未选中,非空代表当前选中项的 id。多选必须记住多个值,用Set<Long>更合理,重复点击同一个 id 就是在集合里做加减。
我见过不少刚切 Compose 的人把selected布尔字段直接塞进 item 的数据类,比如data class Item(val id: Long, val name: String, var selected: Boolean = false)。这个做法在 RecyclerView + Adapter 时代还能凑合,到 Compose 里会立刻出问题:一是var selected不是 State,改动它不会触发重组;二是即使改成mutableStateOf(false),每次选中变化都让整个 item 数据对象失效,容易引发非必要的 LazyColumn item 重组。更好的做法是把选中状态完全从列表数据中剥离,用 id 集合独立管理。
状态与数据分离还有一个实际好处:列表数据可能来自 Room、网络接口或本地静态配置,这些数据模型通常不希望被 UI 交互字段污染。拆开后,一个User实体可以在订单、通讯录、权限配置三个界面里复用,界面各自维护自己的选中 id 集合即可。这也是 Compose 声明式 UI 的核心思路:UI 是状态的投影,状态越简单,投影越稳定。
2.2 用 rememberSaveable 和 ViewModel 做状态持久化
确定了状态形态后,接下来是状态放在哪一层。列表是页面内的一个弹窗面板,用rememberSaveable就够了;如果列表是整个页面的核心,并且筛选条件需要驱动网络请求,建议放进 ViewModel。
@Composable fun rememberSingleSelectState(initialId: Long? = null): MutableState<Long?> = rememberSaveable { mutableStateOf(initialId) } @Composable fun rememberMultiSelectState(initialIds: Set<Long> = emptySet()): MutableState<Set<Long>> = rememberSaveable { mutableStateOf(initialIds) }这里用rememberSaveable而不是remember,是因为remember在 Activity 重建、系统回收时会丢失。rememberSaveable会把初始值写入 Bundle,旋转屏幕后选中状态还在。第二个函数的参数是Set<Long>,它在 Android 平台上可被 Bundle 自动保存,不需要额外写 Saver。
如果你的选中集合非常大,或者需要跨页面共享,可以在 ViewModel 中用MutableStateFlow:
class ListScreenViewModel : ViewModel() { private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet()) val selectedIds: StateFlow<Set<Long>> = _selectedIds.asStateFlow() fun onToggle(id: Long) { _selectedIds.update { current -> if (id in current) current - id else current + id } } }ViewModel 的优势是状态不会随配置变更丢失,并且天然支持多个 Composable 共享订阅。注意MutableStateFlow.update里的函数式写法,传入的是旧集合,返回新集合,整个函数是原子的,避免多线程并发更新时丢状态。
2.3 一个轻量 SelectionState 封装
如果同一页面里既有单选又有多个多选组,比如一个筛选页里排序方式单选、标签多选,直接散落多个MutableState会让调用点变得很啰嗦。我一般会包一个轻量 SelectionState,把增删、单选切换、清空这些操作收敛起来。
class SelectionState(initialIds: Set<Long> = emptySet()) { private val _selectedIds = mutableStateOf(initialIds) val selectedIds: Set<Long> by _selectedIds fun toggle(id: Long) { _selectedIds.value = if (id in _selectedIds.value) { _selectedIds.value - id } else { _selectedIds.value + id } } fun selectOnly(id: Long) { _selectedIds.value = setOf(id) } fun clear() { _selectedIds.value = emptySet() } fun isSelected(id: Long): Boolean = id in _selectedIds.value } @Composable fun rememberSelectionState(initialIds: Set<Long> = emptySet()): SelectionState = remember { SelectionState(initialIds) }这里有几个细节值得说明。Set<Long> + id和Set<Long> - id都是生成新 Set,而不是修改原对象,Compose 才能检测到状态变化。isSelected放在类里是为了让列表项组合函数里写state.isSelected(user.id)更可读。注意remember没有把initialIds作为 key,因为初始值只在第一次创建时有效,后续回传新的初始集合不会主动重置状态,这正好符合我们的预期:状态是页面自有的,不受外部参数变化影响。
单选和多选在状态形状上的对应关系,可以归纳成下面这张表。
| 维度 | 单选 | 多选 |
|---|---|---|
| 状态类型 | Long? | Set<Long> |
| 默认值 | null | emptySet() |
| 切换操作 | 直接赋新值 | 在集合中增删 |
| 对应组件 | RadioButton | Checkbox |
| 语义角色 | Role.RadioButton | Role.Checkbox |
| 典型场景 | 排序方式、收货地址 | 标签筛选、批量删除 |
这张表对后续写列表项很有用:组件的selected参数和状态判定完全由 id 决定,item 自身不持有任何选中信息。
3. LazyColumn 渲染列表:RadioButton 和 Checkbox 实战
3.1 item、key 与列表数据绑定
LazyColumn 区别于普通 Column 的核心是懒加载,它只组合当前可视区域的 item。但这也带来一个约束:每一个 item 必须有稳定的身份,否则滚动复用后,Compose 无法判断哪个 item 对应哪个状态。最直接的做法是利用items的key参数。
@Composable fun UserList( users: List<User>, selectedId: Long?, onSelected: (Long) -> Unit ) { LazyColumn(modifier = Modifier.fillMaxSize()) { items( items = users, key = { user -> user.id } ) { user -> UserRow(user = user, checked = user.id == selectedId, onClick = { onSelected(user.id) }) } } }key建议使用业务主键,比如user.id。如果实在没有主键,可以用 index,但 index 在列表中间插入或删除时会全部失效,选中的 item 会跳到另一行,这是单选多选里最隐蔽的错乱来源。key存在的意义,就是告诉 LazyColumn 这个 item 的身份没有变,可以保留它的组合状态。
3.2 单选列表完整代码与参数拆解
下面是完整的单选列表写法。我没有用RadioGroup,因为 Compose 官方没有提供和 Android View 体系完全对等的 RadioGroup,通过状态模型里的Long?就能天然保证只有一个选中项。
@Composable fun SingleSelectList( items: List<SelectableItem>, selectedId: Long?, onSelected: (Long) -> Unit ) { LazyColumn { items(items, key = { it.id }) { item -> val checked = item.id == selectedId Row( modifier = Modifier .fillMaxWidth() .selectable( selected = checked, role = Role.RadioButton, onClick = { onSelected(item.id) } ) .padding(horizontal = 16.dp, vertical = 12.dp) ) { Text( text = item.name, modifier = Modifier.weight(1f) ) RadioButton( selected = checked, onClick = null ) } } } }这段代码里最关键的参数是Modifier.selectable。selected代表当前行的选中状态,role告诉无障碍服务这一行是单选按钮,onClick则是整行点击回调。把点击交给selectable而不是在 Row 上单独加clickable,是为了让 RadioButton 不放大也能响应点击。
RadioButton的onClick我传了null。这么做是有意为之:如果 RadioButton 和 Row 同时处理点击,在快速点击时会出现两次回调,视觉上表现为选中后又取消。新版 Compose 的 RadioButton 允许 onClick 为 null,此时它只负责展示状态,交互完全由外层行接管。如果编译环境里的 Compose 版本要求非空,可以传{ onSelected(item.id) },但这时候必须去掉外层selectable,改成clickable,否则会重复触发。
列表项的数据类通常是这样的:
data class SelectableItem( val id: Long, val name: String )这里故意没有selected字段,选中状态通过selectedId单独传入。
selectable的参数看起来简单,实际用起来有些细节。下表是我常用的几个参数说明。
| 参数 | 类型 | 作用 | 注意事项 |
|---|---|---|---|
selected | Boolean | 当前项是否被选中 | 状态来自外部,不能是 item 内部可变值 |
role | Role? | 无障碍语义角色 | 单选传Role.RadioButton,多选传Role.Checkbox |
onClick | () -> Unit | 点击回调 | 在这里做选中状态更新 |
interactionSource | InteractionSource? | 点击水波纹等效果来源 | 一般留空,用默认值 |
3.3 多选列表完整代码与点击冲突处理
多选和单选的 UI 结构几乎一样,只有状态判定和语义角色不同。多选时,一个 id 是否被选中,取决于这个 id 是否在selectedIds集合里。
@Composable fun MultiSelectList( items: List<SelectableItem>, selectedIds: Set<Long>, onToggle: (Long) -> Unit ) { LazyColumn { items(items, key = { it.id }) { item -> val checked = item.id in selectedIds Row( modifier = Modifier .fillMaxWidth() .selectable( selected = checked, role = Role.Checkbox, onClick = { onToggle(item.id) } ) .padding(horizontal = 16.dp, vertical = 12.dp) ) { Text( text = item.name, modifier = Modifier.weight(1f) ) Checkbox( checked = checked, onCheckedChange = null ) } } } }Checkbox 同样把onCheckedChange留空,避免和 Row 的selectable竞争点击。这里有个容易踩的逻辑问题:多选点击不应该传入“新状态”,而是应该把 item 的 id 交给上层,由上层决定是加还是减。我上面的onToggle(item.id)就是这个意思。如果写成onCheckedChange = { onToggle(item.id, it) },虽然也能跑,但你会发现上层函数需要同时关心当前状态和点击动作,职责更混乱。
3.4 批量选择操作
多选列表通常还要配全选和清空。这两个操作在状态层很容易实现,不需要在 UI 层做复杂循环。
fun onSelectAll() { selectedIds = items.map { it.id }.toSet() } fun onClearAll() { selectedIds = emptySet() }在界面里可以放两个 TextButton,分别调用这两个函数。全选时会直接把所有 id 一次性赋给集合,触发一次重组。这里有一个性能小技巧:不要用for循环逐个selectedIds + item.id,那样每加一个 id 都会生成新状态,LazyColumn 会连续重组多次。一次性构建完整 Set 赋值,重组次数只有一次。
4. 性能与踩坑:derivedStateOf、item 重组与选择动画
4.1 LazyColumn 的懒加载机制与 key 稳定性
LazyColumn 内部使用 SubcomposeLayout 来测量和放置可视区域内的 item,因此屏幕上只有 8 个条目时,它不会去组合第 9 个、第 100 个。这种机制让它的启动性能远好于一次性加载全部数据的 Column,但也带来一个新问题:item 的组合与销毁是动态的。一旦某个 item 滚出屏幕,它的组合状态就可能被回收;再回来时,Compose 需要通过key判断它是不是原来的那个条目。
RecyclerView 时代我们关心 ViewHolder 复用,到 Compose 里,我们要关心的是 key 是否稳定。key 不稳定,快速滑动时很容易出现某一行的选中态被另一行“继承”的情况。这个问题的本质不是状态存错了,而是 LazyColumn 把新 item 认成了滚动前那个 item,复用了它的状态。所以单选多选的稳定性,第一步是保证 key 用唯一业务 id。
另外,Compose 的固有特性测量(Intrinsic Measurement)通常用于自定义布局在不确定尺寸时计算子项大小。LazyColumn 的 item 高度如果是动态的、且需要互相测量,性能会非常差。我见过有人在多选列表里让每行的 Checkbox 根据行高做自定义绘制,结果列表滚动频繁卡顿。多选列表的行高应该尽量固定,不要依赖其他行的高度。
4.2 用 derivedStateOf 收敛派生计算
多选列表经常需要显示“已选 n 项”这样的统计数字。如果你的统计逻辑直接在 Composable 顶层写:
val selectedCount = items.count { it.id in selectedIds }这段代码会在 items 或 selectedIds 变化时跟随父组件一起重组,计算本身没问题,但它发生在组合阶段,如果 items 有几千条,每次文本变化都会重新遍历列表。
更好的做法是用derivedStateOf把派生计算延迟到状态真正变化时,并且避免统计结果影响列表项渲染:
val selectedCount by remember(items) { derivedStateOf { items.count { it.id in selectedIds } } }注意remember(items)的作用:当 items 引用变化时,重新创建 derivedStateOf。块内部读取了selectedIds,因此selectedIds变化时,derivedStateOf会自动重算selectedCount。这样做的好处是,统计逻辑不再参与列表项的每次重组,只有依赖的selectedIds和items变化时才刷新。
如果你的多选列表还承担实时过滤职责,比如根据已选标签过滤子列表,同样可以把这个过滤逻辑放到remember里:
val visibleItems by remember(filterIds) { derivedStateOf { allItems.filter { it.tagId in filterIds } } }这比在 ViewModel 里提前计算更符合 Compose 的流式风格,也方便测试。
4.3 单选多选最常见的三个坑
我梳理了三个和单选多选相关的高频问题,遇到时可以对照排查。
| 现象 | 原因 | 解决方式 |
|---|---|---|
| Checkbox 点一下后状态反复跳变 | 行点击和 Checkbox 点击同时触发 | Row 用selectable,Checkbox 的onCheckedChange传 null |
| 快速滑动后选项选中错乱 | 用 index 做 key,或选中状态放在 item 内部 | 用稳定 id 做 key,状态统一提升到列表外 |
| 屏幕旋转后选中项丢失 | remember不保存配置变更状态 | 改成rememberSaveable或 ViewModel 持有 |
这里要特别说明第一行。很多人习惯只在 Checkbox 上加onCheckedChange,点一行里的空白区域没反应,于是又给 Row 加clickable。两个事件源叠加,快速触摸时可能触发两次,单选表现不出来,多选会表现为“选中两次等于取消”。正确做法是让最外层行作为唯一的点击接收者,内部控件只显示状态。
选择动画也是多选列表容易被忽略的地方。如果给每行加animateItemPlacement(),要小心选中删除时的动画和状态更新顺序。删除场景下,不要先把 id 从集合里移除再操作列表,那样动画会捕捉不到原始位置。一般做法是让数据源变化带动 item 变化,选中集合保持不变,删除完成后统一清理集合。
5. 选择逻辑做纯函数封装,复用到任意筛选列表
5.1 用两个纯函数收口选择状态
前面几章已经把单选多选的 UI 写出来了。如果项目里有多个列表,比如订单状态筛选、SKU 规格选择、好友多选,每个地方都写一套selectedIds的增删逻辑,代码会重复。我的做法是把选择逻辑抽成纯函数。
fun toggleSelection(current: Set<Long>, id: Long): Set<Long> = if (id in current) current - id else current + id fun selectOnly(current: Set<Long>, id: Long): Set<Long> = setOf(id)这两个函数不依赖任何 Compose 上下文,传入集合和 id,返回新集合。ViewModel、Composable、单元测试都能直接调用。在列表页里使用:
selectedIds = toggleSelection(selectedIds, item.id)这样要比在 SelectionState 类里写 N 个方法更直白。纯函数的好处是输入输出确定,后续如果要加“最多选 5 个”的限制,只需要在外面包一层判断,不需要改动调用方。
5.2 在筛选页同时使用单选和多选
最后给一个实际应用技巧:筛选页通常左边一排多选标签,右边一个单选排序,两个状态同时决定列表请求参数。
val sortState = rememberSingleSelectState(1L) val filterState = rememberMultiSelectState() LaunchedEffect(sortState.value, filterState.value) { viewModel.loadProducts( sortType = sortState.value, tagIds = filterState.value ) }LaunchedEffect会把两个状态当作 key,任何一边变化都会重新请求列表。所需状态类型和上面完全一致,单选是Long?,多选是Set<Long>。这样写出来,UI 层不需要关心是点击触发还是状态恢复触发,只要状态变了就拉数据,这也是 Compose 声明式列表交互里最值得复用的部分。
注意,LaunchedEffect的请求结果回来后,要用remember(itemKey)或者key(itemId)保证 LazyColumn 能正确定位。如果返回列表推翻了之前的状态模型,比如后台把 item id 换了,就需要在 ViewModel 里做一次 id 映射,把旧选中 id 过滤掉。这个边界情况在长列表分页加载时很常见,提前做好防御,单选多选才不会在数据刷新后失效。
本文还有配套的精品资源,点击获取