本文尽可能采用白话的形式介绍Android事件分发的核心思想,经验丰富的同学可以直接移到第四节练习面试题。
Android事件分发机制是指触摸事件在View树中传递,最终找到并交给能处理该事件View的过程。
1. 基础概念
1.1. View 和 ViewGroup
View
是Android中所有控件的基类,它代表的是一个控件。ViewGroup
代表一个控件组,可以嵌套多个控件,同时它本身也是一个控件,继承自View
,但是它重写了View
的绘制和事件分发等多个核心方法。
1.2. MotionEvent
一次完整的事件序列包含手指下按 → 手指移动[0 - n]次 → 手指抬起。
最简单的触摸动作就是点击,对应的事件序列是:手指下按 → 手指抬起,复杂一点的还可能有手指移动、多指的场景,每一个动作都被系统封装到MotionEvent
对象中在View
树之间流转。
事件 | 事件含义 | 一次事件序列中可触发次数 |
---|---|---|
ACTION_DOWN | 手指下按,事件序列起点 | 1 |
ACTION_MOVE | 手指移动 | [0, ∞] |
ACTION_UP | 手指抬起,事件序列终点 | 1 |
ACTION_POINTER_DOWN | 手指下按,下按前屏幕上已有手指 | [0, ∞] |
ACTION_POINTER_UP | 手指抬起,抬起后屏幕上还有手指 | [0, ∞] |
ACTION_CANCEL | 事件被中途拦截 | [0, 1] |
1.3. 按压状态
按压状态分为正在按压、未按压两种状态。
ACTION_DOWN
触发之后变为正在按压状态。
ACTION_UP
触发之后变为取消按压状态。
可以通过xml设置这两种状态下View的背景。
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:color="@color/red"/>
<item android:state_pressed="false" android:color="@color/black"/>
</selector>
1.4. Tooltip
是指长按View时在视图附近悬浮展示一个文本提示,与OnLongClickListener
互斥,它的响应优先级更低,可通过下面两种方式设置。
mBinding.viewTest.tooltipText = "测试"
或者在xml的View中
android:tooltipText="测试"
2. 事件分发起点
2.1. 页面结构
在Android项目中新建一个Activity会发现自动新增了标题栏和状态栏区域(黄色区域是xml中定义的内容)。
是因为Android会预先定义几套比较通用的模版样式,所以最终呈现给用户的视图是模版样式与开发者自定义样式的组合。整个视图的根节点是DecorView,它继承自ViewGroup。
protected ViewGroup generateLayout(DecorView decor) {
// ...
// 找到合适的模版样式
if ((features & ((1 << FEATURE_LEFT_ICON) | (1 << FEATURE_RIGHT_ICON))) != 0) {
layoutResource = R.layout.screen_title_icons;
} else if ((features & ((1 << FEATURE_PROGRESS) | (1 << FEATURE_INDETERMINATE_PROGRESS))) != 0
&& (features & (1 << FEATURE_ACTION_BAR)) == 0) {
layoutResource = R.layout.screen_progress;
} else if ((features & (1 << FEATURE_CUSTOM_TITLE)) != 0) {
layoutResource = R.layout.screen_custom_title;
} else if ((features & (1 << FEATURE_NO_TITLE)) == 0) {
layoutResource = R.layout.screen_title;
} else if ((features & (1 << FEATURE_ACTION_MODE_OVERLAY)) != 0) {
layoutResource = R.layout.screen_simple_overlay_action_mode;
} else {
layoutResource = R.layout.screen_simple;
}
mDecor.startChanging();
// 将模版的View add到DecorView中
mDecor.onResourcesLoaded(mLayoutInflater, layoutResource);
// 从模版View中找到专门为Activity的xml留的位置
ViewGroup contentParent = (ViewGroup) findViewById(ID_ANDROID_CONTENT);
return contentParent;
}
2.2. 初始分发过程
事件先由ViewRootImpl
监听到,它直接传递给视图根节点DecorView
,DecorView
没直接处理,而是绕了一圈,给Activity
和PhoneWindow
拦截处理的机会,之后又重新回到了DecorView
,接着才开始进行View间的事件分发流程。
3. View的分发流程
核心流程:事件最先传递给DecorView
,然后使用深度优先遍历
寻找愿意处理事件的View,寻找到之后将终止遍历,将结果返回给上一层。
代码角度:DFS调用dispatchTouchEvent
方法分发事件,愿意处理返回true,反之为false。
3.1. View#dispatchTouchEvent
View:事件既然分发给我了,我就判断一下我要不要处理就行。
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
// ...
final int actionMasked = event.getActionMasked();
// 当成true即可,校验屏幕被遮挡的情况下是否允许点击
if (onFilterTouchEventForSecurity(event)) {
// ⭐️ 1. 鼠标相关,无需关注,当做false
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
// ⭐️ 2. onTouchListener是否愿意处理事件
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
// ⭐️ 3. onTouchEvent是否愿意处理事件
if (!result && onTouchEvent(event)) {
result = true;
}
}
return result;
}
- 与鼠标拖拽滚动条相关,手机中用不到,当成false即可。
- 为View设置了
OnTouchListener
,需要回调出去问问。 - 调
onTouchEvent
看它是不是要处理↓↓↓
public boolean onTouchEvent(MotionEvent event) {
// ...
// 是否有事件监听,有无点击事件监听 || 长按事件监听 || 设置了可上下文点击事件(手机设备上可忽略)
final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
// ⭐️ 4. 控件处于不可用的状态 并且 要响应点击(默认),不做实质性响应,返回true
if ((viewFlags & ENABLED_MASK) == DISABLED
&& (mPrivateFlags4 & PFLAG4_ALLOW_CLICK_WHEN_DISABLED) == 0) {
// ...
return clickable;
}
// ⭐️ 5. 点击事件代理,将MotionEvent交给另外一个视图处理
if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}
// ⭐️ 6. 如果可点击 或者 有tooltip,表示可以处理事件
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
switch (action) {
// ⭐️ 7. 事件监听回调和tooltip展示流程
}
return true;
}
return false;
}
- 控件如果被设置了禁用状态,默认情况下返回值取决于是否有无事件监听,无论返回值如何都不会回调点击监听了,当然也可以通过
setAllowClickWhenDisabled
让View在事件分发时忽略禁用状态,继续走步骤5和6。 - View中还可以使用
setTouchDelegate
设置事件代理,将事件转发给指定的View处理。 - 有事件监听时由于要回调监听,所以当前View需要处理该事件;有
tooltip
的话,长按时需要展示小浮层所以也需要处理。 - 事件监听回调和
tooltip
展示流程单独放到3.2中。
步骤1-6中的结果,返回给上一层父ViewGroup,告诉它自己的意愿。
3.2. 事件响应
该章节讲解onTouchEvent
中根据触摸事件在一定时机给于监听者单点、长按回调和展示tooltip
的逻辑。
onTouchEvent
源码中分别针对ACTION_DOWN
、ACTION_UP
、ACTION_MOVE
、ACTION_CANCEL
四种事件类别做处理,其它事件抛弃。
- 手指按下:
ACTION_DOWN
switch(action) {
// 手指下按
case MotionEvent.ACTION_DOWN:
if ((viewFlags & TOOLTIP) == TOOLTIP) {
// ⭐️ 1. ViewConfiguration.getLongPressTimeout()为400ms
checkForLongClick(ViewConfiguration.getLongPressTimeout(), x, y);
break;
}
// 是否在一个可滑动容器中
boolean isInScrollingContainer = isInScrollingContainer();
// ⭐️ 2.
if (isInScrollingContainer) {
// 设置为预按压状态
mPrivateFlags |= PFLAG_PREPRESSED;
if (mPendingCheckForTap == null) {
mPendingCheckForTap = new CheckForTap();
}
// ViewConfiguration.getTapTimeout()为100ms
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
} else {
// 设置按压状态
setPressed(true, x, y);
checkForLongClick();
}
break;
}
private final class CheckForTap implements Runnable {
@Override
public void run() {
mPrivateFlags &= ~PFLAG_PREPRESSED;
setPressed(true, x, y);
// ViewConfiguration.getLongPressTimeout()为400ms
// ViewConfiguration.getTapTimeout()为100ms
// delay = 300ms
final long delay =
ViewConfiguration.getLongPressTimeout() - ViewConfiguration.getTapTimeout();
checkForLongClick(delay, x, y, TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
}
}
- 如果有
tooltip
,执行400ms的倒计时,倒计时完成去展示文案。 - 如果不在滚动视图中,立刻设置按压状态,400ms后回调长按事件监听;如果在滚动视图中,手指触摸屏幕时可能是想要滑动,所以不能立刻设置为按压状态,标记为预按压状态,先等个100ms。
-
- 100ms内如果没滚动呢?那再展示按压状态,300ms后回调长按事件监听。
- 如果滚动了呢?事件会被父滚动事件拦截掉,并发送一个
ACTION_CANCEL
给当前View,那时所有的倒计时和标志都会被重置。
- 手指抬起:
ACTION_UP
switch (action) {
// 手指抬起
case MotionEvent.ACTION_UP:
// 1.5s后隐藏tip
if ((viewFlags & TOOLTIP) == TOOLTIP) {
handleTooltipUp();
break;
}
// 是否是预按压状态
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
// 是否是按压状态
boolean pressed = (mPrivateFlags & PFLAG_PRESSED) != 0;
// ⭐️ 1. 必须要是按压或者预按压状态
if (pressed || prepressed) {
// ...
// ⭐️ 2. 如果之前是预按压状态,在手指抬起时,立刻转为按压状态
if (prepressed) {
setPressed(true, x, y);
}
// ⭐️ 3. 如果长按事件还没触发的话,尝试响应点击事件
if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
removeLongPressCallback();
// focusTaken与焦点有关,可当成false
if (!focusTaken) {
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
// 执行setOnClickListener设置的回调
if (!post(mPerformClick)) {
performClickInternal();
}
}
}
// 取消按压状态的回调
if (mUnsetPressedState == null) {
mUnsetPressedState = new UnsetPressedState();
}
// 如果之前是预按压状态,上面刚转为按压状态
// 延迟64ms再取消按压状态,起码让用户瞅一眼
if (prepressed) {
postDelayed(
mUnsetPressedState,
ViewConfiguration.getPressedStateDuration()
);
} else if (!post(mUnsetPressedState)) {
// 其它场景立刻取消按压状态
mUnsetPressedState.run();
}
removeTapCallback();
}
break;
}
- 如果在抬起手指的时候发现View不是按压或者预按压状态是不会触发点击回调的(PS. 手指移动出View边界再松开是不会触发回调事件的)。
- 如果当前是预按压状态(滚动容器内),立刻展示按压样式,并加一点小延迟再取消按压。
- 如果之前没有展示过
长按回调
,执行点击回调
。
- 手指滑动:
ACTION_MOVE
switch (action) {
case MotionEvent.ACTION_MOVE:
// ...
// 识别手势类别
final int motionClassification = event.getClassification();
// 是否是模棱两可的手势
final boolean ambiguousGesture =
motionClassification == MotionEvent.CLASSIFICATION_AMBIGUOUS_GESTURE;
// 8dp
int touchSlop = mTouchSlop;
// 模棱两可的手势 && 长按事件正在倒计时
if (ambiguousGesture && hasPendingLongPressCallback()) {
// ⭐️ 1. 原视图上下左右各加8dp,判断点击事件是否在View区域内
if (!pointInView(x, y, touchSlop)) {
// ⭐️ 2.
// 先移除,增加延迟重新设
removeLongPressCallback();
// 长按超时时间往后延 400ms * 2
long delay = (long) (
ViewConfiguration.getLongPressTimeout()
* mAmbiguousGestureMultiplier
);
delay -= event.getEventTime() - event.getDownTime();
// 长按事件延迟触发
checkForLongClick(delay,x, y);
}
// 变为touchSlop = 8dp * 2
touchSlop *= mAmbiguousGestureMultiplier;
}
// ⭐️3. 如果View不在视图边缘16dp内
if (!pointInView(x, y, touchSlop)) {
removeTapCallback();
removeLongPressCallback();
if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
}
// 如果是用力按压(说明就是想长按),且有长按事件监听,立即触发
final boolean deepPress =
motionClassification == MotionEvent.CLASSIFICATION_DEEP_PRESS;
if (deepPress && hasPendingLongPressCallback()) {
removeLongPressCallback();
checkForLongClick(0, x, y);
}
break;
}
MOVE
事件就围绕一个热区问题:
- 考虑到人手指触摸屏幕的误差,即使移动出View边界了,只要在上下左右8dp扩展范围之内也可以当做在View内。
- 如果有长按事件并且还未执行(正在做400ms倒计时),但是手指移到了View边缘外8dp - 16dp的范围内,有些不确定用户行为的目的是不是要长按,长按倒计时从400ms改到800ms,再等等看。
- 如果超出了16dp,直接取消各类倒计时。
- 事件取消:
ACTION_CANCEL
switch (action) {
// ⭐️ 1. 事件取消,比如上文提到的滑动容器接管事件序列
case MotionEvent.ACTION_CANCEL:
// 移除各种点击事件回调监听
if (clickable) {
setPressed(false);
}
removeTapCallback();
removeLongPressCallback();
mInContextButtonPress = false;
mHasPerformedLongPress = false;
mIgnoreNextUpEvent = false;
mPrivateFlags3 & = ~PFLAG3_FINGER_DOWN;
break;
}
- 一个正常的事件序列必须要有一个
ACTION_DOWN
和ACTION_UP
的,如果中途事件被拦截了,此时View的样式可能还呈现按压样式,各种事件的倒计时还在执行,此时需要拦截者发送一个ACTION_CANCEL
给当前View,起到替代ACTION_UP
的作用。
3.3. ViewGroup#dispatchTouchEvent
ViewGroup:将事件先分发给孩子们,它们处理不了我再看看我要不要处理。
上文的View属于单个控件,而ViewGroup继承自View,同时内部可能包含多个View,它需要选取合适的View承接事件,所以它需要重写dispatchTouchEvent
方法,方法职责是选取合适的View(子View或自身)处理事件。
// 弱化了多点触控的场景
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
// down事件会清空mFirstTouchTarget、重置FLAG_DISALLOW_INTERCEPT
if (actionMasked == MotionEvent.ACTION_DOWN) {
cancelAndClearTouchTargets(ev);
resetTouchState();
}
final boolean intercepted;
// ⭐️ 3.3.1. mFirstTouchTarget
// ⭐️ 3.3.2
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action);
} else {
intercepted = false;
}
} else {
intercepted = true;
}
TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
// 如果是cancel或者intercepted,直接去给接收事件的子View传递cancel事件
if (!canceled && !intercepted) {
// ⭐️ 注意只有DOWN事件才会询问子View是否愿意处理
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
// ...
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
// 按照子View视图层级顺序或者自定义顺序遍历
// 判断是否有能力处理
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// 判断当前子View是否能够处理 && 是否点击坐标是否在子视图范围之内
if (!child.canReceivePointerEvents()
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
// ...
// ⭐️ 3.3.3 分发事件给子视图,看它愿不愿意接收
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { // 如果它愿意,当前序列的事件都分发给它
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}
}
}
}
// ⭐️ 3.3.3 没子View能处理,ViewGroup自身再尝试下是否要处理
if (mFirstTouchTarget == null) {
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
// 将事件传递给子View处理
// 循环是为了适配多指的情况,内有手指id判断,最多有一个子View处理
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
// 如果子View接收过事件,突然这次被父View拦截了,那么此次事件被替换为cancel传递给子View,并且mFirstTouchTarget被清空
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
// ...
}
return handled;
}
3.3.1. mFirstTouchTarget
每个事件都问子View太过于繁琐和耗时,记录ACTION_DOWN
事件属于哪个子View处理的,接下来当前事件序列的事件都一股脑分给它。设计为链表格式是为了记录多个手指同时触摸的情况(多点触控)。
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
3.3.2. 事件拦截
mFirstTouchTarget == null
时,说明子View都愿意处理或者是当前是DOWN
事件,还没开始分发寻找呢。disallowIntercept
为false时,有'人'通过requestDisallowInterceptTouchEvent
不想让ViewGroup拦截。- 正常情况下都会先调下
ViewGroup#onInterceptTouchEvent
问下是否要拦截,拦截之后再判断下自身是否需要处理事件,不需要时也不再征求子View的意见,直接向上层返回false。见3.3.4
// down事件 || 已经交由子view处理过事件了
// 这里可以想象成,只要有可能是子View处理事件时,ViewGroup都可以尝试拦截
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
// 默认为false,子View也有能力更改
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// 这种case出现的原因是:actionMasked != MotionEvent.ACTION_DOWN && mFirstTouchTarget == null,即本身就只能ViewGroup处理
intercepted = true;
}
3.3.3. dispatchTransformedTouchEvent
该方法是个复合方法,若传入的child不为空,就将事件分发给child,反之就将事件分发给自身。
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;
// ...
if (child == null) {
// 即在ViewGroup调用父类View的dispatchTouchEvent,看下自己要不要处理
handled = super.dispatchTouchEvent(transformedEvent);
} else {
handled = child.dispatchTouchEvent(transformedEvent);
}
return handled;
}
3.3.4. 🌛标识处是比较常见的ACTION_MOVE和ACTION_UP事件分发路径,因为ACTION_DOWN时已经记录过下一层级处理者了,剩余事件直接分发即可。
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action);
} else {
//🌛 step1
intercepted = false;
}
} else {
intercepted = true;
}
if (mFirstTouchTarget == null) {
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
//🌛 step2
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
// 分发给target子View
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
}
predecessor = target;
target = next;
}
}
}
return handled;
}
4. 面试场景题
-
Q:如果没有View愿意接收事件,后续事件会是怎么分发?
A:同序列的后续事件全部交由DecorView处理。
ViewGroup在
DOWN
事件时会用深度优先遍历的方式寻找能处理的子View,找到后记录到mFirstTouchTarget
中,后续事件(同一次事件序列)使用mFirstTouchTarget
查到目标子View,直接分发事件,如果mFirstTouchTarget
为null,会被ViewGroup拦截掉自己处理,不会再往下一层子View分发。MOVE
和UP
事件传递到第一个ViewGroup,即DecorView
时,会被全部拦截掉,不再往下分发。 -
Q:parentView包含childView,手指按在childView,然后移到parentView,事件分发过程是怎么样的?
A: 事件还是全部分发给childView。
问题1提到ViewGroup持有
mFirstTouchTarget
,DOWN
之后的事件都是根据mFirstTouchTarget
无脑分发。 -
Q:手指按在一个View上,然后移出这个View,再移进这个View,此时松开手指,OnClickListener会回调吗?
A:不会
移出时View会取消按压状态,重新移入时不会重设,而View只会在按压状态才回调点击监听。
-
Q:childView可以强制让parentView所有事件都不拦截吗?parentView可以强制拦截childView的所有事件吗?
A:不行,可以
childView可以通过调用
requestDisallowInterceptTouchEvent(true)
不让parentView拦截,但是这个标识会在DOWN 事件时被清除,所以
DOWN事件可能会被拦截,如果
DOWN`事件被拦截,后续其它事件更别想要了。而parentView可以通过重写
onInterceptTouchEvent
拦截到所有事件。
总结:
整体来看,DOWN
事件会先分发给DecorView
,DecorView
通过深度优先遍历寻找到要处理该事件的View,之后事件会省略查找过程,逐层传递给该View。
希望通过这篇文章的讲解,能对大家有所帮助。如果有疑问,欢迎在评论区提问,也欢迎大家批评指正其中的不足之处。