我上个月在做一个文件管理器类的应用时,遇到一个很典型的交互需求:列表里的文件支持长按弹出操作菜单,同时还要支持多选后通过顶部工具栏批量删除、重命名。刚开始我打算用两个方案分开做,结果踩了一堆坑,后来才彻底把ContextMenu和Contextual Action Mode这两套机制的使用边界和实现细节摸透。这篇文章就把我的理解、代码实现和实际踩坑记录都整理出来,希望对正在做同类交互的同学有帮助。
1. 先搞清楚两套机制的区别:长按菜单和批量操作栏,不是一回事
很多新手会把ContextMenu和Contextual Action Mode混淆,因为它们都是长按触发的。但它们的交互定位和使用场景有本质区别,选错了用户在操作上会非常别扭。
1.1 ContextMenu是什么:悬浮在屏幕中央的上下文菜单
ContextMenu是Android最早提供的上下文操作方案。长按某个View,会在屏幕中央弹出一个浮动的菜单列表,菜单项以文本形式平铺排列。它的优点是直观简单,缺点也很明显:不支持批量操作,菜单项多了之后体验很差,而且手机屏幕越来越大,中央弹出的菜单在单手操作时很难够到。
这种模式适合只需要对单个条目做少量轻量操作的场景。比如长按一条短信弹出“删除”“转发”“复制到剪贴板”,长按一条联系人弹出“拨打电话”“发送短信”。
1.2 Contextual Action Mode是什么:屏幕顶部的操作工具栏
Contextual Action Mode(简称ActionMode或CAB)是Android 3.0引入的交互模式,它把原本中间弹出的菜单变成一条工具栏,固定在屏幕顶部,替代了原本的ActionBar位置。用户长按进入操作模式之后,可以继续点击其他条目实现多选,顶部工具栏会实时显示选中数量以及可执行的操作项,比如删除、分享、移动等。
这种模式天然适合批量处理场景。典型例子是系统图库的相册多选、邮件列表的批量归档、文件管理器的批量删除。
1.3 选型对照表
| 维度 | ContextMenu | Contextual Action Mode |
|---|---|---|
| 触发方式 | 长按单个View | 长按进入模式,可继续点选多个 |
| 菜单展示位置 | 屏幕中央浮动菜单 | 屏幕顶部工具栏 |
| 是否支持多选 | 不支持,只针对单个条目 | 支持,配合列表多选 |
| 菜单项呈现 | 纯文本列表 | 图标+可折叠的文本 |
| 适用场景 | 单条目的轻量操作 | 批量操作、复杂条目的操作集合 |
| 与旧版兼容性 | 所有版本都可用 | API 11+,低版本需要v7兼容包 |
这里多说一句:很多产品在需求阶段根本不会区分这两种模式,直接说“长按弹菜单”。作为开发我们要有意识地向产品建议:如果有批量操作需求,优先用ActionMode;如果只是单个条目的几个动作,用ContextMenu就够了。两个都用也没有问题,同一个页面里可以共存,实际项目中我就这么干过,但在实现上要处理好冲突(后面会讲)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ContextMenu的完整实现步骤:从注册到回调处理的逐步解析
这一节我用一个具体的例子来演示:一个联系人列表,长按列表项弹出“拨打电话”“发送短信”“删除”三个操作。
2.1 创建菜单资源文件
在res/menu/目录下新建context_menu.xml:
xml复制<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_call"
android:title="拨打电话" />
<item
android:id="@+id/action_sms"
android:title="发送短信" />
<item
android:id="@+id/action_delete"
android:title="删除" />
</menu>
注意,ContextMenu的菜单项默认只显示文本,不显示图标(android:icon属性虽然可以设置,但在ContextMenu里一般不会渲染出来,系统默认的ContextMenu样式是纯文本列表)。所以常规做法是只定义title,等到了ActionMode里再配上图标。
2.2 在Activity或Fragment中注册ContextMenu
在Activity中,如果你直接复写了onCreateContextMenu,那是对整个页面的所有View生效的。更精确的做法是用registerForContextMenu()指定某个View,例如ListView:
kotlin复制class MainActivity : AppCompatActivity() {
private lateinit var listView: ListView
private lateinit var adapter: ArrayAdapter<String>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
listView = findViewById(R.id.list_view)
adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, contactNames)
listView.adapter = adapter
// 关键步骤:为ListView注册上下文菜单
registerForContextMenu(listView)
}
}
如果你用的是RecyclerView,同样需要对RecyclerView调用registerForContextMenu()。
在Fragment里也是一样的逻辑,在onCreateView或onViewCreated对具体View注册即可。
2.3 复写onCreateContextMenu和onContextItemSelected
registerForContextMenu()之后,系统会在用户长按对应View时回调onCreateContextMenu()。我们要在这里加载菜单资源并填充Menu:
kotlin复制override fun onCreateContextMenu(
menu: ContextMenu,
v: View,
menuInfo: ContextMenu.ContextMenuInfo?
) {
super.onCreateContextMenu(menu, v, menuInfo)
menu.setHeaderTitle("选择操作")
menuInflater.inflate(R.menu.context_menu, menu)
}
然后处理菜单项点击事件。这一步有个比较常见的坑:从menuInfo里拿到的position并不是我们列表数据的真实下标。因为ListView的长按信息得到的是AdapterView.AdapterContextMenuInfo,它的position包含了HeaderView的偏移量。如果你在ListView前面加了HeaderView,必须减去HeaderView的数量才能得到正确的列表项位置;如果用的是RecyclerView,则没有任何官方的ContextMenuInfo实现,只能通过RecyclerView.ViewHolder.getAdapterPosition()自行获取。
kotlin复制override fun onContextItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_call -> {
val info = item.menuInfo as AdapterView.AdapterContextMenuInfo
val position = info.position - listView.headerViewsCount
val contact = adapter.getItem(position)
callContact(contact)
true
}
R.id.action_sms -> {
val info = item.menuInfo as AdapterView.AdapterContextMenuInfo
val position = info.position - listView.headerViewsCount
val contact = adapter.getItem(position)
sendSms(contact)
true
}
R.id.action_delete -> {
val info = item.menuInfo as AdapterView.AdapterContextMenuInfo
val position = info.position - listView.headerViewsCount
adapter.remove(adapter.getItem(position))
adapter.notifyDataSetChanged()
true
}
else -> super.onContextItemSelected(item)
}
}
2.4 RecyclerView实现时的一个额外处理
在RecyclerView上注册ContextMenu,菜单回调里是没有AdapterContextMenuInfo的。这时候需要借助RecyclerView的长按监听,把当前被长按的条目信息保存下来,在onCreateContextMenu里取用:
kotlin复制private var longPressedPosition = -1
recyclerView.addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
if (e.action == MotionEvent.ACTION_DOWN) {
val child = rv.findChildViewUnder(e.x, e.y)
if (child != null) {
longPressedPosition = rv.getChildAdapterPosition(child)
}
}
return false
}
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {}
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {}
})
然后在onContextItemSelected和onCreateContextMenu中使用缓存下来的longPressedPosition。注意长按是ACTION_DOWN之后保持一段时间,单纯监听ACTION_DOWN会在短按点击时也记录位置,这没问题,因为我们只会在长按触发的菜单回调里读这个值。
2.5 ContextMenu的动态刷新和dismiss场景
还有几个细节容易被忽略。比如菜单弹出过程中如果数据源发生了变化,比如异步加载了更多数据,那么onCreateContextMenu时应该重新获取最新的条目信息,而不是直接用注册时缓存的旧数据。另外,菜单弹出期间用户又点击了别的地方,菜单会自动关闭,系统会自动调用onContextMenuClosed(),这时如果有清理工作(比如取消高亮状态、复原数据快照),可以在这个回调里做。
3. Contextual ActionMode的逐步实现:列表多选与单View触发的两条路径
ActionMode实现起来比ContextMenu更复杂一些,因为它涉及“进入模式”“更新选中状态”“处理操作”“退出模式”这四个阶段。我拆成两条路径来讲:列表多选场景和单个View触发场景。
3.1 列表多选场景:用MultiChoiceModeListener
假设还是那个联系人列表,我现在要支持长按进入多选模式,然后再选择多个联系人进行批量删除或批量分享。
第一步,把ListView变为可多选列表。通常在布局里设置:
xml复制<ListView
android:id="@+id/list_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:choiceMode="multipleChoiceModal" />
也可以通过代码设置:
kotlin复制listView.choiceMode = ListView.CHOICE_MODE_MULTIPLE_MODAL
第二步,设置MultiChoiceModeListener:
kotlin复制listView.multiChoiceModeListener = object : AbsListView.MultiChoiceModeListener {
override fun onItemCheckedStateChanged(
mode: ActionMode?,
position: Int,
id: Long,
checked: Boolean
) {
// 当用户在ActionMode状态下点击其他item时回调
// 更新选中数量
val checkedCount = listView.checkedItemCount
mode?.title = "$checkedCount 项已选择"
adapter.notifyDataSetChanged()
}
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
// 加载ActionMode菜单资源
mode?.menuInflater?.inflate(R.menu.contextual_action_menu, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return false
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return when (item?.itemId) {
R.id.action_batch_delete -> {
deleteSelectedItems()
mode?.finish()
true
}
R.id.action_batch_share -> {
shareSelectedItems()
mode?.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode?) {
// 退出ActionMode时清理选中状态
adapter.clearSelections()
}
}
第三步,处理选中集合。我在Adapter里新增了selectedPositions这个Set<Int>,在onItemCheckedStateChanged中同步:
kotlin复制class ContactAdapter(context: Context, private val contacts: List<Contact>) :
ArrayAdapter<Contact>(context, 0, contacts) {
val selectedPositions = mutableSetOf<Int>()
fun setItemChecked(position: Int, checked: Boolean) {
if (checked) {
selectedPositions.add(position)
} else {
selectedPositions.remove(position)
}
}
fun clearSelections() {
selectedPositions.clear()
notifyDataSetChanged()
}
fun getCheckedContacts(): List<Contact> {
return selectedPositions.sorted().map { getItem(it) }
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
// 根据选中状态修改背景色或透明度
val isSelected = position in selectedPositions
view.isActivated = isSelected
return view
}
}
注意AbsListView被设计为配合CHOICE_MODE_MULTIPLE_MODAL后,系统自动处理的逻辑是:长按一个item会进入ActionMode,再点其他item会自动切换选中状态,同时回调onItemCheckedStateChanged。你不需要自己写长按监听,这是官方推荐做法。
3.2 单项触发场景:用ActionMode.Callback
如果你的需求是长按一个View直接进入ActionMode,但不支持多选,比如长按一条消息弹出一个工具栏,那么可以手动启动ActionMode:
kotlin复制private var actionMode: ActionMode? = null
view.setOnLongClickListener {
if (actionMode == null) {
actionMode = startActionMode(actionModeCallback)
} else {
// 如果已有ActionMode在运行,可以先finish再重新创建
actionMode?.finish()
actionMode = startActionMode(actionModeCallback)
}
true
}
private val actionModeCallback = object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.contextual_action_menu, menu)
mode.title = "操作选项"
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
// 每次菜单刷新时回调,可在这里动态改变菜单项可用性
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_delete -> {
deleteMessage()
mode.finish()
true
}
R.id.action_share -> {
shareMessage()
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
// 退出时把相关View的高亮状态去掉
view.isActivated = false
actionMode = null
}
}
注意startActionMode在API 11之后可用,AndroidX环境下建议用startSupportActionMode来兼容旧版本。不过考虑到现在的应用最低版本基本都是5.0以上了,直接用startActionMode也没问题。
3.3 菜单项图标和文字的显示策略
ActionMode工具栏默认只会显示图标,文字不显示,除非你把菜单项设置成android:showAsAction="always",同时配合app:showAsAction="ifRoom"属性,并且设置android:title。实际项目中,工具栏上每一个菜单项通常都是图标+文字的组合(比如Gmail邮件列表顶部工具栏的归档、删除、标记已读都是文字按钮)。如果你希望显示文字,需要在menu的xml中明确设置showAsAction:
xml复制<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_batch_delete"
android:icon="@drawable/ic_delete"
android:title="删除"
app:showAsAction="always" />
<item
android:id="@+id/action_batch_share"
android:icon="@drawable/ic_share"
android:title="分享"
app:showAsAction="always" />
</menu>
要注意颜色问题。ActionMode的背景通常是深色(主题色),默认的菜单项文字是浅色的。如果用app:showAsAction="always"让文字显示出来,在深色背景下需要确保文本可读。很多项目在这里踩坑,文字和背景都接近黑色,用户看不见。如果遇到这种情况,可以在Theme中为actionMode单独定义样式:
xml复制<style name="AppTheme" parent="Theme.AppCompat.Light">
<item name="actionModeBackground">@color/action_mode_background</item>
<item name="android:actionModeBackground">@color/action_mode_background</item>
<item name="actionModeCloseDrawable">@drawable/ic_close</item>
</style>
3.4 与RecyclerView的结合
RecyclerView本身没有内置的CHOICE_MODE_MULTIPLE_MODAL支持,所以要么自己写多选逻辑,要么借助第三方库。自己写的话,核心思路是:监听item的长按事件,启动ActionMode,然后在item点击时维护一组选中的位置集合,通过notifyItemChanged()刷新选中状态的UI。
kotlin复制class RecyclerMultiSelectController(
private val adapter: RecyclerView.Adapter<*>,
private val activity: AppCompatActivity,
private val onActionItem: (Int) -> Boolean
) {
private var actionMode: ActionMode? = null
private val selectedItems = mutableSetOf<Int>()
fun onLongPress(position: Int) {
if (actionMode == null) {
selectedItems.clear()
selectedItems.add(position)
actionMode = activity.startSupportActionMode(callback)
adapter.notifyItemChanged(position)
}
}
fun onClick(position: Int) {
if (actionMode == null) return
if (selectedItems.contains(position)) {
selectedItems.remove(position)
} else {
selectedItems.add(position)
}
if (selectedItems.isEmpty()) {
actionMode?.finish()
} else {
actionMode?.title = "${selectedItems.size} 项已选择"
}
adapter.notifyItemChanged(position)
}
private val callback = object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.contextual_action_menu, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
val consumed = onActionItem(item.itemId)
if (consumed) {
mode.finish()
}
return consumed
}
override fun onDestroyActionMode(mode: ActionMode) {
selectedItems.clear()
adapter.notifyDataSetChanged()
actionMode = null
}
}
}
这套控制器可以复用到任意RecyclerView,只要在Adapter的onBindViewHolder里把长按和点击事件回调到控制器就行。我实际用下来感觉比第三方库更可控,不会引入额外的依赖。
4. 两种模式的对比分析与选型决策:我踩过的坑和一些重要细节
4.1 长按与点击事件的冲突处理
这是最常踩的坑。一个列表既要有普通点击事件,又要长按触发ContextMenu或ActionMode,系统默认的长按和点击是可以共存的,但有细节需要注意。
对于ListView,如果注册了ContextMenu,长按会优先触发上下文菜单,而不会触发onItemLongClickListener。如果你既想用ContextMenu又想实现长按拖动排序,就会冲突。解决办法是不要用registerForContextMenu(),改为在onItemLongClickListener里手动调用startActionMode或手动构建PopupMenu。
对于RecyclerView,长按监听和registerForContextMenu(listener)可以同时生效,但顺序是:先触发OnItemTouchListener,再触发ContextMenu的回调。我遇到过的问题是,长按弹出上下文菜单的同时,把item的背景高亮也触发了,导致菜单关闭后背景高亮无法恢复。解决方法是把高亮逻辑放在onCreateActionMode和onDestroyActionMode里统一管理,或者在onContextMenuClosed()里恢复View状态。
4.2 数据位置与View位置的偏移问题
ContextMenu回调里的position不是数据下标这个问题,前面提到过,用ListView加Header的场景尤其明显。我在一个项目里,页面上方放了一个搜索框(作为ListView的HeaderView),列表项从第0个开始,但AdapterContextMenuInfo.position返回的是1、2、3……,我一开始没减1,结果操作的对象整体错位了一个。这个问题排查了半小时才找到,排查过程中打印了所有可能的position值,最后才意识到是HeaderView偏移。这个经验建议大家第一次写的时候就要考虑到。
4.3 ContextMenu的样式限制与定制思路
默认的ContextMenu样式比较简陋,就是白底蓝字的浮动列表,字体大小和颜色都不可控,虽然可以通过setHeaderIcon、setHeaderTitle做简单定制,但如果你需要完全自定义菜单样式,就要考虑改成PopupMenu或者BottomSheetDialog。我实际做过一个项目,设计师要求菜单项带图标并且放在底部弹出,那时候ContextMenu完全满足不了,最后换成BottomSheetDialog来实现。ContextMenu的优势是系统级集成、代码量少、兼容性好;劣势是视觉定制自由度很低。
4.4 在ActionMode中更新菜单项状态
onPrepareActionMode回调有必要好好利用。比如当选中项为空时,某些操作应该置灰。但这个回调不是每次item点击都会触发,只有在菜单重新创建或调用invalidate()时才会回调。
kotlin复制override fun onItemCheckedStateChanged(
mode: ActionMode?,
position: Int,
id: Long,
checked: Boolean
) {
val checkedCount = listView.checkedItemCount
mode?.title = "$checkedCount 项已选择"
// 手动刷新菜单项状态
mode?.invalidate()
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
val deleteItem = menu?.findItem(R.id.action_batch_delete)
deleteItem?.isEnabled = listView.checkedItemCount > 0
return true
}
注意invalidate()会触发布局重建,如果频繁调用会导致轻微的闪烁,所以只有选中状态发生变化时才调用,不要放在高频回调里。
4.5 ActionMode与系统返回键的交互
ActionMode激活时,按系统返回键会优先退出ActionMode,而不是退出Activity。这个行为系统默认已经处理好了,但如果你的ActionMode是手动启用的(非MultiChoiceModeListener那种),需要确认onDestroyActionMode里的清理逻辑是完整的,否则返回键退出ActionMode后,页面上会残留高亮状态。
4.6 低版本兼容性说明
如果你还在维护minSdkVersion低于11的工程,直接用ActionMode会崩溃。推荐做法是用AndroidX库中的startSupportActionMode,配合AppCompatActivity,这样在5.0之前的老版本上也能运行。不过现在的应用minSdkVersion基本都是21以上了,这个已经不是大问题。
5. 从ContextMenu到现代交互的演变与我的实践心得
5.1 ContextMenu与PopupMenu的取舍
PopupMenu是另一种长按弹菜单的实现,但它不走onCreateContextMenu回调,而是挂在某个View上,位置从锚点View弹出,形状更像一个气泡菜单,视觉上比ContextMenu灵活。我的原则是:
- 有批量操作需求 →
Contextual Action Mode - 只是单条目操作,数量少(2-4项)且可接受系统默认样式 →
ContextMenu - 单条目操作,但需要图标、自定义样式或菜单项较多 →
PopupMenu或BottomSheetDialog
实际项目里,ContextMenu用得越来越少,因为视觉自由度太低,但作为系统级的标准交互,理解它的实现逻辑对写PopupMenu和ActionMode也有帮助。
5.2 Compose时代的替代方案
如果你的新项目已经是Jetpack Compose了,那么ContextMenu这个API基本不会再直接用。Compose里实现长按菜单的方式通常是组合combinedClickable和DropdownMenu:
kotlin复制Text(
text = "联系人",
modifier = Modifier
.combinedClickable(
onClick = { /* 普通点击 */ },
onLongClick = { showMenu = true }
)
)
而批量操作的ActionMode,在Compose里也没有现成的等价物,通常是自己写一个基于AnimatedVisibility的顶部工具栏,放在Scaffold的topBar位置或者是Box的顶部叠加层。虽然实现方式和传统View完全不同,但交互模式的设计思路依然是那套:长按进入多选,工具栏显示操作,选中数量实时更新。
5.3 实际项目的模式融合经验
最后分享一个我在真实项目里的做法。一个邮箱类型的应用,列表页同时存在两种长按操作需求:轻量单条操作(收藏、标记已读)用ContextMenu弹出;批量操作(删除、移动、归档)用ActionMode顶部工具栏。为了实现“长按直接进入ActionMode,点击已选中的item退出选中但继续留在ActionMode”,我自定义了一个RecyclerView的状态机,把所有事件都收敛到Adapter的onBindViewHolder里处理。
处理的逻辑是这样的:长按一个item时,如果当前没有ActionMode,就进入ActionMode并把当前item选中;如果当前已有ActionMode,则把当前item的选中状态切换。普通点击一个item时,如果当前处于ActionMode,也切换选中状态;如果不在ActionMode,则执行普通的点击逻辑。当ActionMode处于激活状态时,列表的普通点击被禁用,所有点击都作为多选切换处理。
这套逻辑整理成了上面提到的RecyclerMultiSelectController,后来我把控制器改成泛型,支持任意类型的RecyclerView.Adapter,只要Adapter在绑定视图时回调两个方法:onItemClick(position)和onItemLongClick(position),控制器就负责管理ActionMode的生命周期和选中集合。用这种方式,不用在Activity里写大量重复的ActionMode回调,整个多选功能的代码量减少了接近一半。
5.4 性能与交互的细节优化
最后补充一些性能方面的建议。如果你自定义了Adapter,每次选中状态变化都调用notifyDataSetChanged()会导致整个列表重绘,数据量大的时候能感觉到明显的卡顿。更优的做法是只刷新变化的条目:
kotlin复制adapter.notifyItemChanged(position)
同时,在多选模式下,item的复用会导致之前设置的背景、图标状态残留,所以一定要在onBindViewHolder里根据selectedItems.contains(holder.adapterPosition)重新设置状态,而不能只依赖设置一次。
还有一个很小的体验点:进入ActionMode时,让系统震动反馈一下,用户能明显感知到模式切换:
kotlin复制val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(20)
}
不要小看这个细节,真实的用户反馈是:有了震动之后,长按进入多选的发现成本降低了很多,用户很容易就掌握了这个操作。
说了这么多,其实ContextMenu和Contextual Action Mode并不是什么高深的技术,但它们在日常App中的出现频率非常高。把它们的适用边界、实现套路和兼容性细节理清楚,写起来就会顺手很多。如果这篇文章里有什么不对的地方,欢迎在评论区指正,也欢迎分享你在实际项目里踩过的坑。
