Android 事件分發

概念

在移動設備上,我們去做一些操作,無論是 Android 還是 IOS 其實在系統中是根據事件驅動的,用戶通過屏幕與手機交互的時候,每一次的點擊,長按,移動等都是一個事件。
而事件分發機制呢?他其實是因爲 Android 每一個頁面都是基於 Activity 進行實現的,一個 Activity 裏面有若干個 View 以及若干個 ViewGroup 組成的,而事件分發機制就是某一個事件從屏幕傳遞給各個 View,由這個 View 來消費這個事件或者忽略這個事件,交與其他 View 進行消費的這個過程的控制。
事件分發的對象是什麼呢?系統會把整個事件封裝爲 MotionEvent 對象,事件分發的過程就是 MotionEvent 對象分發的過程

事件的類型

結合我們的人爲操作過程,事件的類型有下面四種:

  • 按下(ACTION_DOWN)
  • 移動(ACTION_MOVE)
  • 擡起(ACTION_UP)
  • 取消(ACTION_CANCEL)

所以說一個完整的事件序列是從手指按下屏幕開始,到手指離開屏幕爲止所產生的一系列事件。也就是說一個完整的事件序列是以一個 ACTION_DOWN 事件開始,到一個 ACTION_UP 事件爲止,中間有若干個 ACTION_MOVE 事件(當然可以沒有)。
在同一個事件序列中,如果子 View / ViewGroup 沒有消費該事件,那同一事件序列的後續事件就不會傳遞到該子 View / ViewGroup 中去。

事件分發

那 Android 中是怎樣傳遞事件的呢?

接觸屏幕
Activity
Window
DecorView
ViewGroup
View

其實主要的對象就是 Activity,ViewGroup 以及 View。事件的分發就是對這一系列的傳遞的操作。接下來我們就圍繞這三種主要的對象的事件分發來進行理解。

Activity

流程圖

下面就是 Activity 事件分發的流程圖:
Activity 流程圖

源碼分析

我們從流程圖中可以知道當事件開始觸發的時候會調用 dispatchTouchEvent 方法,那我們來看下對應的源碼:

public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        onUserInteraction();
    }
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    return onTouchEvent(ev);
}

從源碼中我們可以知道當事件的類型是 DOWN 的時候,會執行 onUserInteraction 方法

public void onUserInteraction() {
}

然後進入這個方法,我們可以發現在源碼中該方法爲空方法。所以說當我們需要監聽按下手勢的時候,重寫 onUserInteraction 方法就可以達到監聽的效果。
然後接着往下面看,我們就會發現會調用 Window 的 superDispatchTouchEvent 方法。假如消費了該事件的話,就會返回 true ,代表事件已被消費,否則調用 onTouchEvent 方法消費該事件。

public abstract boolean superDispatchTouchEvent(MotionEvent event);

觀看源碼後我們發現其實 superDispatchTouchEvent 方法是一個抽象方法。我們看 Window 類的註釋會發現

* <p>The only existing implementation of this abstract class is
* android.view.PhoneWindow, which you should instantiate when needing a
* Window.

其實他有唯一的實現,就是 PhoneWindow 類,然後我們看下對應的實現類的方法:

@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
}

其實可以發現調用的是 DecorView 的 superDispatchTouchEvent 方法,而 DecorView 呢?其實就是 Activity 頂層的View,也我們 setContentView 方法傳遞進來的 layout 就是添加到了這個 View 上面。

public boolean superDispatchTouchEvent(MotionEvent event) {
    return super.dispatchTouchEvent(event);
}

然後我們就發現他其實調用了他的父類的 dispatchTouchEvent 方法,也就是 ViewGroup 的 dispatchTouchEvent 方法,那這個方法就一起在後面的 ViewGroup 裏面記錄。
接下來就是說假如 getWindow().superDispatchTouchEvent(ev) 返回了 true ,那就什麼該事件已經被消費了,直接返回就行,如果返回的是 false ,就說明當前任何視圖都沒有處理這個事件,那我們就是要調用 Activity 的 onTouchEvent 方法去消費該事件,並且直接返回 onTouchEvent 方法的結果。

