ARTICLE DETAIL

资讯详情

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

Jetpack Compose列表单选多选实战:状态模型与性能优化

Jetpack Compose列表单选多选实战:状态模型与性能优化 简介无论你是刚接触 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? 还是 Set2.1 单选和多选在状态形状上的差异单选和多选在 UI 上只是 RadioButton 与 Checkbox 的差异但状态模型完全不同。单选选中值只能有一个用Long?表示最合适空值代表未选中非空代表当前选中项的 id。多选必须记住多个值用SetLong更合理重复点击同一个 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): MutableStateLong? rememberSaveable { mutableStateOf(initialId) } Composable fun rememberMultiSelectState(initialIds: SetLong emptySet()): MutableStateSetLong rememberSaveable { mutableStateOf(initialIds) }这里用rememberSaveable而不是remember是因为remember在 Activity 重建、系统回收时会丢失。rememberSaveable会把初始值写入 Bundle旋转屏幕后选中状态还在。第二个函数的参数是SetLong它在 Android 平台上可被 Bundle 自动保存不需要额外写 Saver。如果你的选中集合非常大或者需要跨页面共享可以在 ViewModel 中用MutableStateFlowclass ListScreenViewModel : ViewModel() { private val _selectedIds MutableStateFlowSetLong(emptySet()) val selectedIds: StateFlowSetLong _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: SetLong emptySet()) { private val _selectedIds mutableStateOf(initialIds) val selectedIds: SetLong 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: SetLong emptySet()): SelectionState remember { SelectionState(initialIds) }这里有几个细节值得说明。SetLong id和SetLong - id都是生成新 Set而不是修改原对象Compose 才能检测到状态变化。isSelected放在类里是为了让列表项组合函数里写state.isSelected(user.id)更可读。注意remember没有把initialIds作为 key因为初始值只在第一次创建时有效后续回传新的初始集合不会主动重置状态这正好符合我们的预期状态是页面自有的不受外部参数变化影响。单选和多选在状态形状上的对应关系可以归纳成下面这张表。维度单选多选状态类型Long?SetLong默认值nullemptySet()切换操作直接赋新值在集合中增删对应组件RadioButtonCheckbox语义角色Role.RadioButtonRole.Checkbox典型场景排序方式、收货地址标签筛选、批量删除这张表对后续写列表项很有用组件的selected参数和状态判定完全由 id 决定item 自身不持有任何选中信息。3. LazyColumn 渲染列表RadioButton 和 Checkbox 实战3.1 item、key 与列表数据绑定LazyColumn 区别于普通 Column 的核心是懒加载它只组合当前可视区域的 item。但这也带来一个约束每一个 item 必须有稳定的身份否则滚动复用后Compose 无法判断哪个 item 对应哪个状态。最直接的做法是利用items的key参数。Composable fun UserList( users: ListUser, 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: ListSelectableItem, 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的参数看起来简单实际用起来有些细节。下表是我常用的几个参数说明。参数类型作用注意事项selectedBoolean当前项是否被选中状态来自外部不能是 item 内部可变值roleRole?无障碍语义角色单选传Role.RadioButton多选传Role.CheckboxonClick() - Unit点击回调在这里做选中状态更新interactionSourceInteractionSource?点击水波纹等效果来源一般留空用默认值3.3 多选列表完整代码与点击冲突处理多选和单选的 UI 结构几乎一样只有状态判定和语义角色不同。多选时一个 id 是否被选中取决于这个 id 是否在selectedIds集合里。Composable fun MultiSelectList( items: ListSelectableItem, selectedIds: SetLong, 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 用selectableCheckbox 的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: SetLong, id: Long): SetLong if (id in current) current - id else current id fun selectOnly(current: SetLong, id: Long): SetLong 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?多选是SetLong。这样写出来UI 层不需要关心是点击触发还是状态恢复触发只要状态变了就拉数据这也是 Compose 声明式列表交互里最值得复用的部分。注意LaunchedEffect的请求结果回来后要用remember(itemKey)或者key(itemId)保证 LazyColumn 能正确定位。如果返回列表推翻了之前的状态模型比如后台把 item id 换了就需要在 ViewModel 里做一次 id 映射把旧选中 id 过滤掉。这个边界情况在长列表分页加载时很常见提前做好防御单选多选才不会在数据刷新后失效。本文还有配套的精品资源点击获取
返回列表