一、一次触摸是如何发生的?
当用户触摸屏幕或者按键操作,首次触发的是硬件驱动,驱动收到事件后,将该相应事件写入到输入设备节点交由内核处理, 产生最原生态的内核事件。内核会将触摸事件包装成EVENT作为文件存储到"/dev/input/event[x]"目录中。
然后,InputReaderThread会不断的从"/dev/input/event[x]"目录中读取事件,并会把事件交给InputDispatch处理。InputDispatch会把事件分发到需要的地方。
以上的这些步骤都是C/C++代码实现,只需要对流程做一些简单的了解即可。而真正java开始拿到事件的起点是在ViewRootImpl的内部类WindowInputEventReceiver之中。
WindowInputEventReceiver.onInputEvent()通过InputDispatch调用接受到事件之后,首先将事件传给了enqueueInputEvent(),将新接受的事件插入到事件队列中,接着调用doProcessInputEvents()。
class ViewRootImpl {
...
final class WindowInputEventReceiver extends InputEventReceiver {
...
@Override
public void onInputEvent(InputEvent event, int displayId) {
enqueueInputEvent(event, this, 0, true);
}
}
void enqueueInputEvent(InputEvent event,
InputEventReceiver receiver, int flags, boolean processImmediately) {
...
QueuedInputEvent q = obtainQueuedInputEvent(event, receiver, flags);
QueuedInputEvent last = mPendingInputEventTail;
last.mNext = q;
...
doProcessInputEvents();
}
}
doProcessInputEvents()循环着取出所有的事件,按照顺序将事件交给deliverInputEvent(),而它的工作就是将事件发送到InputStage的表头中。
void doProcessInputEvents() {
...
while (mPendingInputEventHead != null) {
QueuedInputEvent q = mPendingInputEventHead;
mPendingInputEventHead = q.mNext;
...
deliverInputEvent(q);
}
}
private void deliverInputEvent(QueuedInputEvent q) {
...
InputStage stage;
if (q.shouldSendToSynthesizer()) {
stage = mSyntheticInputStage;
} else {
stage = q.shouldSkipIme() ? mFirstPostImeInputStage : mFirstInputStage;
}
stage.deliver(q);
...
}
InputStage是一个单向链表结构,这个链表中有一系列的InputStage,他们都能对事件做出立,事件就在它们上面传递,如果事件没有被前面的InputStage标记Finished,当前的InputStage就会尝试消费它。其中ViewPostImeInputStage就会将事件发送到View处理处理。
ViewPostImeInputStage.onProcess()拿到事件后,交给ProcessPointerEvent(),这就调用到mView.dispatchPointerEvent(),而ViewRootImpl.mView众所周知,也就是DecorView,也就是终于走到了View中进行处理了!(这里还有疑问的翻阅一下上一篇文章)。
final class ViewPostImeInputStage extends InputStage {
...
@Override
protected int onProcess(QueuedInputEvent q) {
...
return processPointerEvent(q);
}
private int processPointerEvent(QueuedInputEvent q) {
final MotionEvent event = (MotionEvent)q.mEvent;
...
boolean handled = mView.dispatchPointerEvent(event);
...
return handled ? FINISH_HANDLED : FORWARD;
}
}
首先ViewRootImpl将事件传递到DecorView.dispatchPointerEvent(),DecorView没有实现这个方法,实际调用View.dispatchPointerEvent(),而它又调用了DecorView.dispatchTouchEvent()将事件传回DecorView。
紧接着DecorView通过Window.getCallback()将事件传递到了Acitivity.dispatchTouchEvent()。
Activity又将事件又传给了PhoneWindow,PhoneWindow将事件直接透传回DecorView,最终调用到super.dispatchTouchEvent()也就是ViewGroup.dispatchTouchEvent()。
///View
public final boolean dispatchPointerEvent(MotionEvent event) {
...
return dispatchTouchEvent(event);
}
///DecorView
public boolean dispatchTouchEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
...
return cb.dispatchTouchEvent(ev);
}
///Activity
public boolean dispatchTouchEvent(MotionEvent ev) {
...
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
///PhoneWindow
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
///DecorView
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
这一步看起来会有点疑惑行为的感觉:
第一点,为什么DecorView要把事件传递给Activity然后又传递回来?
Activity.dispatchTouchEvent中首先将事件又交给View树,如果没有被消费,Activity就可以拿到这个事件并做处理,官方注释写到在其中可以处理在窗口范围外发生的触摸事件效果最佳。如此,就可以理解为,Activity希望做事件分发的起点,所以需要传给Activity由它来启动。
第二个问题,传递的目标是交给Activity然后再传递给DecorView,为什么ViewRootImpl将事件传给了DecorView而不是直接传递给Activity?而Activity为什么通过Window去传递?而且PhoneWindow中就只是将事件直接透传给DecorView。
这个问题在上一篇文章就讨论过,这样设计的目的是单一原则以及解耦,ViewRootImpl并不知道Activity的存在,它只有DecorView的对象,同理Activity并不知道DecorView的存在,它只有Window的对象,上一次说到Window存在的意义就是承载和处理视图,Activity并不关心它内部是怎样实现的,只需要将事件交给他就行了,另一方面,这几个类尽量最少的持有对方,最大程度上的实现解耦,而解耦是为了什么,就么有必要再累赘了。
二、事件如何完成分发?
上一节分析到,事件被分发到了DecorView,并且调用了super.dispatchTouchEvent(),也就是ViewGroup.dispatchTouchEvent()。这一节主要分析一下ViewGroup.dispatchTouchEvent()都做了那些事情,又是如何完成了事件的向下传递。
2.1. ViewGroup.dispatchTouchEvent()
ViewGroup.dispatchTouchEvent()比较长,将这个方法大致拆分为三个步骤来进行分析:1. 拦截 2. 寻找分发目标 3. 分发
首先先了解一下流程中十分重要的两个概念:
-
PointerId:触摸点Id,多指操作时,每根手指从按下、移动到离开屏幕,都会拥有一个固定PointerId与对应的手指进行绑定。Id的范围是0..31,官方目前假设这是安全的,因为底层的管道也是这样设计的。32个数字范围的优点就是可以用32位的
int来存储一组pointerId,并且方便去计算和查询。 -
TouchTarget:缓存触摸的child以及它所消费的触摸点Id集合,也就表示事件派发目标。TouchTarget是一个单向链表结构,ViewGroup通过mFirstTouchTarget持有表头。TouchTarget自身维护了一个复用池,池子的容量也是32个。
2.1.1. 拦截
分发最首先的操作是看自己是否要拦截这一次事件,如果自身选择了拦截,事件就不再下发,而是交给自己处理。
public boolean dispatchTouchEvent(MotionEvent ev) {
...
///消费标志
boolean handled = false;
///onFilterTouchEventForSecurity():安全检查
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
///如果是ACTION_DOWN,就表示一组新的事件的开始,清除旧的TouchTarget以及重置滚动状态
if (actionMasked == MotionEvent.ACTION_DOWN) {
cancelAndClearTouchTargets(ev);
resetTouchState();
}
// 拦截标志
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
///判断是否允许进行拦截
///子view调用parent.requestDisallowInterceptTouchEvent()影响的就是这里
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
///onInterceptTouchEvent():拦截的方法,子类一般通过实现这个方法来进行事件拦截
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action);
} else {
intercepted = false;
}
} else {
///不是ACTION_DOWN并且没有touchTarget
///也就表示已经之前所有的view都接受到之前ACTION_DOWN事件,但都没有消费
///也就是没有视图会愿意接受这个事件,直接拦截掉
intercepted = true;
}
...
}
}
子view通过调用parent.requestDisallowInterceptTouchEvent()阻止parent拦截事件分析一下前置条件actionMasked == MotionEvent.ACTION_DOWN和mFirstTouchTarget != null。
- 当第一个条件成立,即是一次
ACTION_DOWN事件,那么在执行判断之前就会先执行clear操作,不仅清除了TouchTarget,也清除了mGroupFlags标志位,而又可以看到阻止拦截就是通过mGroupFlags标志,所以child设置的拦截标志直接被重置掉了。也就是说当是ACTION_DOWN事件的时候,子view的阻止拦截会直接失效,还可以说,child阻止parent拦截最多到下一次ACTION_DOWN事件的时候。 - 当第二个条件成立,
mFirstTouchTarget不为空,并且不是ACTION_DOWN事件,这个时候才会根据标志判断是否走拦截的方法。
总体分析,如果想要进行拦截,首先就是child要拿到拿到ACTION_DOWN或者稍后的事件,这个时候再调用parent.requestDisallowInterceptTouchEvent()更改标志位来阻止parent拦截这个ACTION_DOWN同一组的事件事件。
2.1.2. 寻找分发目标
当事件不是ACTION_CANCEL并且没有被拦截,而且必须是一个DOWN事件,才会去寻找派发的目标View。首先如果是ACTION_CANCEL或者被拦截,事件都没有了继续传递的必要,其次,DOWN之类的事件标记着一组事件的起始,如果想要对一组事件进行处理,必须在DOWN产生的时候就拿到并处理过它,而这种情况就已经存在了TouchTarget,也是没有查找派发目标的必要。
其次就是遍历所有的子view,寻找符合条件的view。而遍历时的顺序首先遵循Z轴的顺序,也就是视觉上的从前面到后面,其次是遵循绘制顺序,最后绘制的优先级最高,这样在视图有重叠的时候,也是视觉上的从前面到后面。
遍历的时候,需要根据多个条件来筛选判断当前view是否会接受这个事件,筛选的先后顺序:
- view是否可见或者在是否在播放动画,而播放动画可以理解为,在将来某一个时刻可能会可见。
- 触摸点是否命中到view的范围内。
- 查找
TouchTarget链表是否已经有了child的TouchTarget,如果有了只需要将触摸点Id存入这个TouchTarget,然后跳出循环,走后续的派发流程就可。这种情况会发生在多指操作的时候,第一根手指落在一个view上面之后,第二根手指也落在这个view上面,就只需要继续讲这个事件发送给这个view即可。 - 将事件直接派发给当前
child去处理,即将事件向下传递,如果当前child或者它的child处理了这个事件,那么当前这个child就是派发目标,新建一个TouchTarget存入到TouchTarget链表的头部。
当遍历完所有的child没有找到派发的目标,并且TouchTarget链表不是空的,那么就会将最早添加的TouchTarget作为派发目标。
public boolean dispatchTouchEvent(MotionEvent ev) {
...
///消费标志
boolean handled = false;
///onFilterTouchEventForSecurity():安全检查
if (onFilterTouchEventForSecurity(ev)) {
...
///ACTION_CANCEL事件标志
final boolean canceled = resetCancelNextUpFlag(this)
|| actionMasked == MotionEvent.ACTION_CANCEL;
///分裂标志(是否支持多指操作)
final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;
TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;///已经分配给新的TouchTarget标志
///1. 不是取消并且没被拦截才进行派发
///2. 是一个DOWN事件(ACTION_HOVER_MOVE是指针悬浮在view上)
/// 表示是一个新的事件序列开始了,需要重新找Target
if (!canceled && !intercepted) {
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
final int actionIndex = ev.getActionIndex(); // always 0 for down
///触摸点ID转换为32位标志
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS;
///清除TouchTarget列表中的相同的触摸点Id,因为是一个新的事件队列,之前的就已经失效了
removePointersFromTouchTargets(idBitsToAssign);
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);
///构建一个子view的列表在原有的顺序基础上,优先根据z轴排序
///这个列表会在所有子view都没有z轴返回空值
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
///view是否按规定正常绘制的标志
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
///preorderedList可能为null,通过这两个方法获的实际的index和child
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
...
///判断是否要将事件发给child
///canViewReceivePointerEvents():子类是否可以接收,条件是view可见 或者 正在播放动画
///isTransformedTouchPointInView():触摸点是否命中view范围
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
///查询已有的TouchTarget列表中是否已经有该child的TouchTarget
///如果有的话只需要给触摸点Id集合添加新的Id即可
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
///重置child的detached标志
///(child未将事件处理完成就detached,就会将后续的事件设为CANCEL)
resetCancelNextUpFlag(child);
///dispatchTransformedTouchEvent():将事件向下传递交给view去处理
///返回true就是view成功消费了这个事件
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
///如果返回了true,更新一下相关数据,停止循环
///并且创建一个TouchTarget,插入到mFirstTouchTarget前面作为表头
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;///已经派发了事件的标志
break;
}
}
if (preorderedList != null) preorderedList.clear();
}
///如果没有找到派发的目标,但是存在旧的TouchTarget
///将事件派发给最早添加的Target
if (newTouchTarget == null && mFirstTouchTarget != null) {
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
}
...
}
}
需要注意的是,当是一次ACTION_DOWN事件的时候,大概率在这个过程中并不会存在TouchTarget,那么在查找派发目标的过程中,就已经完成了派发的动作,也就是说这个事件到这里已经完成了它的分发流程。
2.1.3. 分发
分发的逻辑比较清晰,遍历TouchTraget,尝试给它分发事件。有几个比较特殊的情况:
TouchTarget链表为空,就直接将事件发送给自己处理。- 在寻找派发目标的时候,已经完成了派发,就不需要再执行派发。
- 如果事件被拦截了,给所有的
TouchTarget发送取消事件。
public boolean dispatchTouchEvent(MotionEvent ev) {
...
///消费标志
boolean handled = false;
///onFilterTouchEventForSecurity():安全检查
if (onFilterTouchEventForSecurity(ev)) {
...
if (mFirstTouchTarget == null) {
///没找到派发目标就发给自己来处理
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
///给TouchTarget派发事件
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
///已经在上一个步骤派发过
handled = true;
} else {
///派发给对应的TouchTarget
///如果拦截了事件,就会给所有的TouchTarget发送CANCEL事件
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;
}
}
/// 当是取消事件或者是ACTION_UP之类的事件,执行清除的操作。
if (canceled
|| actionMasked == MotionEvent.ACTION_UP
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
resetTouchState();
} else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
final int actionIndex = ev.getActionIndex();
final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
removePointersFromTouchTargets(idBitsToRemove);
}
}
return handled;
}
这里发觉到的一个比较有意思的逻辑是,事件按理说应该只交给一个child去处理,为什么要给所有的TouchTarget分发?奥秘就在TouchTarget存储的触摸点Id集合中,在查找分发目标的过程中,首先的步骤就是清除了TouchTarget链表中相同的触摸点Id,而分发的函数dispatchTransformedTouchEvent()中,又会根据Id集合是否包含当前事件的触摸点Id去选择是否下发,所以实际上只将事件发送到了唯一的持有当前事件触摸点Id的TouchTarget上面。而对于拦截了的情况,就会给所有的TouchTarget发送取消事件并且清除掉TouchTarget。
2.2. ViewGroup.dispatchTransformedTouchEvent()
在上一节中,时常会提到这个方法ViewGroup.dispatchTransformedTouchEvent(),当确定了分发目标的时候,就会调用这个方法进行分发,做一些预处理之后进行分发。主要的步骤:
ACTION_CANCEL事件直接下发。- 检查事件的触摸点Id和传入的触摸点Id是否有交集,也就是上一节讨论的为什么给所有的
TouchTarget分发的问题。如果没有交集,说明这个TouchTarget不是分发目标,直接返回。 - 根据事件的触摸点Id集合和上一步得到的交集是否有差异,做一些分裂处理。当两者相同,直接复制一份即可,当量者不相同,只有可能是事件的集合比交集的集合多,所以对事件进行分裂。这种情况发生在多指操作时,事件中包含了多个触摸点Id的信息,但当前分发的
TouchTarget表示这个child只接受其中的一部分,也就是,多个手指落在了不同的view上面,需要拆开进行分发。 - 将坐标轴偏移到child的坐标轴上面,然后进行下发。
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;
///取消事件直接下发
final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}
///触摸点id过滤
final int oldPointerIdBits = event.getPointerIdBits();
final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;
///取和等与0,没有交集,不需要下发,直接返回
if (newPointerIdBits == 0) {
return false;
}
///根据情况,对事件进行一定的分裂
final MotionEvent transformedEvent;
if (newPointerIdBits == oldPointerIdBits) {
if (child == null || child.hasIdentityMatrix()) {
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
event.offsetLocation(offsetX, offsetY);
handled = child.dispatchTouchEvent(event);
event.offsetLocation(-offsetX, -offsetY);
}
return handled;
}
transformedEvent = MotionEvent.obtain(event);
} else {
///当触摸点id不相等的时候,需要对事件进行过滤
///只拿到需要下发的触摸点id的事件进行下发
transformedEvent = event.split(newPointerIdBits);
}
///分发
if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}
handled = child.dispatchTouchEvent(transformedEvent);
}
transformedEvent.recycle();
return handled;
}
这个方法中,有很多的child == null判断,然后调用super.dispatchTouchEvent(),而这时候ViewGroup执行super.dispatchTouchEvent()调用的是View.dispatchTouchEvent(),也就是事件交给了自己来处理。
认真看代码还会发现一个奇怪的事情,在分裂的时候,有一段和后面分发完全一致的代码,也就是在分裂直接进行了分发。为什么重复的代码要写两遍?
分析一下走第一部分不走第二部分的条件:newPointerIdBits == oldPointerIdBits
和child.hasIdentityMatrix()==true(变换矩阵是单位矩阵,也就是没有变化)。
简单分析不考虑多指操作不同的view,也就是newPointerIdBits == oldPointerIdBits的情况下, 剩下的条件就只有child.hasIdentityMatrix()。
没有变换矩阵走第一段代码,有变换矩阵走第二段代码,两段代码的区别也就是后面的那段代码使用的是复制的MotionEvent,进行了矩阵映射,并且使用结束了就直接回收了;而前面的那段代码是直接使用的传入的MotionEvent,使用后进行了坐标系的复原。
而这就体现出两端代码的区别:如果有矩阵映射的情况下,第一段代码会多一个反映射的复原操作,而第二段代码多的是一个MotionEvent创建销毁的操作。我猜测,这可能就是在变换矩阵反映射和MotionEvent创建销毁之间取得的一点性能上得优化吧。
2.3. View.dispatchTouchEvent()
走到这也就到了一次触摸事件分发的尾声了,当View.dispatchTouchEvent()执行的时候,只需要将事件分发给自身处理即可,自身又不止一个处理方法,所以也就会有个优先级来决定先后顺序:
- 事件首先交给滚动条去处理,判断是否是拖动滚动条的事件。
- 事件交给
onTouchListener处理,也就是通过外部设置的监听。 - 最后如果前两个步骤都没有消费掉事件,才会交给自身
onTouchEvent()处理。
public boolean dispatchTouchEvent(MotionEvent event) {
...
boolean result = false;
final int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_DOWN) {
stopNestedScroll();
}
if (onFilterTouchEventForSecurity(event)) {
///滚动条处理
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
///onTouchListener处理
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
///onTouchEvent()处理
if (!result && onTouchEvent(event)) {
result = true;
}
}
...
if (actionMasked == MotionEvent.ACTION_UP ||
actionMasked == MotionEvent.ACTION_CANCEL ||
(actionMasked == MotionEvent.ACTION_DOWN && !result)) {
stopNestedScroll();
}
return result;
}
写在最后
这次尝试将手指触摸到屏幕上到控件获取到这次触摸的流程做一次完整的剖析,没有想到工作量竟然如此大,文章长度达到了5000词。收获颇丰,不仅学到了很多源码设计的巧妙之处,同时是源码的阅读能力还是事件分发的理解,都上了一个大台阶。
入行浅,起步要稳,一步一个脚印,做大做强,再创辉煌。