public boolean onTouchEvent(MotionEvent event) {
    if (mWindow.shouldCloseOnTouch(this, event)) {
        finish();
        return true;
    }
    return false;
}

如果 mWindow.shouldCloseOnTouch(this, event) 返回結果爲 true ,就將該 Activity finish掉,並且返回true,否則爲 false。

/** @hide */
public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
    final boolean isOutside =
            event.getAction() == MotionEvent.ACTION_DOWN && isOutOfBounds(context, event)
            || event.getAction() == MotionEvent.ACTION_OUTSIDE;
    if (mCloseOnTouchOutside && peekDecorView() != null && isOutside) {
        return true;
    }
    return false;
}

mCloseOnTouchOutside 只有當 Activity 是以 Dialog 方式進行實現的時候纔會爲 true 否則爲 false
然後怎麼確保有 View 呢?其實 peekDecorView 方法就是用來獲取當前 Activity 的 DecorView 的
最後怎麼保證是點擊在 View 外部的呢,其實就是靠 isOutside 變量。所以我們就可以理解爲什麼之前會調用 finish 方法了。所以說只有在當 Activity 是以 Dialog 方式進行實現的時候,並且點擊了 View 外部的空白纔會將該 Activity 關閉,否則不做任何處理直接消費事件。

ViewGroup

流程圖

上面說到了之後就會進入 ViewGroup 的 dispatchTouchEvent 方法,這個方法就是標誌着事件已經到了 ViewGroup 這一層。然後呢?就是 onInterceptTouchEvent 方法,這個方法的意義就是是否攔截事件,假如返回結果爲 true 的話,則代碼該事件,當前的 ViewGroup 會攔截該事件,事件就不會再向下傳遞了。最後就是 onTouchEvent 方法了。這個方法在 ViewGroup 中沒有實現,而是在 View 中進行實現的。這個方法就是用來當我們把事件攔截了以後,自己來處理這個事件重寫的。
下面就是 ViewGroup 的事件分發流程圖:
ViewGroup 流程圖

源碼分析

我們首先就來看一下 ViewGroup 的 dispatchTouchEvent 方法,源碼的行數較多,就不貼上來了,其實這個方法主要就是做了三件事:

  1. 判斷是否需要攔截事件
  2. 在當前的 ViewGroup 中找到用戶真正點擊的 View
  3. 分發事件到 View 上

根據流程圖我們可以發現從 onFilterTouchEventForSecurity 方法開始進入事件分發的過程
onFilterTouchEventForSecurity 方法做的就是一些安全策略的操作,主要的用處就是去判斷這個 View 是不是可以被觸摸,假如這個視圖被其他視圖遮擋,那就不會去處理這個事件。

public boolean onFilterTouchEventForSecurity(MotionEvent event) {
    //noinspection RedundantIfStatement
    if ((mViewFlags & FILTER_TOUCHES_WHEN_OBSCURED) != 0
            && (event.getFlags() & MotionEvent.FLAG_WINDOW_IS_OBSCURED) != 0) {
        // Window is obscured, drop this touch.
        return false;
    }
    return true;
}

如果這個方法返回了 false,那就說明安全策略不通過,所以直接返回 false,否則的話再進行後面的事件分發。
然後我們就會對這個事件的類型進行判斷,假如這個事件的類型是一個按下的操作的時候,就回去做一些初始化的操作,因爲按下是一個事件系列的開始。

if (actionMasked == MotionEvent.ACTION_DOWN) {
    // Throw away all previous state when starting a new touch gesture.
    // The framework may have dropped the up or cancel event for the previous gesture
    // due to an app switch, ANR, or some other state change.
    cancelAndClearTouchTargets(ev);
    resetTouchState();
}

cancelAndClearTouchTargets 方法是用於取消和清除所有的觸摸目標,然後通過 resetTouchState 方法來重置觸摸狀態。
然後就要開始檢測當前的事件是否是需要攔截的,就是靠 intercepted 這個變量去記錄的。

