前阵子做消息类应用的列表批量操作,功能原型代号就叫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 | 单条数据上的少量操作,如“复制、重命名” |
| ActionMode(context-mode) | 长按或点击进入 | 顶部动作条替换 ActionBar,随选中项实时变化 | 批量操作、多选编辑,如“全选、删除” |
| 多选模式(多选框/编辑态) | 点击按钮进入 | 列表内出现 CheckBox,顶部可有普通 Toolbar | 明确“管理/编辑”场景,配合底部按钮 |
这里的核心区别在于状态是否持续。ContextMenu 用完即走,不改变列表状态;ActionMode 一旦启动,整个列表就处于“上下文操作”的特殊状态,这个状态由 ActionMode 本身携带着,退出时就自动清理;而多选模式一般是自己在 Fragment 或 Activity 里维护布尔值,灵活性高,但和系统交互解耦,需要自己处理返回键、动画和状态恢复。
action-mode 并不排斥 CheckBox,实际项目中我经常做成“进入 context-mode 后每行右侧出现 CheckBox”,这反而符合用户直觉。真正排斥的是把 ActionMode 当成普通弹窗来用,那才是本末倒置。
1.3 学这个项目前需要准备的基础
既然标题是 context-mode,我默认读者手上至少有一个能跑的 RecyclerView 列表。具体需要准备的东西不多:
- 已接入 AppCompat 主题的 Activity
- RecyclerView 和基础 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-mode
3.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 里维护一个HashSet<Long>来记录选中 id 集合。我推荐用集合,因为选中状态的增删和查询都是 O(1),而且后续做全选、反选都比较方便。把selected塞进实体类里也不是不行,但如果数据是数据库里的,每次列表刷新都要重新赋值,反而麻烦。
3.2 适配器里维护选中状态
我现在直接给一版简化但完整的 Adapter 代码,重点看选中状态怎么管理:
class ItemAdapter( private val items: MutableList<ItemData>, private val onItemClick: (ItemData) -> Unit, private val onItemLongClick: (ItemData, Int) -> Unit ) : RecyclerView.Adapter<ItemAdapter.ViewHolder>() { private val selectedIds = HashSet<Long>() var isActionMode = false private set fun getSelectedIds(): Set<Long> = 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:android="http://schemas.android.com/apk/res/android"> <item android:state_activated="true" android:drawable="@color/selected_bg"/> <item android:drawable="@android:color/transparent"/> </selector>3.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:android="http://schemas.android.com/apk/res/android"> <item android:id="@+id/action_select_all" android:title="全选" android:showAsAction="always" /> <item android:id="@+id/action_delete" android:title="删除" android:showAsAction="always" /> </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里判断依据是“当前已选数量是否等于总数”,相等就取消全选,否则全选。这里有个逻辑隐患:如果列表长度为 0,selectedCount == 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 时,原来的复用实例可能残留旧状态。HashSet<Long>配合isActivated状态,是我在多个项目里用下来最稳的组合。
4.5 兼容性速查表
| 环境 | 使用方案 | 注意事项 |
|---|---|---|
| Activity 继承 AppCompatActivity | startSupportActionMode | 必须使用 appcompat 的 Callback 类型 |
| Activity 继承普通 Activity | startActionMode | 低版本系统菜单样式较简陋 |
| Fragment 中使用 | 通过requireActivity()启动 | ActionMode 跨 Activity 生命周期 |
| 无 ActionBar 的 Toolbar 页面 | AppCompat 自动处理 | 确保主题继承 Theme.AppCompat |
启用windowActionModeOverlay | 动作条悬浮覆盖 | 适合列表顶部不想被顶起的场景 |
低版本兼容性问题主要出现在菜单图标上,建议菜单项全部用文字标题,不要用图标,省去兼容适配工作。
5. 把 context-mode 封装成通用组件
5.1 泛型化思路
同一个项目里往往有消息列表、联系人列表、文件列表,每个都要写一套几乎相同的 ActionMode.Callback,很冗余。我在项目后期把 context-mode 封装成了一个泛型组件,核心思路是抽出“选择集合 + ActionMode 管理”到独立类里,宿主只需要提供一个菜单资源和批量操作回调。
一个极简设计如下:
class ContextModeController<T>( private val menuResId: Int, private val onBatchAction: (Set<Long>) -> Unit ) : ActionMode.Callback { private var actionMode: ActionMode? = null private val selectedIds = HashSet<Long>() 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 组件。