Android開發知識(八):Android事件處理機制:事件分發、傳遞、攔截、處理機制的原理分析(中)
在本章節中,我們重點談論一下onTouch、onClick、onLongClick三個方法被回撥的過程。
在上一篇文章中,我們談到關於為View新增一個點選事件SetOnClickListener後,就可以通過回撥onClick方法來實現事件的響應。而另外還有一個setOnTouchListener方法,通過設定監聽後可以在觸控的時候回撥onTouch方法。而我們又說到onTouchEvent方法是處理事件的。那麼這三個方法究竟有什麼區別呢?
我們在上篇文章的原始碼中,在Actvity裡來分別設定OnClick和onTouch的監聽,通過log來打印出他們的呼叫:
MyView view = (MyView) findViewById(R.id.Button);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Log.i("lc_miao","MyView : onClick");
}
});
view.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
Log.i("lc_miao" ,"MyView : onTouch");
return false;
}
});
我們來看一下關於onClick、onTouch、onTouchEvent三者的呼叫順序,執行後點擊按鈕,log如下:
從log上看,當事件被View接收後,在ACTION_DOWN的時候View會分別觸發onTouch和onTouchEvent方法,而在ACTION_UP的時候會分別觸發onTouch和onTouchEvent、onClick方法。
這說明了,在日常中我們給View設定點選事件其實響應優先順序是最低的,因為他需要同時接收到ACTION_DOWN和ACTION_UP事件後才會觸發,而onTouch方法則是在設定監聽後,只要有事件到來,則會觸發一次,它比onTouchEvent優先被響應。
事實上:
1、onTouch比onClick方法多了一個返回值,其返回值也表示了是否消耗事件,如果返回了true則不會再呼叫onTouchEvent方法
2、onClick方法是在onTouchEvent裡面被回撥的,如果onTouch返回了true,onTouchEvent不會被呼叫,那麼onClick也就不會被呼叫。
我們來檢視一下View的原始碼。從View接收到事件開始,也就是dispatchTouchEvent方法的原始碼。
由於Android系統版本的更新,我檢視的是android-23的原始碼,可能與較低版本的原始碼會有差異,但是整體的核心流程並不會有改變。
在原始碼中,我們關注方法中這部分重要的原始碼:
//如果設定了mOnTouchListener且onTouch返回true,則不走onTouchEvent
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
if (!result && onTouchEvent(event)) {
result = true;
}
原始碼中先判斷li != null && li.mOnTouchListener != null的條件,我們不用看原始碼也清楚mOnTouchListener 就是我們呼叫setOnTouchLister的時候賦值進去的。所以如果設定了OnTouchListener的話這裡條件就成立,其次(mViewFlags & ENABLED_MASK) == ENABLED,要View是enable狀態條件才成立,事實上預設就已經是enable狀態,除非呼叫setEnable(false)讓控制元件變為不可選取狀態。滿足了上面兩個條件後,則onTouch便會觸發,同時會把onTouch返回值作為條件。到這裡,我們也就清楚了onTouch的為什麼會被優先響應。
然後,假如onTouch消費了事件,也就是返回了true,則result變數則為true,導致下面的:
if (!result && onTouchEvent(event)) {
result = true;
}
從!result就已經條件不成立了,所以就不會呼叫onTouchEvent方法,除非onTouch返回false,這驗證了前面我們說的:
onTouch比onClick方法多了一個返回值,其返回值也表示了是否消耗事件,如果返回了true則不會再呼叫onTouchEvent方法
接下來我們再來檢視onTouchEvent的方法原始碼,同樣方法原始碼很多,我們挑重點的來看:
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
//performClick()裡面會走onClick
if (!post(mPerformClick)) {
performClick();
}
從這個程式碼中mPerformClick 確認是不為空的,然後呼叫post(mPerformClick),我們直到View裡面的post方法其實也就是相當於把runnable任務放進主執行緒的訊息佇列來處理,如果post進去失敗,則會直接處理performClick();我們看一下這個mPerformClick的實現:
private final class PerformClick implements Runnable {
@Override
public void run() {
performClick();
}
}
public boolean performClick() {
final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
//呼叫onClick
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
return result;
}
發現PerformClick中其實主要的是執行一個performClick方法,而我們在performClick方法中可以看出,首先程式碼判斷li != null && li.mOnClickListener != null,這裡我們也不難看出mOnClickListener 就是我們呼叫setOnClickListener的時候設定進去的物件,當設定了點選監聽事件後此處條件便成立,然後會呼叫一個播放點選音效,然後呼叫li.mOnClickListener.onClick(this);這正是我們設定點選監聽事件的時候,回撥的onClick方法,由此可以驗證我們的第二條說法:
onClick方法是在onTouchEvent裡面被回撥的,如果onTouch返回了true,onTouchEvent不會被呼叫,那麼onClick也就不會被呼叫。
另外一個我們還可能會用到一個長按的監聽,我們也給view增加一個長按事件並在onLongClick打印出來,,發現log如下:
從log上看,當View接收到ACTION_DOWN的時候,並且不鬆開大概0.5s的時候(log從onTouchEvent到onLongClick的執行時間差大概就是0.5s)會執行onLongClick,當接收到ACTION_UP的時候再執行onClick,而如果onLongClick方法中返回了true,則onClick就不會再執行。
我們再來看一下關於這個說法的原始碼依據,並且分析原始碼中是如何確定長按事件並且回撥onLongClick的。
當手指開始按下的時候,執行了onTouchEvent方法。我們從onTouchEvent關於對ACTION_DOWN的執行邏輯程式碼如下:
case MotionEvent.ACTION_DOWN:
mHasPerformedLongPress = false;
if (performButtonActionOnTouchDown(event)) {
break;
}
// Walk up the hierarchy to determine if we're inside a scrolling container.
//判斷是不是一個滾動檢視
boolean isInScrollingContainer = isInScrollingContainer();
// For views inside a scrolling container, delay the pressed feedback for
// a short period in case this is a scroll.
//如果是滾動檢視的話
if (isInScrollingContainer) {
mPrivateFlags |= PFLAG_PREPRESSED;
if (mPendingCheckForTap == null) {
mPendingCheckForTap = new CheckForTap();
}
mPendingCheckForTap.x = event.getX();
mPendingCheckForTap.y = event.getY();
//則新增一個延遲訊息,大概是getTapTimeout()的時間,實際上就是100ms,如果100ms裡面沒有滾動則判斷為是長按的過程
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
} else {
// Not inside a scrolling container, so show the feedback right away
//如果不是可滾動佈局的話,則直接就是代表長按了
setPressed(true, x, y);
//注意傳入的是0,代表一個延遲偏移值,如果值越大則等待形成長按的事件會更短
checkForLongClick(0);
}
break;
從程式碼上看首先執行performButtonActionOnTouchDown(event),為true則直接跳出,原始碼如下:
/**
* Performs button-related actions during a touch down event.
*
* @param event The event.
* @return True if the down was consumed.
*
* @hide
*/
protected boolean performButtonActionOnTouchDown(MotionEvent event) {
if ((event.getButtonState() & MotionEvent.BUTTON_SECONDARY) != 0) {
//如果是滑鼠右鍵的話會彈出選單之類的
if (showContextMenu(event.getX(), event.getY(), event.getMetaState())) {
return true;
}
}
return false;
}
MotionEvent的BUTTON_SECONDARY其實對應的就是滑鼠中的右鍵,事實上,在手機裝置中只要我們用手指觸控的都是返回false,而比如是接入了滑鼠,那麼滑鼠點選右鍵了就會有展開選單之類的功能,則會消費掉這個事件,所以在這裡我們條件不成立會接著走下面的程式碼。接著執行:
// Walk up the hierarchy to determine if we're inside a scrolling container.
boolean isInScrollingContainer = isInScrollingContainer();
isInScrollingContainer方法的原始碼如下:
/**
* @hide
*/
public boolean isInScrollingContainer() {
ViewParent p = getParent();
while (p != null && p instanceof ViewGroup) {
if (((ViewGroup) p).shouldDelayChildPressedState()) {
return true;
}
p = p.getParent();
}
return false;
}
說明該方法是用來遍歷View樹判斷當前按下的View是不是在一個滾動的檢視容器中,
如果是在一個可以滾動的容器中,比如(ListView,ScorllView)那麼先設定PFLAG_PREPRESSED標記位,表示使用者準備點選,
隨後發出一個延遲的訊息來確定使用者到底是要滾動還是點選.,通過記錄使用者按下的座標和ViewConfiguration.getTapTimeout()指定的時間(原始碼被設定為100ms),來延遲100ms後判斷出使用者是滾動了還是點選的
而這個訊息的執行任務是mPendingCheckForTap,點選檢視:
private final class CheckForTap implements Runnable {
public float x;
public float y;
@Override
public void run() {
mPrivateFlags &= ~PFLAG_PREPRESSED;
setPressed(true, x, y);
//注意這裡區別與非滾動容器,因為滾動容器要花getTapTimeout()這個事件去判斷是不是滾動,所以形成長按的話要帶上這個偏移量來減去這個時間 checkForLongClick(ViewConfiguration.getTapTimeout());
}
}
不難看出其實在延遲訊息後再呼叫checkForLongClick,並且引數是ViewConfiguration.getTapTimeout()。
,而如果不是在一個滾動的容器中,則執行:setPressed(true, x, y);標記PFLAG_PRESSED為true表示標記一個按下的狀態,再呼叫
checkForLongClick,不同的是引數是0.
實際上這兩種情況,都是確定了是保持按下狀態,不是滾動螢幕。然後再呼叫checkForLongClick,只不過給出的引數不同罷了。
到這裡也不難看出,checkForLongClick會在檢查確定是滿足長按條件後執行長按監聽的回撥。我們看checkForLongClick的原始碼:
private void checkForLongClick(int delayOffset) {
if ((mViewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) {
mHasPerformedLongPress = false;
if (mPendingCheckForLongPress == null) {
mPendingCheckForLongPress = new CheckForLongPress();
}
mPendingCheckForLongPress.rememberWindowAttachCount();
//形成長按的過程,如果這個訊息到執行的時候依舊滿足條件,則代表長按事件成立,注意延遲時間會減去一個delayOffset偏移量
postDelayed(mPendingCheckForLongPress,
ViewConfiguration.getLongPressTimeout() - delayOffset);
}
}
從程式碼上看,是把一個mPendingCheckForLongPress 的任務延遲執行,而執行的時間正是ViewConfiguration.getLongPressTimeout()-delayOffset,這裡也就直到了如果不是滾動檢視則會馬上進入這個方法,傳了引數0,所以長按延遲是整個ViewConfiguration.getLongPressTimeout()的時間,如果是在滾動容器的話則因為消耗了100ms的時間去判斷是否是滾動,所以在這裡就會減掉那個時間。
我們重點看下mPendingCheckForLongPress是個什麼任務,檢視下他的原始碼:
private final class CheckForLongPress implements Runnable {
private int mOriginalWindowAttachCount;
@Override
public void run() {
if (isPressed() && (mParent != null)
&& mOriginalWindowAttachCount == mWindowAttachCount) {
//performLongClick裡面呼叫onLongClick,並且返回true則記錄mHasPerformedLongPress,而不會再讓onClick被呼叫
if (performLongClick()) {
mHasPerformedLongPress = true;
}
}
}
public void rememberWindowAttachCount() {
mOriginalWindowAttachCount = mWindowAttachCount;
}
}
因為手指按下後不鬆開到形成長按的這段等待過程之中,介面是可能發生一些變化的,比如Activity被暫停了或者被重啟了,或者這個時候,長按的事件就不應該被響應了
在View中有一個mWindowAttachCount記錄了View的attach次數.他的作用是:當檢查長按時的attach次數與長按到形成時的attach一樣則處理,否則就不應該形成長按事件. 所以在將檢查長按的訊息新增時隊伍的時候,要記錄下當前的windowAttachCount.
而當滿足上面說的條件後,則performLongClick()會被呼叫,它的原始碼如下:
/**
* Call this view's OnLongClickListener, if it is defined. Invokes the context menu if the
* OnLongClickListener did not consume the event.
*
* @return True if one of the above receivers consumed the event, false otherwise.
*/
public boolean performLongClick() {
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
boolean handled = false;
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnLongClickListener != null) {
handled = li.mOnLongClickListener.onLongClick(View.this);
}
if (!handled) {
handled = showContextMenu();
}
if (handled) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
return handled;
}
可以看出當我們設定了長按監聽事件了之後會回撥li.mOnLongClickListener.onLongClick(View.this);,
而如果方法返回了了true,則mHasPerformedLongPress = true;
我們在回過頭來看onTouchEvent的ACTION_UP的處理邏輯部分程式碼:
if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
// This is a tap, so remove the longpress check
removeLongPressCallback();
// Only perform take click actions if we were in the pressed state
if (!focusTaken) {
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
if (!post(mPerformClick)) {
performClick();
}
}
}
可以看到如果mHasPerformedLongPress 為true了,則不會再走performClick()方法回撥onClick了。這裡驗證了我們在上文的說法:如果onLongClick方法中返回了true,則onClick就不會再執行。
到了這裡,我們就已經明白了當一個事件序列被View接收後,onTouch、onClick、onLongClick被回撥的原理過程。
我將在下一章節中,我們繼續談論Android事件處理機制。主要是剖析出從頂級ViewGroup到最低階的View的事件分發處理的原理過程。要理解這個事件處理機制的過程還是有一些難度的,畢竟原始碼的流程也比較多。不過全部貼出程式碼來一句句深究其含義並不是我們從原始碼上理解Android系統機制的辦法,原始碼太多,我們挑出重要的程式碼部分就已經足夠我們來理解這個過程了。