final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    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 {
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
}

如果當前事件爲按下事件或者是已經有處理改事件的子 View 的時候就要進入後面的判斷,否則就爲 true,表示的是事件被攔截,事件就不會再向下傳遞。disallowIntercept 變量的意義就是判斷當前事件是否可以攔截,如果爲 true 的話,就代表當前事件在這個 ViewGroup 是不允許被攔截的,如果爲 false 代表這個事件是可以被攔截的,然後就要通過 onInterceptTouchEvent 方法來判斷是否對事件攔截。

public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
            && ev.getAction() == MotionEvent.ACTION_DOWN
            && ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
            && isOnScrollbarThumb(ev.getX(), ev.getY())) {
        return true;
    }
    return false;
}

這幾個判斷條件都是什麼呢?

  1. 判斷當前事件是不是來自鼠標(因爲一般我們使用都不會使用鼠標進行操作,所有一般來說,這個返回爲 false)
  2. 當前事件是不是按下事件
  3. 判斷當前我們是否按下鼠標左鍵(如果按下返回 true,否則返回 false)
  4. 判斷當前觸摸位置是不是在一個滾動條的上面(如果是的話返回爲 true,否則爲 false)

只有這四個條件都滿足的話,我們纔會去攔截這個事件。所以說一般情況下 onInterceptTouchEvent 方法都是返回 false,不去攔截該事件的。
接下來就是判斷改事件是不是一個取消事件:

final boolean canceled = resetCancelNextUpFlag(this) || actionMasked == MotionEvent.ACTION_CANCEL;

然後再去判斷該事件是不是作用與多個視圖:

final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;

最後如果即通過了安全判斷也不是取消事件以後就是 開始進入事件分發的邏輯。

if (actionMasked == MotionEvent.ACTION_DOWN|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN) || actionMasked == MotionEvent.ACTION_HOVER_MOVE)

當事件的類型是按下或者是移動的時候進入事件的分發,首先系統就會清除之前觸摸點的信息,然後判斷當前觸摸點是否大於 0,之後就去獲取當前觸摸點的座標。並且獲取到可以接受到該觸摸事件的子 View 的集合 preorderedList,以及判斷是否對自定義 View 繪製順序有要求。

final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);
// Find a child that can receive the event.
// Scan children from front to back.
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
        && isChildrenDrawingOrderEnabled();

然後就開始對這個子 View 列表進行遍歷。然後通過索引獲取到每一個子 View

final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);

getAndVerifyPreorderedIndex 方法裏面就利用到了我們之前的 customOrder 變量,如果當前的 View 的繪製是有自定義順序的話就要通過 getChildDrawingOrder 方法去獲取,這個方法就是在我們自定義繪製順序的時候需要重寫的方法,否則索引就是列表的下標。

private int getAndVerifyPreorderedIndex(int childrenCount, int i, boolean customOrder) {
    final int childIndex;
    if (customOrder) {
        final int childIndex1 = getChildDrawingOrder(childrenCount, i);
        if (childIndex1 >= childrenCount) {
            throw new IndexOutOfBoundsException("getChildDrawingOrder() "
                    + "returned invalid index " + childIndex1
                    + " (child count is " + childrenCount + ")");
        }
        childIndex = childIndex1;
    } else {
        childIndex = i;
    }
    return childIndex;
}

然後就是通過 getAndVerifyPreorderedView 方法去獲取對應的 View。

private static View getAndVerifyPreorderedView(ArrayList<View> preorderedList, View[] children, int childIndex) {
    final View child;
    if (preorderedList != null) {
        child = preorderedList.get(childIndex);
        if (child == null) {
            throw new RuntimeException("Invalid preorderedList contained null child at index "
                    + childIndex);
        }
    } else {
        child = children[childIndex];
    }
    return child;
}

然後就是判斷這個 View 能否接受到觸摸事件以及當前的觸摸事件是不是在這個 View 範圍之內。

