安卓自定義View進階-事件分發機制詳解
在上一篇文章事件分發機制原理 中簡要分析了一下事件分發機制的原理,原理是十分簡單的,一句話就能總結: 責任鏈模式,事件層層傳遞,直到被消費。 雖然原理簡單,但是隨著 Android 不斷的發展,實際運用場景也越來越復雜,所以想要徹底玩轉事件分發機制還需要一定技巧,本篇事件分發機制詳解將帶大家了解 …
你以為我接下來要講源碼?
我就不按套路,所有的源碼都是為了適應具體的應用場景而寫的,只要能夠理解運用場景,理解源碼也就十分簡單了。所以本篇的核心問題是: 正確理解在實際場景中事件分發機制的作用。 會涉及到源碼,但不是主角。
注意:本文中所有源碼分析部分均基于 API23(Android 6.0) 版本,由于安卓系統源碼改變很多,可能與之前版本有所不同,但基本流程都是一致的。
常見事件
既然是事件分發,總要有事件才能分發吧,所以我們先了解一下常見的幾種事件。
根據面向對象思想,事件被封裝成 MotionEvent 對象,由于本篇重點不在于此,所以只會涉及到幾個與手指觸摸相關的常見事件:
事件 | 簡介 |
---|---|
ACTION_DOWN | 手指 初次接觸到屏幕 時觸發。 |
ACTION_MOVE | 手指 在屏幕上滑動 時觸發,會會多次觸發。 |
ACTION_UP | 手指 離開屏幕 時觸發。 |
ACTION_CANCEL | 事件 被上層攔截 時觸發。 |
對于單指觸控來說,一次簡單的交互流程是這樣的:
手指落下(ACTION_DOWN) -> 移動(ACTION_MOVE) -> 離開(ACTION_UP)
- 本次事例中 ACTION_MOVE 有多次觸發。
- 如果僅僅是單擊(手指按下再抬起),不會觸發 ACTION_MOVE。
事件分發、攔截與消費
關于這一部分內容,上一篇文章事件分發機制原理 已經將流程整理的比較清楚了,本文會深入細節來研究這些內容。之所以分開講,是為了防止大家被細節所迷惑而忽略了整體邏輯。
√ 表示有該方法。
X 表示沒有該方法。
類型 | 相關方法 | ViewGroup | View |
---|---|---|---|
事件分發 | dispatchTouchEvent | √ | √ |
事件攔截 | onInterceptTouchEvent | √ | X |
事件消費 | onTouchEvent | √ | √ |
View 相關
dispatchTouchEvent 是事件分發機制中的核心,所有的事件調度都歸它管。不過我細看表格, ViewGroup 有 dispatchTouchEvent 也就算了,畢竟人家有一堆 ChildView 需要管理,但為啥 View 也有?這就引出了我們的第一個疑問。
Q: 為什么 View 會有 dispatchTouchEvent ?
A: 我們知道 View 可以注冊很多事件監聽器,例如:單擊事件(onClick)、長按事件(onLongClick)、觸摸事件(onTouch),并且View自身也有 onTouchEvent 方法,那么問題來了,這么多與事件相關的方法應該由誰管理?毋庸置疑就是 dispatchTouchEvent ,所以 View 也會有事件分發。
相信看到這里很多小伙伴會產生第二個疑問,View 有這么多事件監聽器,到底哪個先執行?
Q: 與 View 事件相關的各個方法調用順序是怎樣的?
A: 如果不去看源碼,想一下讓自己設計會怎樣?
- 單擊事件(onClickListener) 需要兩個兩個事件(ACTION_DOWN 和 ACTION_UP )才能觸發,如果先分配給onClick判斷,等它判斷完,用戶手指已經離開屏幕,黃花菜都涼了,定然造成 View 無法響應其他事件,應該最后調用。(最后)
- 長按事件(onLongClickListener) 同理,也是需要長時間等待才能出結果,肯定不能排到前面,但因為不需要ACTION_UP,應該排在 onClick 前面。(onLongClickListener > onClickListener)
- 觸摸事件(onTouchListener) 如果用戶注冊了觸摸事件,說明用戶要自己處理觸摸時間了,這個應該排在最前面。(最前)
- View自身處理(onTouchEvent) 提供了一種默認的處理方式,如果用戶已經處理好了,也就不需要了,所以應該排在 onClickListener 后面。(onClickListener > onTouchListener)
所以事件的調度順序應該是 onTouchListener > onTouchEvent > onLongClickListener > onClickListener。
下面我們來看一下實際測試結果:
手指按下,不移動,稍等片刻再抬起。
[Listener ]: onTouchListener ACTION_DOWN
[GcsView ]: onTouchEvent ACTION_DOWN
[Listener ]: onLongClickListener
[Listener ]: onTouchListener ACTION_UP
[GcsView ]: onTouchEvent ACTION_UP
[Listener ]: onClickListener
可以看到,測試結果也支持我們猜測的結論,因為長按 onLongClickListener 不需要 ACTION_UP 所以會在 ACTION_DOWN 之后就觸發。
接下來就看一下源碼是怎么設計的(省略了大量無關代碼):
public boolean dispatchTouchEvent(MotionEvent event) {
...
boolean result = false; // result 為返回值,主要作用是告訴調用者事件是否已經被消費。
if (onFilterTouchEventForSecurity(event)) {
ListenerInfo li = mListenerInfo;
/**
* 如果設置了OnTouchListener,并且當前 View 可點擊,就調用監聽器的 onTouch 方法,
* 如果 onTouch 方法返回值為 true,就設置 result 為 true。
*/
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
/**
* 如果 result 為 false,則調用自身的 onTouchEvent。
* 如果 onTouchEvent 返回值為 true,則設置 result 為 true。
*/
if (!result && onTouchEvent(event)) {
result = true;
}
}
...
return result;
}
如果覺得源碼還是太長,那么用偽代碼實現應當是這樣的(省略若干安全判斷),簡單粗暴:
public boolean dispatchTouchEvent(MotionEvent event) {
if (mOnTouchListener.onTouch(this, event)) {
return true;
} else if (onTouchEvent(event)) {
return true;
}
return false;
}
正當你沉迷在源碼的”精妙”邏輯的時候,你可能沒發現有兩個東西失蹤了,等回過神來,定睛一看,哎呦媽呀, OnClick 和 OnLongClick 去哪里了?
不要擔心,OnClick 和 OnLongClick 的具體調用位置在 onTouchEvent 中,看源碼(同樣省略大量無關代碼):
public boolean onTouchEvent(MotionEvent event) {
...
final int action = event.getAction();
// 檢查各種 clickable
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
(viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
switch (action) {
case MotionEvent.ACTION_UP:
...
removeLongPressCallback(); // 移除長按
...
performClick(); // 檢查單擊
...
break;
case MotionEvent.ACTION_DOWN:
...
checkForLongClick(0); // 檢測長按
...
break;
...
}
return true; // ??表示事件被消費
}
return false;
}
注意了,第一個重點要出現了(敲黑板)!
注意上面代碼中存在一個 return true; 并且是只要 View 可點擊就返回 true,就表示事件被消費了。
舉個栗子: I have a RelativeLayout ,I have a View ,Ugh, RelativeLayout - View
<RelativeLayout
android:background="#CCC"
android:id="@+id/layout"
android:onClick="myClick"
android:layout_width="200dp"
android:layout_height="200dp">
<View
android:clickable="true"
android:layout_width="200dp"
android:layout_height="200dp" />
</RelativeLayout>
現在你有了一個 RelativeLayout - View 你開開心心的為 RelativeLayout 設置了一個點擊事件 myClick ,然而你會發現不論怎么點都不會接收到信息,仔細一看,發現內部的 View 有一個屬性 android:clickable="true" 正是這個看似不起眼的屬性把事件給消費掉了,由此我們可以得出如下結論:
1. 不論 View 自身是否注冊點擊事件,只要 View 是可點擊的就會消費事件。
2. 事件是否被消費由返回值決定,true 表示消費,false 表示不消費,與是否使用了事件無關。
關于 View 的事件分發先說這么多,下面我們來看一下 ViewGroup 的事件分發。
ViewGroup 相關
ViewGroup(通常是各種Layout) 的事件分發相對來說就要麻煩一些,因為 ViewGroup 不僅要考慮自身,還要考慮各種 ChildView,一旦處理不好就容易引起各種事件沖突,正所謂養兒方知父母難啊。
VIewGroup 的事件分發流程又是如何的呢?
上一篇文章事件分發機制原理 中我們了解到事件是通過ViewGroup一層一層傳遞的,最終傳遞給 View,ViewGroup 要比它的 ChildView 先拿到事件,并且有權決定是否告訴要告訴 ChildView。在默認的情況下 ViewGroup 事件分發流程是這樣的。
- 1.判斷自身是否需要(詢問 onInterceptTouchEvent 是否攔截),如果需要,調用自己的 onTouchEvent。
- 2.自身不需要或者不確定,則詢問 ChildView ,一般來說是調用手指觸摸位置的 ChildView。
- 3.如果子 ChildView 不需要則調用自身的 onTouchEvent。
用偽代碼應該是這樣的:
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean result = false; // 默認狀態為沒有消費過
if (!onInterceptTouchEvent(ev)) { // 如果沒有攔截交給子View
result = child.dispatchTouchEvent(ev);
}
if (!result) { // 如果事件沒有被消費,詢問自身onTouchEvent
result = onTouchEvent(ev);
}
return result;
}
有人看到這里可能會有疑問,我看過源碼,ViewGroup 的 dispatchTouchEvent 可有二百多行呢,你弄這幾行就想忽悠我,別以為我讀書少。
當然了,上述源碼是不完善的,還有很多問題是沒有解決的,例如:
1. ViewGroup 中可能有多個 ChildView,如何判斷應該分配給哪一個?
這個很容易,就是把所有的 ChildView 遍歷一遍,如果手指觸摸的點在 ChildView 區域內就分發給這個View。
2. 當該點的 ChildView 有重疊時應該如何分配?
當 ChildView 重疊時, 一般會分配給顯示在最上面的 ChildView 。
如何判斷哪個是顯示在最上面的呢?后面加載的一般會覆蓋掉之前的,所以 顯示在最上面的是最后加載的 。
如下:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.gcssloop.viewtest.MainActivity">
<View
android:id="@+id/view1"
android:background="#E4A07B"
android:layout_width="200dp"
android:layout_height="200dp"/>
<View
android:id="@+id/view2"
android:layout_margin="100dp"
android:background="#BDDA66"
android:layout_width="200dp"
android:layout_height="200dp"/>
</RelativeLayout>
當手指點擊有重疊區域時,分如下幾種情況:
- 只有 View1 可點擊時,事件將會分配給 View1,即使被 View2 遮擋,這一部分仍是 View1 的可點擊區域。
- 只有 View2 可點擊時,事件將會分配給 View2。
- View1 和 View2 均可點擊時,事件會分配給后加載的 View2,View2 將事件消費掉,View1接收不到事件。
注意:
- 上面說的是可點擊,可點擊包括很多種情況,只要你給View注冊了 onClickListener、onLongClickListener、OnContextClickListener 其中的任何一個監聽器或者設置了 android:clickable="true" 就代表這個 View 是可點擊的。
另外,某些 View 默認就是可點擊的,例如,Button,CheckBox 等。 - 給 View 注冊 OnTouchListener 不會影響 View 的可點擊狀態。即使給 View 注冊 OnTouchListener , 只要不返回 true 就不會消費事件 。
3. ViewGroup 和 ChildView 同時注冊了事件監聽器(onClick等),哪個會執行?
事件優先給 ChildView,會被 ChildView消費掉,ViewGroup 不會響應。
4. 所有事件都應該被同一 View 消費
在上面的例子中我們分析后可以了解到,同一次點擊事件只能被一個 View 消費,這是為什呢?主要是為了防止事件響應混亂,如果再一次完整的事件中分別將不同的事件分配給了不同的 View 容易造成事件響應混亂。
( View 中 onClick 事件需要同時接收到 ACTION_DOWN 和 ACTION_UP 才能觸發,如果分配給了不同的 View,那么 onClick 將無法被正確觸發)。
安卓為了保證所有的事件都是被一個 View 消費的,對第一次的事件( ACTION_DOWN )進行了特殊判斷,View 只有消費了 ACTION_DOWN 事件,才能接收到后續的事件(可點擊控件會默認消費所有事件),并且會將后續所有事件傳遞過來,不會再傳遞給其他 View,除非上層 View 進行了攔截。
如果上層 View 攔截了當前正在處理的事件,會收到一個 ACTION_CANCEL,表示當前事件已經結束,后續事件不會再傳遞過來。
源碼:
其實如果能夠理解上面的內容,不看源碼也能非常順利的使用事件分發,但源碼中能挖掘出更多的內容。
public boolean dispatchTouchEvent(MotionEvent ev) {
// 調試用
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(ev, 1);
}
// 判斷事件是否是針對可訪問的焦點視圖(很晚才添加的內容,個人猜測和屏幕輔助相關,方便盲人等使用設備)
if (ev.isTargetAccessibilityFocus() && isAccessibilityFocusedViewOrHost()) {
ev.setTargetAccessibilityFocus(false);
}
boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
// 處理第一次ACTION_DOWN.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// 清除之前所有的狀態
cancelAndClearTouchTargets(ev);
resetTouchState();
}
// 檢查是否需要攔截.
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); // 恢復操作,防止被更改
} else {
intercepted = false;
}
} else {
// 沒有目標來處理該事件,而且也不是一個新的事件事件(ACTION_DOWN), 進行攔截。
intercepted = true;
}
// 判斷事件是否是針對可訪問的焦點視圖
if (intercepted || mFirstTouchTarget != null) {
ev.setTargetAccessibilityFocus(false);
}
// 檢查事件是否被取消(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;
// 如果沒有取消也沒有被攔截 (進入事件分發)
if (!canceled && !intercepted) {
// 如果事件是針對可訪問性焦點視圖,我們將其提供給具有可訪問性焦點的視圖。
// 如果它不處理它,我們清除該標志并像往常一樣將事件分派給所有的 ChildView。
// 我們檢測并避免保持這種狀態,因為這些事非常罕見。
View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
? findChildWithAccessibilityFocus() : null;
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
final int actionIndex = ev.getActionIndex();
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS;
// 清除此指針ID的早期觸摸目標,防止不同步。
removePointersFromTouchTargets(idBitsToAssign);
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex); // 獲取觸摸位置坐標
final float y = ev.getY(actionIndex);
// 查找可以接受事件的 ChildView
final ArrayList<View> preorderedList = buildOrderedChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
// ▼注意,從最后向前掃描
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = customOrder
? getChildDrawingOrder(childrenCount, i) : i;
final View child = (preorderedList == null)
? children[childIndex] : preorderedList.get(childIndex);
// 如果有一個視圖具有可訪問性焦點,我們希望它首先獲取事件,
// 如果不處理,我們將執行正常的分派。
// 盡管這可能會分發兩次,但它能保證在給定的時間內更安全的執行。
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}
// 檢查View是否允許接受事件(即處于顯示狀態(VISIBLE)或者正在播放動畫)
// 檢查觸摸位置是否在View區域內
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
// getTouchTarget 中判斷了 child 是否包含在 mFirstTouchTarget 中
// 如果有返回 target,如果沒有返回 null
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// ChildView 已經準備好接受在其區域內的事件。
newTouchTarget.pointerIdBits |= idBitsToAssign;
break; // ??已經找到目標View,跳出循環
}
resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
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;
}
ev.setTargetAccessibilityFocus(false);
}
if (preorderedList != null) preorderedList.clear();
}
if (newTouchTarget == null && mFirstTouchTarget != null) {
// 沒有找到 ChildView 接收事件
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
}
// 分發 TouchTarget
if (mFirstTouchTarget == null) {
// 沒有 TouchTarget,將當前 ViewGroup 當作普通的 View 處理。
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// 分發TouchTarget,如果我們已經分發過,則避免分配給新的目標。
// 如有必要,取消分發。
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;
}
}
// 如果需要,更新指針的觸摸目標列表或取消。
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);
}
}
if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}
return handled;
}
核心要點
- 事件分發原理: 責任鏈模式,事件層層傳遞,直到被消費。
- View 的 dispatchTouchEvent 主要用于調度自身的監聽器和 onTouchEvent。
- View的事件的調度順序是 onTouchListener > onTouchEvent > onLongClickListener > onClickListener 。
- 不論 View 自身是否注冊點擊事件,只要 View 是可點擊的就會消費事件。
- 事件是否被消費由返回值決定,true 表示消費,false 表示不消費,與是否使用了事件無關。
- ViewGroup 中可能有多個 ChildView 時,將事件分配給包含點擊位置的 ChildView。
- ViewGroup 和 ChildView 同時注冊了事件監聽器(onClick等),由 ChildView 消費。
- 一次觸摸流程中產生事件應被同一 View 消費,全部接收或者全部拒絕。
- 只要接受 ACTION_DOWN 就意味著接受所有的事件,拒絕 ACTION_DOWN 則不會收到后續內容。
- 如果當前正在處理的事件被上層 View 攔截,會收到一個 ACTION_CANCEL,后續事件不會再傳遞過來 。
總結
本文啰嗦了這么多內容,但真正需要注意的就是核心要點中的幾個概念,只要能正確理解這些概念,相信理解事件分發機制將再也不是難題。
最后,個人推薦閱讀源碼的方法,先嘗試用自己的角度去分析,建立概念,然后看源碼進行驗證、對比,如果發現自己建立的概念有問題,就嘗試修正自己的概念,這樣比較容易理解原作者的意圖,也不容易被眾多的代碼所迷惑。
就像 ViewGroup 中的 dispatchTouchEvent 內容非常多,主要是為了應對實際的場景,里面有很多 安全判斷,處理多指觸控 等內容,這些如果不先建立概念就去看源碼很容易被這些細節問題所迷惑。
參考資料
來自:http://www.gcssloop.com/customview/dispatch-touchevent-source