RecyclerView 长按多选实战:Android Contextual Action Mode 详解与封装
前阵子做消息类应用的列表批量操作功能原型代号就叫context-mode。开始以为无非是长按弹出几个菜单真正动手才发现Android 框架里的 Contextual Action Mode上下文操作模式远比我想象的讲究菜单怎么写、选择状态放哪里、旋转屏幕怎么办、ActionMode 的生命周期怎么和 RecyclerView 配合每一处都是坑。这篇文章就把我踩过的坑和封装经验从头到尾讲清楚适合刚接触 Android 列表交互、打算在 RecyclerView 上实现长按多选批量操作的开发者参考。1. context-mode 到底是什么为什么需要它1.1 从一个批量操作场景说起日常开发里经常遇到这种需求某个列表页用户长按一条消息后希望顶部浮出一条操作条上面有“删除、转发、全选”之类的选项同时列表里的条目进入多选状态点一下就能选中或取消。这个交互模式在 Android 里有个官方术语叫Contextual Action Mode也就是上下文操作模式。我们内部一般简写为 context-mode项目名也直接叫这个。它和普通 ContextMenu 最大的区别是ContextMenu 是一块居中的弹窗菜单选完就消失而 ActionMode 会替换掉原本的 ActionBar在屏幕顶部生成一条专门的动作条并且随着选择数量变化实时刷新标题比如“已选择 3 项”。在邮箱、相册、文件管理器这类 App 里你几乎每天都能看到它属于系统级交互范式不是某个第三方库的临时方案。当时我为什么非要用 ActionMode而不是自己做一个悬浮布局原因很简单ActionMode 有系统级的生命周期管理、有标准的菜单处理机制、和 AppCompat 紧密集成在低版本 Android 上也能通过兼容库保持一致。自己写一个悬浮条当然也可以但按下返回键、点击外部区域、菜单按钮展开这些系统行为全部要自己处理工作量并不小而且很容易和 Toolbar 的现有逻辑打架。1.2 ActionMode、ContextMenu、多选模式三者的区别很多刚接触的同学搞不清这三个概念我把它们放在一起对比过差别还是很明显的。交互形态触发方式界面表现适用场景ContextMenu长按弹出屏幕中央弹窗类似 Dialog单条数据上的少量操作如“复制、重命名”ActionModecontext-mode长按或点击进入顶部动作条替换 ActionBar随选中项实时变化批量操作、多选编辑如“全选、删除”多选模式多选框/编辑态点击按钮进入列表内出现 CheckBox顶部可有普通 Toolbar明确“管理/编辑”场景配合底部按钮这里的核心区别在于状态是否持续。ContextMenu 用完即走不改变列表状态ActionMode 一旦启动整个列表就处于“上下文操作”的特殊状态这个状态由 ActionMode 本身携带着退出时就自动清理而多选模式一般是自己在 Fragment 或 Activity 里维护布尔值灵活性高但和系统交互解耦需要自己处理返回键、动画和状态恢复。action-mode 并不排斥 CheckBox实际项目中我经常做成“进入 context-mode 后每行右侧出现 CheckBox”这反而符合用户直觉。真正排斥的是把 ActionMode 当成普通弹窗来用那才是本末倒置。1.3 学这个项目前需要准备的基础既然标题是 context-mode我默认读者手上至少有一个能跑的 RecyclerView 列表。具体需要准备的东西不多已接入 AppCompat 主题的 ActivityRecyclerView 和基础 Adapter 的写法简单菜单文件res/menu 下的 XML对 Kotlin 基础语法不陌生如果这些都没问题那这篇文章里的代码你基本可以直接抄。我后面会给出完整的数据结构、Adapter 和 Callback 实现并且把每个方法在生命周期里所处的位置解释清楚。文中用的 Kotlin 版本是 Android 开发当前主流的写法Java 项目照着翻译即可核心 API 完全一致。2. 核心机制拆解ActionMode 的生命周期与调用链2.1 整个交互的完整调用链在写代码之前我觉得有必要先把 ActionMode 的调用链在脑子里过一遍。它不是一个孤立的类而是由 Activity、Callback、Menu、Adapter 四部分共同协作完成的。完整流程是这样的用户长按 RecyclerView 的某个条目Adapter 捕获长按事件。Adapter 回调 Activity或者 ViewModel里的一个方法方法里调用startSupportActionMode(callback)。系统创建 ActionMode回调onCreateActionMode此时填充菜单。系统再回调onPrepareActionMode此时可以刷新标题或调整菜单项状态。列表进入多选状态用户继续点击其他条目Adapter 更新选中集合并调用actionMode.invalidate()。invalidate()触发onPrepareActionMode再次回调更新标题和菜单。用户点击菜单项进入onActionItemClicked执行批量操作。操作完成调用actionMode.finish()退出。系统回调onDestroyActionMode在这里清空所有选中状态。其中第 5 步容易被忽略很多人以为标题更新应该自己写个方法去 setTitle实际上标准做法是改完数据后调用invalidate()让系统重新走一遍onPrepareActionMode。这样做的好处是后续想加“全选后禁用某菜单项”之类的需求不需要额外写刷新逻辑全都在预备阶段统一处理。2.2 startActionMode 与 startSupportActionMode 的选型这两个方法经常让人困扰。直接说结论如果你的 Activity 继承自 AppCompatActivity优先用startSupportActionMode。startActionMode是 API 11 就有的原生方法但它返回的是原生 ActionMode和 appcompat 库的ActionMode不是同一个类型两者在菜单处理上有差异。startSupportActionMode会创建支持库版本的 ActionMode功能一致而且能兼容低版本系统上的生命周期行为。你用 AppCompatActivity 再调原生方法会得到一个类型不匹配的 ActionMode之后在invalidate()和setTitle()上出现诡异问题。另外注意一个界面同时只能存在一个 ActionMode。如果已经处于 context-mode 中再次调用startSupportActionMode系统不会创建新实例而是直接返回 null 或者无操作。所以需要先判断actionMode null再创建。2.3 Callback 四个回调方法逐个说清楚ActionMode.Callback 是核心接口一共四个方法onCreateActionMode(ActionMode mode, Menu menu)在这里填充菜单使用menuInflater.inflate(R.menu.menu_context_mode, menu)。如果返回 false模式会直接结束所以这里正常情况下返回 true。onPrepareActionMode(ActionMode mode, Menu menu)每次进入预备阶段都会调用。典型场景是更新标题文字比如“已选择 n 项”或者根据选中状态切换“全选/取消全选”菜单项。返回值同样表示菜单是否更新。onActionItemClicked(ActionMode mode, MenuItem item)菜单点击入口。处理完自己的逻辑后如果希望系统自动结束 ActionMode就返回 true如果希望保持模式继续就返回 false。这里有个坑后文专门说。onDestroyActionMode(ActionMode mode)模式销毁回调不区分是正常完成还是被取消。所有清理工作必须放这里否则列表会残留选中状态。这四个方法的调用顺序和频率搞明白了整个 context-mode 的骨架就搭起来了。接下来看具体实现。3. 从零实现RecyclerView 长按进入 context-mode3.1 项目结构与数据模型我习惯把这个功能拆成三个部分数据模型、Adapter、ActionModeCallbck。项目代号是 context-mode整体结构大致如下item_data.kt // 列表条目数据模型 item_adapter.kt // RecyclerView Adapter维护选中集合 context_mode_callback.kt // ActionMode.Callback 实现 main_activity.kt // 宿主容器创建 ActionMode数据模型很简单为了演示加一个选中状态字段data class ItemData( val id: Long, val title: String, var selected: Boolean false )实际开发中这个字段不一定需要更常见的是在 Adapter 里维护一个HashSetLong来记录选中 id 集合。我推荐用集合因为选中状态的增删和查询都是 O(1)而且后续做全选、反选都比较方便。把selected塞进实体类里也不是不行但如果数据是数据库里的每次列表刷新都要重新赋值反而麻烦。3.2 适配器里维护选中状态我现在直接给一版简化但完整的 Adapter 代码重点看选中状态怎么管理class ItemAdapter( private val items: MutableListItemData, private val onItemClick: (ItemData) - Unit, private val onItemLongClick: (ItemData, Int) - Unit ) : RecyclerView.AdapterItemAdapter.ViewHolder() { private val selectedIds HashSetLong() var isActionMode false private set fun getSelectedIds(): SetLong selectedIds.toSet() fun toggleSelection(position: Int) { val item items[position] if (selectedIds.contains(item.id)) { selectedIds.remove(item.id) item.selected false } else { selectedIds.add(item.id) item.selected true } notifyItemChanged(position) } fun selectAll() { selectedIds.clear() items.forEach { it.selected true selectedIds.add(it.id) } notifyDataSetChanged() } fun cancelAll() { selectedIds.clear() items.forEach { it.selected false } notifyDataSetChanged() } fun clearSelection() { selectedIds.clear() items.forEach { it.selected false } notifyDataSetChanged() } inner class ViewHolder(val binding: ItemViewBinding) : RecyclerView.ViewHolder(binding.root) // onCreateViewHolder、onBindViewHolder 省略 }在onBindViewHolder里绑定颜色和点击事件时需要根据isActionMode和item.selected决定 UI 表现override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item items[position] val isSelected item.selected holder.binding.root.isActivated isSelected holder.binding.root.setOnClickListener { if (isActionMode) { toggleSelection(position) } else { onItemClick(item) } } holder.binding.root.setOnLongClickListener { onItemLongClick(item, position) true } }isActivated是一个很容易被忽略的属性。配合在布局里定义selector可以在选中和未选中之间切换背景色比手动 setBackgroundColor 更干净而且 RecyclerView 复用 ViewHolder 时不会出现状态错乱。Selector 文件大概长这样selector xmlns:androidhttp://schemas.android.com/apk/res/android item android:state_activatedtrue android:drawablecolor/selected_bg/ item android:drawableandroid:color/transparent/ /selector3.3 长按触发与点击切换关键逻辑在宿主里。我从 MainActivity 里抽出核心代码class MainActivity : AppCompatActivity() { private lateinit var adapter: ItemAdapter private var actionMode: ActionMode? null private val callback object : ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { mode.menuInflater.inflate(R.menu.menu_context_mode, menu) return true } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { val count adapter.getSelectedIds().size mode.title if (count 0) else 已选择 $count 项 mode.invalidate() return true } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_delete - { deleteSelectedItems() mode.finish() true } R.id.action_select_all - { toggleSelectAll() true } else - false } } override fun onDestroyActionMode(mode: ActionMode) { adapter.clearSelection() actionMode null } } private fun startContextMode() { if (actionMode null) { actionMode startSupportActionMode(callback) adapter.isActionMode true } } // 在 initRecyclerView 里绑定长按回调 private fun bindLongClick() { adapter ItemAdapter( items getItems(), onItemClick { /* 普通点击逻辑 */ }, onItemLongClick { item, position - if (actionMode null) { // 先把长按的这条加入选中集合 adapter.toggleSelection(position) startContextMode() } } ) } }这段代码有个细节长按第一项时先toggleSelection(position)再startContextMode()。因为startSupportActionMode只负责创建 ActionMode不会自动帮你选中第一个条目如果你先启动模式再选数据会有一瞬间顶部动作条已经出现但列表还没有任何选中项观感很差。先选中再启动标题一出现就已经是“已选择 1 项”体验上流畅很多。3.4 ActionMode 菜单与标题动态更新菜单文件和普通菜单没什么区别menu xmlns:androidhttp://schemas.android.com/apk/res/android item android:idid/action_select_all android:title全选 android:showAsActionalways / item android:idid/action_delete android:title删除 android:showAsActionalways / /menu这里要注意showAsAction的值。如果是always菜单项直接显示在动作条上如果是ifRoom在小屏设备上可能会被折叠进菜单。批量删除这种高频操作我建议都设为always。标题更新是另一个容易翻车的地方。很多人直接在onPrepareActionMode里mode.title ...结果发现第一次创建时标题没更新或者更新过头导致卡顿。原因在于onPrepareActionMode只在invalidate()时触发创建流程本身不会调用它。所以如果进入模式时需要设置初始标题应该在onCreateActionMode里也设置一次或者先调用mode.invalidate()。另外不要做“节流”之类的自作聪明优化。ActionMode 的标题刷新本身非常轻量每次点击一条就notifyItemChanged(position)加上invalidate()完全不会卡。真正卡的是notifyDataSetChanged()这个后面专门说。3.5 全选、删除与模式退出全选和删除是 context-mode 里最常见的两个操作。先看全选逻辑private fun toggleSelectAll() { val count adapter.itemCount val selectedCount adapter.getSelectedIds().size if (selectedCount count) { adapter.cancelAll() } else { adapter.selectAll() } actionMode?.invalidate() }toggleSelectAll里判断依据是“当前已选数量是否等于总数”相等就取消全选否则全选。这里有个逻辑隐患如果列表长度为 0selectedCount count成立会执行cancelAll()但 0 条数据时本来就不应该有全选菜单所以最好在onPrepareActionMode里根据列表长度隐藏全选项。删除我模拟的是批量删除数据源并刷新private fun deleteSelectedItems() { val selected adapter.getSelectedIds() val newItems items.filterNot { selected.contains(it.id) }.toMutableList() items.clear() items.addAll(newItems) adapter.notifyDataSetChanged() }我在真实项目里通常会把items放在 ViewModel 中删除逻辑移到 ViewModel 里操作数据库Adapter 再通过观察者刷新。上面的写法只是为了演示流程不要把它当成生产级代码。重点在于mode.finish()之后onDestroyActionMode里的clearSelection()会再次刷新列表所以删除后不要手动cancelAll()否则会产生一次无用刷新浪费性能。退出动作条还有一个注意点用户按返回键时系统默认会关闭 ActionMode。但如果你的 Activity 重写了onBackPressed()要记得把“先关闭 ActionMode”的逻辑加进去override fun onBackPressed() { if (actionMode ! null) { actionMode?.finish() return } super.onBackPressed() }不处理的话返回键会直接退出整个页面而不是退出多选模式这个体验问题在 QA 测试时几乎必报。4. 真实项目中的坑与排查笔记4.1 点击外部区域导致 ActionMode 意外关闭系统原生 ActionMode 有一个隐藏行为点击动作条之外的区域比如列表的空白处会自动关闭模式。这在邮箱里是合理的但有些业务场景不想要这个行为比如“用户正在一页一页选联系人不小心点空白就全白选了”非常致命。处理方案有多种我的做法是给 RecyclerView 设置一个空白的根布局拦截点击binding.root.setOnClickListener { // 空实现用于吃掉自动消失的点击事件 }但这里有个问题RecyclerView 本身会处理自己的触摸事件根布局的setOnClickListener不一定能拦截空白区域。更可靠的做法是在 Activity 层面重写dispatchTouchEvent判断点击位置是否落在 ActionMode 区域之外如果落在了列表上就手动接管。不过这个方案复杂而且容易误伤。实测下来真正高频出现的问题是用户点击列表 item 后ActionMode 也会关闭——这其实是“点击非选中项应该取消选中而不是退出模式”的需求需要你在 item 的onClick里判断holder.binding.root.setOnClickListener { if (isActionMode) { toggleSelection(position) } }只要 item 的点击事件被正确消费系统就不会把这次触摸当作“空白区域点击”ActionMode 也就不会自动关闭。所以问题往往不是系统专注而是你没有处理 item 的点击事件导致触摸事件向上冒泡到了 Activity 的根节点。4.2 旋转屏幕后 ActionMode“消失”的经典问题旋转屏幕是 context-mode 最容易暴露问题的场景。默认情况下Activity 重建后actionMode变量变成了 null但列表数据如果是通过savedInstanceState恢复的selectedIds还想重新恢复那就会处于“模式已退出但选中状态还在”的撕裂状态。要解决有两个思路。简单方案是旋转时就退出模式禁止多选状态跨配置变更。在onSaveInstanceState里不需要保存选定集合只保存普通列表数据因为 ActionMode 本来就是瞬时交互状态和键盘弹起、对话框一样没有“恢复”的必要。多数 App 都是这么做的用户旋转屏幕后看到列表但多选状态消失也算可接受。进阶方案是用 ViewModel 保存selectedIds和isActionMode然后把actionMode的创建放到onResume里根据 ViewModel 状态重新启动。这个方案体验好但代码复杂度高一截需要恢复模式、重新填充菜单、刷新标题、并且处理 ViewModel 中状态和 Adapter 中集合不同步的问题。我个人建议第一个版本先做简单方案等需求明确后再升级。还有一点在 Activity 的onDestroy里如果actionMode ! null应该调用actionMode?.finish()避免 Activity 被销毁时还有未清理的回调。虽然系统会释放但养成这个习惯能减少很多内存泄漏相关的玄学问题。4.3 菜单项失效与“点击即退出”的细节onActionItemClicked的返回值决定点击菜单后是否立刻关闭 ActionMode。很多人全部 return true结果点“全选”之后动作条直接没了逻辑根本走不完。我的原则是操作目标是“完成批量动作”比如删除、归档、移动返回 true让系统退出模式。操作目标是“改变当前选择集合”比如全选、反选、排序返回 false保持模式存活。invalidate()时要小心“全选”菜单项文案切换。我会在onPrepareActionMode里读取selectedIds.size和列表总长度做比较动态设置菜单标题override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { val count adapter.getSelectedIds().size mode.title if (count 0) 请选择 else 已选择 $count 项 val selectAllItem menu.findItem(R.id.action_select_all) if (count adapter.itemCount) { selectAllItem.title 取消全选 } else { selectAllItem.title 全选 } return true }这里还有个隐藏问题如果列表长按启动 ActionMode 时只选中了一项此时用户点击“全选”selectAll()后 count 和 itemCount 相等于是标题变成“取消全选”。但如果此时直接点击列表空白处退出模式一切正常。可如果用户从全切到部分再切到全往复几次菜单项的标题可能因为状态未刷新而卡在旧文案。这个问题的坑根在于onPrepareActionMode依赖系统回调不会主动触发所以你必须在每次invalidate()之后确保 Adapter 内的集合已经同步。我通常会在toggleSelection、selectAll、cancelAll三个方法里同时打印日志配合验证状态一致性。4.4 列表刷新时的性能与状态一致性ActionMode 不卡但多人容易在刷新方式上犯错。选中状态通常是一点一点变的如果每一次toggleSelection后调用notifyDataSetChanged()数据量大时 RecyclerView 的全部 ViewHolder 都会重绘即使选中项只有一行也会从头到尾闪一遍背景色视觉上就是“白屏闪烁”。正确做法是选中哪一行就更新哪一行用notifyItemChanged(position)。这个不只是性能问题还牵扯状态一致性全量刷新会让onBindViewHolder把所有行重新绑定如果你在onBindViewHolder里读取了isActionMode做判断可能刚启动模式的一瞬间所有未选中的行也变成“可选中样式”而用户可能觉得模式还没开始就被 UI 变化吓一跳。选中集合的数据结构也值得多说一句。不要在实体类里维护selected然后在Holder里直接改字段因为notifyItemChanged触发后绑定新 ViewHolder 时原来的复用实例可能残留旧状态。HashSetLong配合isActivated状态是我在多个项目里用下来最稳的组合。4.5 兼容性速查表环境使用方案注意事项Activity 继承 AppCompatActivitystartSupportActionMode必须使用 appcompat 的 Callback 类型Activity 继承普通 ActivitystartActionMode低版本系统菜单样式较简陋Fragment 中使用通过requireActivity()启动ActionMode 跨 Activity 生命周期无 ActionBar 的 Toolbar 页面AppCompat 自动处理确保主题继承 Theme.AppCompat启用windowActionModeOverlay动作条悬浮覆盖适合列表顶部不想被顶起的场景低版本兼容性问题主要出现在菜单图标上建议菜单项全部用文字标题不要用图标省去兼容适配工作。5. 把 context-mode 封装成通用组件5.1 泛型化思路同一个项目里往往有消息列表、联系人列表、文件列表每个都要写一套几乎相同的 ActionMode.Callback很冗余。我在项目后期把 context-mode 封装成了一个泛型组件核心思路是抽出“选择集合 ActionMode 管理”到独立类里宿主只需要提供一个菜单资源和批量操作回调。一个极简设计如下class ContextModeControllerT( private val menuResId: Int, private val onBatchAction: (SetLong) - Unit ) : ActionMode.Callback { private var actionMode: ActionMode? null private val selectedIds HashSetLong() fun toggle(id: Long, position: Int) { if (actionMode null) return if (!selectedIds.add(id)) selectedIds.remove(id) actionMode?.invalidate() } fun start(activity: AppCompatActivity) { if (actionMode null) { actionMode activity.startSupportActionMode(this) } } override fun onDestroyActionMode(mode: ActionMode) { selectedIds.clear() actionMode null } }封装之后Activity 里只需要关心两个问题进入模式时启动 controller退出模式时清理状态。菜单点击里的具体业务逻辑通过回调抛给宿主不用每写一个新列表就把 Callback 四个方法重抄一遍。5.2 与 ViewModel Room 的批量删除联动真实项目里批量操作几乎都是数据库操作。以 Room 为例大致流程是ActionMode 菜单点击删除。获取selectedIds。调用 ViewModel 里的deleteByIds(ids)内部执行Query(DELETE FROM item_table WHERE id IN (:ids))。删除成功后 ViewModel 的 LiveData/Flow 自动发出新列表。Adapter 观察新数据刷新 UI。删除完成后调用actionMode?.finish()。这里要小心顺序问题删除是异步的如果在删除请求发出后立刻finish()而 Room 删除还没执行完列表数据刷新时onDestroyActionMode已经清空了选中集合用户可能看到旧数据闪一下再消失。我一般会把actionMode?.finish()放在删除操作的回调里或者干脆放在onBatchAction的后续处理里自行控制而不是在点击菜单时立刻结束。5.3 还能扩展的细节振动、无障碍、动画最后聊几个能让功能质感提升一个档次的细节。进入 context-mode 时长按触发系统振动反馈是一个非常小的改动itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)这个方法不需要申请VIBRATE权限直接用系统自带的反馈通道体验立刻从“网页版”变成“原生 App”的感觉。无障碍方面ActionMode 的标题变化建议通过mode.title结合无障碍文本一起通知列表项被选中或取消时给itemView添加上下文描述比如“已选中”或“未选中”。这套东西在新版本 Android 的无障碍测试里是加分项。动画方面itemView.isActivated的切换其实有默认的背景变化但如果你用了自定义背景建议给背景色加一个短促的交叉淡入淡出否则“全选”速度很快时列表会显得特别生硬。可以在 Selector 里加上彩色切换结合android:animateLayoutChanges或者直接用 Transitions API 处理看团队习惯即可。我个人在实际项目里的体会是ActionMode 并不复杂但它的“正确用法”藏在细节里。表面上是一个顶部动作条实际牵扯到触摸事件分发、集合状态同步、生命周期与列表刷新顺序。这些坑不踩一遍永远不会真正理解 Android 对“上下文操作”这个交互形态的抽象设计。希望这篇文章能让你少走几段我走过的弯路直接写出一个稳、体验好、能复用的 context-mode 组件。