if (!canViewReceivePointerEvents(child)
        || !isTransformedTouchPointInView(x, y, child, null)) {
    ev.setTargetAccessibilityFocus(false);
    continue;
}

如果兩個方法都返回了 true,就說明這個 View 就可以處理該事件。然後就要獲取當前 View 的觸摸對象。

newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
    // Child is already receiving touch within its bounds.
    // Give it the new pointer in addition to the ones it is handling.
    newTouchTarget.pointerIdBits |= idBitsToAssign;
    break;
}

resetCancelNextUpFlag(child);

如果該 View 已經有對應的觸摸對象的話直接退出循環即可,否則的話就表示該 View 還沒有對應的觸摸事件,然後就要去判斷,該 View 有沒有設置不接收觸摸事件的標誌位,如果有的話就清除這個標誌。
然後就是最主要的一個方法了,dispatchTransformedTouchEvent 方法裏面講述了一個事件是如何從一個 ViewGroup 傳遞到一個具體的 View 中是如何過度的。

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;
}

首先我們會判斷該事件是否爲一個取消事件,如果是取消事件的話,就要去判斷是否有 View 處理,如果 child 爲 null 則直接用 ViewGroup 父類的 dispatchTouchEvent 方法處理,否則調用 View 的方法,最後直接返回結果。

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;
}

如果不是取消事件的話,就會去獲取一個新的指針位,如果指針位爲 0 的話,直接返回 false

// Calculate the number of pointers to deliver.
final int oldPointerIdBits = event.getPointerIdBits();
final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;
// If for some reason we ended up in an inconsistent state where it looks like we
// might produce a motion event with no pointers in it, then drop the event.
if (newPointerIdBits == 0) {
    return false;
}

然後呢就要去判斷原有的指針位和新的指針位是不是一樣的,如果是一樣的就要去判斷子視圖是否存在,如果沒有的話還是調用父類的 dispatchTouchEvent 方法處理,否則就要去計算子 View 的偏移量,然後調用子 View 的 dispatchTouchEvent 方法處理。

// If the number of pointers is the same and we don't need to perform any fancy
// irreversible transformations, then we can reuse the motion event for this
// dispatch as long as we are careful to revert any changes we make.
// Otherwise we need to make a copy.
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 {
    transformedEvent = event.split(newPointerIdBits);
}

如果前面的兩個條件都不滿足,沒有返回值的話,就要創建出一個 MotionEvent 類,然後再去判斷子 View 是否爲空,如果爲空的話還是調用父類的 dispatchTouchEvent 方法處理,否則就要去計算子 View 的偏移量,然後調用子 View 的 dispatchTouchEvent 方法處理。最後釋放相關資源。

// Perform any necessary transformations and dispatch.
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);
}

// Done.
transformedEvent.recycle();

然後如果 dispatchTransformedTouchEvent 方法返回了 true 的話,就代表了已經傳遞到了子 View 的 dispatchTouchEvent 方法了,也就代表了該事件已經被消費了,所以就可以直接結束。

if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
    // Child wants to receive touch within its bounds.
    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;
}

這樣我們就解決了當前事件不爲取消事件以及子 View 允許事件傳遞的情況。然後就要看通用的情況了。

// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
            TouchTarget.ALL_POINTER_IDS);
}

在這個時候 mFirstTouchTarget 還是爲 null 的話,就說明沒有子 View 去執行這個事件,就要通過 dispatchTransformedTouchEvent 方法去消費事件獲取返回值,我們就可以發現他的 View 的傳值爲 null,在之前講的 dispatchTransformedTouchEvent 方法裏面,就會交與他的父類去執行 dispatchTouchEvent 方法。
如果不爲空的話就會遍歷整個鏈表,假如在之前已經處理了的話,他就會直接返回 true,否則的話就會去重新分發事件。

else {
    // Dispatch to touch targets, excluding the new touch target if we already
    // dispatched to it.  Cancel touch targets if necessary.
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;
    while (target != null) {
        final TouchTarget next = target.next;
        if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
            handled = true;
        } else {
            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;
    }
}

View

流程圖

最後就到了最後一步 View 了。Android 中事件在 View 裏面會怎麼進行處理呢?首先和前面是一樣的,就是 dispatchTouchEvent 方法,這個方法就是標誌着事件已經到了 View 這一層。然後呢?就是 onTouchEvent 方法了。這個方法裏面就是 Android 系統處理觸摸事件的相關邏輯。
下面就是 View 的事件分發流程圖:
View 流程圖

源碼分析

首先判斷該 View 是否有可相應焦點,如果沒有的話直接返回 false。

// If the event should be handled by accessibility focus first.
if (event.isTargetAccessibilityFocus()) {
    // We don't have focus or no virtual descendant has it, do not handle the event.
    if (!isAccessibilityFocusedViewOrHost()) {
        return false;
    }
    // We have focus and got the event, then use normal event dispatch.
    event.setTargetAccessibilityFocus(false);
}

然後對事件的類型進行判斷,如果當前的事件爲按下事件的話,如果存在視圖滾動效果的話就要立刻停止滾動

final int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_DOWN) {
    // Defensive cleanup for new gesture
    stopNestedScroll();
}

之後就開始進入真正的事件分發的過程了,首先和 ViewGroup 是一樣的,通過 onFilterTouchEventForSecurity 方法來進行事件的安全判斷。這塊的邏輯和 View Group 中是一樣的,如果不符合的話就返回 false ,如果符合的話就要接下來進行事件的處理了。

if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
    result = true;
}
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
        && (mViewFlags & ENABLED_MASK) == ENABLED
        && li.mOnTouchListener.onTouch(this, event)) {
    result = true;
}
if (!result && onTouchEvent(event)) {
    result = true;
}

首先就是判斷當前的操控方式是不是爲鼠標操作,如果爲鼠標操作的話,返回值就可以直接爲 ture 了,表示消費了該事件。然後就要檢查是否有觸摸事件的監聽,然後就要調用 listener 的 onTouch 方法,如果結果返回的是 true,則我們的返回結果也爲 true。如果還是不去消費該事件的話,就要調用 View 的 onTouchEvent 方法,根據返回的結果來進行返回。
然後就要進入 View 的 onTouchEvent 方法裏面來看了

final float x = event.getX();
final float y = event.getY();
final int viewFlags = mViewFlags;
final int action = event.getAction();
final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
        || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
        || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
if ((viewFlags & ENABLED_MASK) == DISABLED) {
    if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
        setPressed(false);
    }
    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
    // A disabled view that is clickable still consumes the touch
    // events, it just doesn't respond to them.
    return clickable;
}

首先獲取到點擊的位置座標,View 的標誌位,事件類型以及可點擊狀態,然後去判斷該 View 是否處於一個禁用狀態的話,返回結果就爲可點擊的狀態,這樣就可以說明,當 View 是處於一個禁用狀態的話,如果是可點擊的,也會去消費這個事件,但是因爲是直接返回的,所以說不會去多事件有所響應。
然後就會去判斷有沒有設置觸摸的代理。

if (mTouchDelegate != null) {
    if (mTouchDelegate.onTouchEvent(event)) {
        return true;
    }
}

如果有的話就要通過代理的 onTouchEvent 方法去獲取結果,如果能夠消費事件的話就直接返回 true,否則繼續後面的事件處理。
最後就是去判斷是否爲一個可點擊的狀態或者在標記位上 TOOLTIP 位爲 1 的話,就會開始根據事件類型的不同做不同的處理,然後返回 true,表示事件已經被消費,否則返回結構爲 false。

if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                    ...
                case MotionEvent.ACTION_DOWN:
                    ...
                case MotionEvent.ACTION_CANCEL:
                    ...
                case MotionEvent.ACTION_MOVE:
                    ...
            }

            return true;
        }

        return false;
    }
發佈了329 篇原創文章 · 獲贊 117 · 訪問量 13萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章