【透鏡系列】看穿 > 觸控事件分發 >

RubiTree發表於2019-01-14

【透鏡系列】看穿 > 觸控事件分發 >

(轉載請註明作者:RubiTree,地址:blog.rubitree.com

引子

事件分發,我想大部分人都能說幾句,哦,三大方法,哦,那段經典虛擬碼,哦,責任鏈... 但如果要讓你完完整整捋一遍,你可能就開始支支吾吾了,只能想到啥說啥

這塊的東西確實麻煩,說出來不怕嚇到你,事件流到底怎麼流與這些因素都有關係:是什麼事件型別(DOWN/MOVE/UP/CANCEL)、在哪個檢視層次(Activity/ViewGroup/View)、在哪個回撥方法(dispatch()/onIntercept()/onTouch())、回撥方法給不同的返回值(true/false/super.xxx),甚至對當前事件的不同處理還會對同一事件流中接下來的事件造成不同影響

比如我可以問:重寫某個ViewGroup裡的dispatchTouchEvent方法,對MOVE事件返回false,整個事件分發過程會是什麼樣的?

於是就有人對這些情況分門別類進行總結,得到了很多規律,也畫出了紛繁複雜的事件分發流程圖:

【透鏡系列】看穿 > 觸控事件分發 >
【透鏡系列】看穿 > 觸控事件分發 >

甚至還有類似題圖那樣的動態流程圖 (是的,吸引你進來的題圖居然是反面教材,我也很心疼啊,畫了我半個下午,結果並沒有太大的幫助)

這些規律和流程圖確實是對的,而且某種意義上也是非常清晰的,能幫助你在除錯 Bug 的時候找到一點方向。 你或許可以奮發圖強,把這些流程圖和規律背下來,也能在需要的時候一通嘰裡呱啦背完大家大眼瞪小眼。 但它們並不能讓你真正理解事件分發是什麼樣子,你可能某一次花費了大量的時間去看懂它們,但是「每次都能看明白!過一段時間又忘了!」 (某段有代表性的評論原話)

但講道理,分發個觸控事件為什麼會這麼複雜呢?需要這麼複雜嗎?圖啥呢?

於是,讓我們回到起點,看看分發觸控事件到底是為了解決一個什麼樣的問題,有沒有更簡單的分發辦法?然後看看當需求增加的時候,要怎麼調整這個簡單的分發策略? 看到最後你就會發現,原來一切是那麼地自然。

所以,不用死記硬背,也不用急著去懟完整的事件分發流程,那麼多複雜的邏輯和情況其實都是圍繞著最根本的問題發展出來的,是隨著需求的增加一步步變得複雜的,理解了演化過程,你自然會對其演化的結果瞭然於胸,想忘都忘不掉。

從根本問題出發,一切就會變得自然而然。

艾維巴蒂,黑喂狗! 下面我將從最簡單的需求開始思考方案、編寫程式碼,然後一步步增加需求、調整方案、繼續編寫程式碼,爭取造出一個麻雀雖小五臟俱全的事件分發框架。

1. 五造輪子

1.1. 一造:直接傳給目標View

我們先實現一個最簡單的需求:Activity 中有一堆層層巢狀的 View,有且只有最裡邊那個 View 會消費事件

(黃色高亮 View 代表可以消費事件,藍色 View 代表不消費事件)

-w350

思考方案:

  1. 首先事件從哪兒來,肯定得從父親那來,因為子View被包裹在裡面,沒有直接與外界通訊的辦法,而實際中Activity連線著根ViewDecorView,它是通往外界的橋樑,能接收到螢幕硬體傳送過來的觸控事件
  2. 所以事件是從Activity開始,經過一層一層 ViewGroup ,傳到最裡邊的 View
  3. 這時只需要一個從外向裡傳遞事件的passEvent(ev)方法,父親一層層往裡調,能把事件傳遞過去,就完成了需求

示意圖

-w400

麻雀程式碼:

(本文程式碼使用Kotlin編寫,核心程式碼也提供了Java版本

open class MView {
    open fun passEvent(ev: MotionEvent) {
        // do sth
    }
}
    
class MViewGroup(private val child: MView) : MView() {
    override fun passEvent(ev: MotionEvent) {
        child.passEvent(ev)
    }
}
複製程式碼
  1. 暫時把Activity當成MViewGroup處理也沒有問題
  2. 為什麼是MViewGroup繼承MView而不是反過來,因為 MView 是不需要 child 欄位的

1.2. 二造:從裡向外傳給目標View

然後我們增加一條需求,讓情況複雜一點:Activity中有一堆層層巢狀的View,有好幾個疊著的View能處理事件

-w350

同時需要增加一條設計原則:使用者的一次操作,只能被一個View真正處理(消費)

  1. 要求這條原則是為了讓操作的反饋符合使用者直覺
  2. 很容易理解,正常情況下人只會想一次就做一件事
    1. 比如一個列表條目,列表可以點選進入詳情,列表上還有個編輯按鈕,點選可以編輯條目
      1. 這是一個上下兩個View都能點選的場景,但使用者點一個地方,肯定只想去做一件事,要麼進入詳情,要麼是編輯條目,如果你點編輯結果跳了兩個頁面,那肯定是不合適的
    2. 再比如在一個可點選Item組成的列表裡(比如微信的訊息介面),Item可以點選進入某個聊天,列表還能滑動上下檢視
      1. 如果你讓Item和列表都處理事件,那在你滑動的時候,你可能得跳一堆你不想去的聊天頁面

如果使用第一次試造的框架,要遵守這條原則,就需要在每一個可以處理事件的View層級,判斷出自己要處理事件後,不繼續呼叫childpassEvent()方法了,保證只有自己處理了事件。 但如果真這樣實現了,在大部分場景下會顯得怪怪的,因為處理事件的順序不對:

  1. 比如還是上面的列表,當使用者點選按鈕想編輯條目的時候,點選事件先傳到條目,如果你在條目中判斷需要事件,然後把事件消費了不傳給子View,使用者就永遠點不開編輯條目了
  2. 而且換個角度看更加明顯,使用者肯定希望點哪,哪兒最靠上、離手指最近的東西被觸發

所以實現新增需求的一個關鍵是:找到那個適合處理事件的View,而我們通過對業務場景進行分析,得到答案是:那個最裡面的View適合處理事件

這就不能是等parent不處理事件了才把事件傳給child,應該反過來,你需要事件的處理順序是從裡向外:裡邊的child不要事件了,才呼叫parentpassEvent()方法把事件傳出來。 於是得加一條向外的通道,只能在這條向外的通道上處理事件,前面向裡的通道什麼都不幹,只管把事件往裡傳。 所以這時你有了兩條通道,改個名字吧,向裡傳遞事件是passIn()方法,向外傳遞並處理事件是passOut()方法。

示意圖

-w400

麻雀程式碼:

open class MView {
    var parent: MView? = null

    open fun passIn(ev: MotionEvent) {
        passOut(ev)
    }

    open fun passOut(ev: MotionEvent) {
        parent?.passOut(ev)
    }
}

class MViewGroup(private val child: MView) : MView() {
    init {
        child.parent = this // 示意寫法
    }

    override fun passIn(ev: MotionEvent) {
        child.passIn(ev)
    }
}
複製程式碼

這段程式碼沒有問題,非常簡單,但是它對需求意圖的表達不夠清晰,增加了框架的使用難度

  1. 如前所述,我們希望passIn()的時候只傳遞事件,希望在passOut()的時候每個View決定是否要處理事件,並進行處理,而且在處理事件後,不再呼叫parentpassOut()方法把事件傳出來
  2. 你會發現,這其中包含了兩類職責:
    1. 一類是事件傳遞控制邏輯,另一類是事件處理鉤子
    2. 其中事件傳遞控制邏輯基本不會變化,但事件處理的鉤子中可能做任何事情
  3. 我們需要把不同職責的程式碼分開,更需要把變化的和不變的分開,減少框架使用者的關注點

於是我們用一個叫做dispatch()的方法單獨放事件傳遞的控制邏輯,用一個叫做onTouch()的方法作為事件處理的鉤子,而且鉤子有一個返回值,表示鉤子中是否處理了事件:

open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = child.dispatch(ev)
        if (!handled) handled = onTouch(ev)

        return handled
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
複製程式碼

這樣寫完,整個行為其實沒有變化,但你會發現:

  1. 控制邏輯集中在dispatch()中,一目瞭然
  2. onTouch()單純是一個鉤子,框架使用者只需要關心這個鉤子和它的返回值,不用太關心控制流程
  3. 另外,連parent也不需要了

1.3. 三造:區分事件型別

上文的實現看上去已經初具雛形了,但其實連開始提的那條原則都沒實現完,因為原則要求一次操作只能有一個 View 進行處理,而我們實現的是一個觸控事件只能有一個View進行處理。 這裡就涉及到一次觸控操作和一個觸控事件的區別:

  1. 假設還沒有觸控事件的概念,我們要怎麼區分一次觸控操作呢?
    1. 把觸控操作細分一下,大概有按下動作、抬起動作、與螢幕接觸時的移動和停留動作
    2. 很容易想到,要區分兩次觸控操作,可以通過按下和抬起動作進行區分,按下動作開始了一次觸控操作,抬起動作結束了一次觸控,按下和抬起中間的移動和停留都屬於這一次觸控操作,至於移動和停留是否要區分,目前沒有看到區分的必要,可以都作為觸控中來處理
  2. 於是在一次觸控操作中就有了三種動作的型別:DOWN/UP/ING,其中ING有點不夠專業,改個名字叫MOVE
  3. 而每個觸控動作會在軟體系統中產生一個同樣型別的觸控事件
  4. 所以最後,一次觸控操作就是由一組從DOWN事件開始、中間是多個MOVE事件、最後結束於UP事件的事件流組成

於是設計原則更確切地說就是:一次觸控產生的事件流,只能被一個View消費

在上次試造的基礎上把一個事件變成一個組事件流,其實非常簡單:處理DOWN事件時跟前面處理一個事件時一樣,但需要同時記住DOWN事件的消費物件,後續的MOVE/UP事件直接交給它就行了

麻雀程式碼:

open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    private var isChildNeedEvent = false

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false
        
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
        
            handled = child.dispatch(ev)
            if (handled) isChildNeedEvent = true

            if (!handled) handled = onTouch(ev)
        } else {
            if (isChildNeedEvent) handled = child.dispatch(ev)
            if (!handled) handled = onTouch(ev)
        }
        
        if (ev.actionMasked == MotionEvent.ACTION_UP) {
            clearStatus()
        }
            
        return handled
    }
    
    private fun clearStatus() {
        isChildNeedEvent = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
複製程式碼

程式碼好像增加了很多,其實只多做了兩件事:

  1. 增加了一個isChildNeedEvent狀態,對是子View是否處理了DOWN事件進行記錄,並在其他觸控事件時使用這個狀態
  2. 在收到DOWN事件的最開始和收到UP事件的最後,重置狀態

此時框架使用者還是隻需要關心onTouch()鉤子,在需要處理事件時進行處理並返回true,其他事情框架都做好了。

1.4. 四造:增加外部事件攔截

上面的框架已經能完成基本的事件分發工作了,但下面這個需求,你嘗試一下用現在框架能實現嗎? 需求:在可滑動View中有一個可點選View,需要讓使用者即使按下的位置是可點選View,再進行滑動時,也可以滑動外面的的可滑動View

-w380
這個需求其實非常常見,比如所有「條目可點選的滑動列表」就是這樣的(微信/QQ聊天列表)。

假如使用上面的框架:

  1. 可滑動View會先把事件傳到裡邊的可點選View
  2. 可點選View一看來事件了,我又能點選,那捨我其誰啊
  3. 然後外面的可滑動View就永遠無法處理事件,也就無法滑動

所以直接使用現在的模型去實現的「條目可點選的滑動列表」會永遠滑動不了。

那怎麼辦呢?

  1. 難道要讓裡面的可點選View去感知一下(層層往上找),自己是不是被一個能消費事件的View包裹?是的話自己就不消費事件了?
    1. 這肯定是不行的,先不說子View層層反向遍歷父親是不是個好實現,至少不能外面是可以滑動的,裡邊View的點選事件就全部失效
  2. 或者我們調整dispatch()方法在傳入事件過程中的人設,讓它不是隻能往裡傳遞事件,而是在自己能消費事件的時候把事件給自己
    1. 這肯定也是不行的,跟第一個辦法的主要問題一樣

直接想實現覺得到處是矛盾,找不到突破口,那就從頭開始吧,從什麼樣的觸控反饋是使用者覺得自然的出發,看看這種符合直覺的反饋方案是否存在,找出來它是什麼,再考慮我們要怎麼實現:

  1. 當使用者面對一個滑動View裡有一個可點選View,當他摸在可點選View上時,他是要做什麼?
  2. 顯然,只有兩個可能性,要麼使用者想點這個可點選View,要麼使用者想滑動這個可滑動View
  3. 那麼,當使用者剛用手指接觸的時候,也就是DOWN事件剛來的時候,能判斷使用者想幹什麼嗎?很抱歉,不能
  4. 所以,客觀條件下,你就是不可能在DOWN事件傳過來的時候,判斷出使用者到底想做什麼,於是兩個View其實都不能確定自己是否要消費事件

我*,這不傻*了嗎,還搞什麼GUI啊,大家都用命令列吧 等等,不要著急,GUI還是得搞的,不搞沒飯吃的我跟你講,所以你還是得想想,想盡辦法去實現。

你先忘記前面說的原則,你想想,不考慮其他因素,也不是隻能用DOWN事件,只要你能判斷使用者的想法就行,你有什麼辦法

  1. 辦法肯定是有的,你可以多等一會,看使用者接下來的行為能匹配哪種操作模式
    1. 點選操作的模式是這樣:使用者先DOWN,然後MOVE很小一段,也不會MOVE出這個子View,關鍵是比較短的時間就UP
    2. 滑動操作的模式是這樣:使用者先DOWN,然後開始MOVE,這時候可能會MOVE出這個子View,也可能不,但關鍵是比較長的時間也沒有在UP,一直是在MOVE
  2. 所以你的結論是,只有DOWN不行,還得看接下來的事件流,得走著瞧
  3. 再多考慮個長按的情況,總結就是:
    1. 如果在某個時間內UP,就是點選裡邊的View
    2. 如果比較長的時間UP,但沒怎麼MOVE,就是長按裡邊的View
    3. 如果在比較短的時間MOVE比較長的距離,就是滑動外面的View

看上去這個目標 View 判定方案很不錯,安排得明明白白,但我們現有的事件處理框架實現不了這樣的判定方案,至少存在以下兩個衝突:

  1. 因為子View和父View都無法在DOWN的時候判斷當前事件流是不是該給自己,所以一開始它們都只能返回false。但為了能對後續事件做判斷,你希望事件繼續流過它們,按照當前框架的邏輯,你又不能返回false
  2. 假設事件會流過它們,當事件流了一會兒後,父 View 判斷出這符合自己的消費模式啊,於是想把事件給自己消費,但此時子 View 可能已經在消費事件了,而目前的框架是做不到阻止子 View 繼續消費事件的

所以要解決上述的衝突,就肯定要對上一版的事件處理框架進行修改,而且看上去一不小心就會大改

  1. 首先看第二個衝突,解決它的一個直接方案是:調整 dispatch() 方法在傳入事件過程中的人設,讓它不是隻傳遞事件了,還可以在往裡傳遞事件前進行攔截,能夠看情況攔截下事件並交給自己的 onTouch() 處理
  2. 基於這個解決方案,大概有以下兩個改動相對小的方案調整思路:
    1. 思路一:
      1. 當事件走到可滑動父View的時候,它先攔截並處理事件,而且還把事件給攢著
      2. 當經過了幾個事件
        1. 如果判斷出符合自己的消費模式,那就直接開始自己消費了,也不用繼續攢事件了
        2. 如果判斷出不是自己的消費模式,再把所有攢著的事件一股腦給子 View,觸發裡邊的點選操作
    2. 思路二:
      1. 所有的 View 只要可能消費事件,就在onTouch()裡對DOWN事件返回true,不管是否識別出當前屬於自己的消費模式
      2. 當事件走到到可滑動父 View 的時候,它先把事件往裡傳,裡邊可能會處理事件,可能不會,可滑動父 View 都暫時不關心
      3. 然後看子 View 是否處理事件
        1. 假如子 View 不處理事件,那啥問題沒有,父 View 直接處理事件就好了
        2. 假如子 View 處理事件,可滑動父View就會繃緊神經暗中觀察伺機而動,觀察事件是不是符合自己的消費模式,一旦發現符合,它就把事件流攔截下來,即使子View也在處理事件,它也不往裡disptach事件了,而是直接給自己的onTouch()
  3. 兩個思路總結一下:
    1. 思路一:外面的父 View 先攔事件,如果判斷攔錯了,再把事件往裡發
    2. 思路二:外面的父 View 先不攔事件,在判斷應該攔的時候,突然把事件攔下來
  4. 這兩個思路都要對當前框架做改變,看似差不多,但其實還是有比較明顯的優劣的
    1. 思路一問題比較明顯:
      1. 父 View 把事件攔下來了,然後發現攔錯了再給子 View,但其實子 View 又並不一定能消費事件,這不就是白做一步嗎。等到子View不處理事件,又把事件們還給父View,父View還得繼續處理事件。整個過程不僅繁瑣,而且會讓開發者感覺到彆扭
      2. 所以這個思路不太行,還得是把事件先給子View
    2. 思路二就相對正常多了,只有一個問題(下一節再講,你可以猜一猜,這裡我先當沒發現),而且框架要做的改變也很少:
      1. 增加一個攔截方法onIntercept()在父 View 往裡dispatch事件前,開發者可以覆寫這個方法,加入自己的事件模式分析程式碼,並且可以在確定要攔截的時候進行攔截
        1. 把分析攔截邏輯抽成一個方法非常合理:什麼時候攔,什麼時候不攔,內裡的邏輯很多,但對外暴露的 API 可以很小,非常適合抽出去
      2. 在確定自己要攔截事件的時候,即使裡邊在一開始消費了事件,也不把事件往裡傳了,而是直接給自己的onTouch()

示意圖:

-w400

於是使用思路二,在「三造」的基礎上,修改得到以下程式碼:

open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false

        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
            
            if (onIntercept(ev)) {
                isSelfNeedEvent = true
                handled = onTouch(ev)
            } else {
                handled = child.dispatch(ev)
                if (handled) isChildNeedEvent = true

                if (!handled) {
                    handled = onTouch(ev)
                    if (handled) isSelfNeedEvent = true
                }
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                if (onIntercept(ev)) {
                    isSelfNeedEvent = true
                    handled = onTouch(ev)
                } else {
                    handled = child.dispatch(ev)
                }
            }
        }

        if (ev.actionMasked == MotionEvent.ACTION_UP) {
            clearStatus()
        }
        
        return handled
    }

    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }

    open fun onIntercept(ev: MotionEvent): Boolean {
        return false
    }
}
複製程式碼

寫的過程中增加了一些對細節的處理:

  1. 不僅是在DOWN事件的dispatch()前需要攔截,在後續事件中,也需要加入攔截,否則無法實現中途攔截的目標
  2. 在某一個事件判斷攔截之後,還需要在後續事件中再判斷一次是否要攔截嗎?
    1. 完全不需要,我們希望的就是在一次觸控中,儘可能只有1個物件去消費事件,決定是你了,那就不要變
    2. 所以增加一個isSelfNeedEvent記錄自己是否攔截過事件,如果攔截過,後續事件直接就交給自己處理
  3. 在後續事件時,子 View 沒有處理事件,外面也不會再處理了,同樣因為只能有一個 View 處理(Actvity會處理這樣的事件,後面會提到)

這一下程式碼是不是看上去瞬間複雜了,但其實只是增加了一個事件攔截機制,對比上一次試造的輪子,會更容易理解。(要是 Markdown 支援程式碼塊內自定義著色就好了)

而且對於框架的使用者來說,關注點還是非常少

  1. 重寫onIntercept()方法,判斷什麼時候需要攔截事件,需要攔截時返回true
  2. 重寫onTouch()方法,如果處理了事件,返回true

1.5. 五造:增加內部事件攔截

上面的處理思路雖然實現了需求,但可能會導致一個問題:裡邊的子 View 接收了一半的事件,可能都已經開始處理並做了一些事情,父 View 忽然就不把後續事件給它了,會不會違背使用者操作的直覺?甚至出現更奇怪的現象?

這個問題確實比較麻煩,分兩類情況討論

  1. 裡邊的 View 接收了一半事件,但還沒有真正開始反饋互動,或者在進行可以被取消的反饋
    1. 比如對於一個可點選的View,View的預設實現是隻要被touch了,就會有pressed狀態,如果你設定了對應的background,你的 View 就會有高亮效果
    2. 這種高亮即使被中斷也沒事,不會讓使用者感覺到奇怪,不信你自己試試微信的聊天列表
    3. 但一個值得注意的點是,如果你只是直接不傳送MOVE事件了,這會有問題,就這個按下高亮的例子,如果你只是不傳MOVE事件了,那誰來告訴裡邊的子View取消高亮呢?所以你需要在中斷的時候也傳一個結束事件
      1. 但是,你能直接傳一個UP事件嗎?也是不行的,因為這樣就匹配了裡邊點選的模式了,會直接觸發一個點選事件,這顯然不是我們想要的
      2. 於是外面需要給一個新的事件,這個事件的型別就叫取消事件好了CANCEL
    4. 總結一下,對於這種簡單的可被取消情況,你可以這樣去處理:
      1. 在確定要攔截的時候,在把真正的事件轉發給自己的onTouch()的同時,另外生成一個新的事件發給自己的子View,事件型別是CANCEL,它將是子View收到的最後一個事件
      2. 子View可以在收到這個事件後,對當前的一些行為進行取消
  2. 裡邊的View接收了一半事件,已經開始反饋互動了,這種反饋最好不要去取消它,或者說取消了會顯得很怪
    1. 這個時候,事情會複雜一些,而且這個場景發生的遠比你想象中的多,形式也多種多樣,不處理好的後果也比只是讓使用者感覺上奇怪要嚴重得多,可能會有的功能會實現不了,下面舉兩個例子
      1. ViewPager裡有三個page,page裡是ScrollViewViewPager可以橫向滑動,page裡的ScrollView可以豎向滑動
        1. 如果按前面邏輯,當ViewPager把事件給裡邊ScrollView之後,它也會偷偷觀察,如果你一直是豎向滑動,那沒話說,ViewPager不會觸發攔截事件
        2. 但如果你豎著滑著滑著,手抖了,開始橫滑(或者只是斜滑),ViewPager就會開始緊張,想「組織終於決定是我了嗎?真的假的,那我可就不客氣了」,於是在你斜滑一定距離之後,忽然發現,你劃不動ScrollView了,而ViewPager開始動
        3. 原因就是ScrollView的豎滑被取消了,ViewPager把事件攔下來,開始橫滑
        4. 這個體驗還是比較怪的,會有種過於靈敏的感覺,會讓使用者只能小心翼翼地滑動
      2. 在一個ScrollView裡有一些按鈕,按鈕有長按事件,長按再拖動就可以移動按鈕
        1. (更常見的例子是一個列表,裡邊的條目可以長按拖動)
        2. 同樣按前面的邏輯,當你長按後準備拖動按鈕時,你怎麼保證不讓ScrollView把事件攔下來呢?
    2. 所以這類問題是一定要解決的,但要怎麼解決呢
      1. 還是先從業務上看,從使用者的角度看,當裡邊已經開始做一些特殊處理了,外面應不應該把事件搶走?
        1. 不應該對吧,OK,解決方針就是不應該讓外邊的View搶事件
      2. 所以接下來的問題是:誰先判斷出外邊的View不該搶事件,裡邊的子View還是外邊的父View?然後怎麼不讓外邊的View搶?
        1. 首先,肯定是裡邊的View做出判斷:這個事件,真的,外邊的View你最好別搶,要不使用者不開心了
        2. 然後裡邊就得告知外邊,你別搶了,告知可以有幾個方式
          1. 外邊搶之前問一下里邊,我能不能搶
          2. 裡邊在確定這個事件不能被搶之後,從dispatch方法返回一個特別的值給外邊(之前只是truefalse,現在要加一個)
          3. 裡邊通過別的方式通知外邊,你不要搶
        3. 講道理,我覺得三個方式都行,但第三個方式最為簡單直接,而且對框架沒有過大的改動,Android也使用了這個方式,父View給子View提供了一個方法requestDisallowInterceptTouchEvent(),子View呼叫它改變父View的一個狀態,同時父View每次在準備攔截前都會判斷這個狀態(當然這個狀態只對當前事件流有效)
        4. 然後,這個情況還得再注意一點,它應該是向上遞迴的,也就是,在複雜的情況中,有可能有多個上級在暗中觀察,當裡邊的View決定要處理事件而且不準備交出去的時候,外面所有的暗中觀察的父View都應該把腦袋轉回去

所以,連同上一次試造,總結一下

  1. 對於多個可消費事件的View進行巢狀的情況,怎麼判定事件的歸屬會變得非常麻煩,無法立刻在DOWN事件時就確定,只能在後續的事件流中進一步判斷
  2. 於是在沒判斷歸屬的時候,先由裡邊的子View消費事件,外面暗中觀察,同時兩方一塊對事件型別做進一步匹配,並準備在匹配成功後對事件流的歸屬進行搶拍
  3. 搶拍是先搶先得
    1. 父親先搶到,發個CANCEL事件給兒子就完了
    2. 兒子先搶到,就得大喊大叫,撒潑耍賴,爸爸們行行好吧,最後得以安心處理事件

另外有幾個值得一提的地方:

  1. 這種先搶先得的方式感覺上有點亂來是吧,但目前也沒有想到更好的辦法了,一般都是開發者自己根據實際使用者體驗調整,讓父親或兒子在最適合的時機準確及時地搶到應得的事件
  2. 父View在攔截下事件後,把接下來的事件傳給自己的onTouch()後,onTouch()只會收到後半部分的事件,這樣會不會有問題呢?
    1. 確實直接給後半部分會有問題,所以一般情況是,在沒攔截的時候就做好如果要處理事件的一些準備工作,以便之後攔截事件了,只使用後半部分事件也能實現符合使用者直覺的反饋

在「四造」的基礎上,修改得到以下程式碼:

interface ViewParent {
    fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean)
}

open class MView {
    var parent: ViewParent? = null

    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

open class MViewGroup(private val child: MView) : MView(), ViewParent {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false
    private var isDisallowIntercept = false

    init {
        child.parent = this
    }

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false
        
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
            
            // add isDisallowIntercept
            if (!isDisallowIntercept && onIntercept(ev)) {
                isSelfNeedEvent = true
                handled = onTouch(ev)
            } else {
                handled = child.dispatch(ev)
                if (handled) isChildNeedEvent = true

                if (!handled) {
                    handled = onTouch(ev)
                    if (handled) isSelfNeedEvent = true
                }
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                // add isDisallowIntercept
                if (!isDisallowIntercept && onIntercept(ev)) {
                    isSelfNeedEvent = true

                    // add cancel
                    val cancel = MotionEvent.obtain(ev)
                    cancel.action = MotionEvent.ACTION_CANCEL
                    handled = child.dispatch(cancel)
                    cancel.recycle()
                } else {
                    handled = child.dispatch(ev)
                }
            }
        }
        
        if (ev.actionMasked == MotionEvent.ACTION_UP 
            || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
            clearStatus()
        }
        
        return handled
    }
    
    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
        isDisallowIntercept = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }

    open fun onIntercept(ev: MotionEvent): Boolean {
        return false
    }

    override fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean) {
        this.isDisallowIntercept = isDisallowIntercept
        parent?.requestDisallowInterceptTouchEvent(isDisallowIntercept)
    }
}
複製程式碼

這次改動主要是增加了發出CANCEL事件和requestDisallowInterceptTouchEvent機制

  1. 在發出CANCEL事件時有一個細節:沒有在給 child 分發CANCEL事件的同時繼續把原事件分發給自己的onTouch 2. 這是原始碼中的寫法,不是我故意的,可能是為了讓一個事件也只能有一個View處理,避免出現bug
  2. 實現requestDisallowInterceptTouchEvent機制時,增加了ViewParent介面
    1. 不使用這種寫法也行,但使用它從程式碼整潔的角度看會更優雅,比如避免反向依賴,而且這也是原始碼的寫法,於是直接搬來了

雖然目前整個框架的程式碼有點複雜,但對於使用者來說,依然非常簡單,只是在上一版框架的基礎上增加了:

  1. 如果View判斷自己要消費事件,而且執行的是不希望被父View打斷的操作時,需要立刻呼叫父View的requestDisallowInterceptTouchEvent()方法
  2. 如果在onTouch方法中對事件消費並且做了一些操作,需要注意在收到CANCEL事件時,對操作進行取消

到這裡,事件分發的主要邏輯已經講清楚了,不過還差一段 Activity 中的處理,其實它做的事情類似ViewGroup,只有這幾個區別:

  1. 不會對事件進行攔截
  2. 只要有子View沒有處理的事件,它都會交給自己的onTouch()

所以不多講了,直接補上Activity的麻雀:

open class MActivity(private val childGroup: MViewGroup) {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false

    open fun dispatch(ev: MotionEvent): Boolean {
        var handled = false

        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()

            handled = childGroup.dispatch(ev)
            if (handled) isChildNeedEvent = true

            if (!handled) {
                handled = onTouch(ev)
                if (handled) isSelfNeedEvent = true
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                handled = childGroup.dispatch(ev)
            }

            if (!handled) handled = onTouch(ev)
        }

        if (ev.actionMasked == MotionEvent.ACTION_UP
            || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
            clearStatus()
        }

        return handled
    }

    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
複製程式碼

1.6. 總結

到這裡,我們終於造好了一個粗糙但不劣質的輪子,原始碼的主要邏輯與它的區別不大,具體區別大概有:TouchTarget機制、多點觸控機制、NestedScrolling 機制、處理各種 listener、結合View的狀態進行處理等,相比主要邏輯,它們就沒有那麼重要了,大家可以自行閱讀原始碼,之後有空也會寫關於多點觸控和TouchTarget的內容 (挖坑預警)

輪子的完整程式碼可以在在這裡檢視(Java版本) 這個輪子把原始碼中與事件分發相關的內容剝離了出來,能看到:

  1. 相比原始碼,這份程式碼足夠短足夠簡單,那些跟事件分發無關的東西通通不要來干擾我
    1. 長度總共不超過150行,剔除了所有跟事件分發無關的程式碼,並且把一些因為其他細節導致寫得比較複雜的邏輯,用更簡單直接的方式表達了
  2. 相比那段經典的事件分發虛擬碼(見附錄),這份程式碼又足夠詳細,詳細到能告訴你所有你需要知道的事件分發的具體細節
    1. 那段經典虛擬碼只能起到提綱挈領的作用,而這份麻雀程式碼雖然極其精簡但它五臟俱全,全到可以直接跑 —— 你可以用它進行為偽佈局,然後觸發觸控事件

但輪子不是最重要的,最重要的是整個演化的過程。

所以回頭看,你會發現事件分發其實很簡單,它的關鍵不在於「不同的事件型別、不同的View種類、不同的回撥方法、方法不同的返回值」對事件分發是怎麼影響的。 關鍵在於 「它要實現什麼功能?對實現效果有什麼要求?使用了什麼解決方案?」,從這個角度,就能清晰而且簡單地把事件分發整個流程梳理清楚。

事件分發要實現的功能是:對使用者的觸控操作進行反饋,使之符合使用者的直覺。

從使用者的直覺出發能得到這麼兩個要求:

  1. 使用者的一次操作只有一個View去消費
  2. 讓消費事件的View跟使用者的意圖一致

第二個要求是最難實現的,如果有多個View都可以消費觸控事件,怎麼判定哪個View更適合消費,並且把事件交給它。 我們使用了一套簡單但有效的先到先得策略,讓內外的可消費事件的View擁有近乎平等的競爭消費者的資格:它們都能接收到事件,並在自己判定應該消費事件的時候去發起競爭申請,申請成功後事件就全部由它消費。

(轉載請註明作者:RubiTree,地址:blog.rubitree.com

2. 測試輪子

可能有人會問,聽你紙上談兵了半天,你講的真的跟原始碼一樣嗎,這要是不對我不是虧大了。 問的好,所以接下來我會使用一個測試事件分發的日誌測試框架對這個小麻雀進行簡單的測試,還會有實踐部分真刀真槍地把上面講過的東西練起來。

2.1. 測試框架

測試的思路是通過在每個事件分發的鉤子中列印日誌來跟蹤事件分發的過程。 於是就需要在不同的 View 層級的不同鉤子中,針對不同的觸控事件進行不同的操作,以製造各種事件分發的場景。

為了減少重複程式碼簡單搭建了一個測試框架(所有程式碼都能在此處檢視),包括一個可以代理 View 中這些的操作的介面IDispatchDelegate及其實現類,和一個DispatchConfig統一進行不同的場景的配置。 之後建立了使用統一配置和代理操作的 真實控制元件們SystemViews 和 我們自己實現的麻雀控制元件們SparrowViews

DispatchConfig中配置好事件分發的策略後,直接啟動SystemViews中的DelegatedActivity,進行觸控,使用關鍵字TouchDojo過濾,就能得到事件分發的跟蹤日誌。 同時,執行SparrowActivityTest中的dispatch()測試方法,也能得到麻雀控制元件的事件分發跟蹤日誌。

2.2. 測試過程

場景一

先配置策略,模擬ViewViewGroup都不消費事件的場景:

fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {
    return DispatchDelegate(layer)
}
複製程式碼

能看到列印的事件分發跟蹤日誌:

[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept_BE |type:down
|layer:SViewGroup |on:Intercept_AF |result(super):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch_BE |type:down
|layer:SView |on:Touch_AF |result(super):false |type:down
|layer:SView |on:Dispatch_AF |result(super):false |type:down
|layer:SViewGroup |on:Touch_BE |type:down
|layer:SViewGroup |on:Touch_AF |result(super):false |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:down
|layer:SActivity |on:Touch_BE |type:down
|layer:SActivity |on:Touch_AF |result(super):false |type:down
|layer:SActivity |on:Dispatch_AF |result(super):false |type:down
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move

[move]
...
 
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SActivity |on:Touch_BE |type:up
|layer:SActivity |on:Touch_AF |result(super):false |type:up
|layer:SActivity |on:Dispatch_AF |result(super):false |type:up
複製程式碼
  1. 因為系統控制元件和麻雀控制元件列印的日誌一模一樣,所以只貼出一份
  2. 這裡用BE代表 before,表示該方法開始處理事件的時候,用AF代表after,表示該方法結束處理事件的時候,並且列印處理的結果
  3. 從日誌中能清楚看到,當ViewViewGroup都不消費DOWN事件時,後續事件將不再傳遞給ViewViewGroup

場景二

再配置策略,模擬ViewViewGroup都消費事件,同時ViewGroup在第二個MOVE事件時認為自己需要攔截事件的場景:

fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {
    return DispatchDelegate(
        layer,
        ALL_SUPER,
        // 表示 onInterceptTouchEvent 方法中,DOWN 事件返回 false,第一個 MOVE 事件返回 false,第二個第三個 MOVE 事件返回 true
        EventsReturnStrategy(T_FALSE, arrayOf(T_FALSE, T_TRUE, T_TRUE), T_SUPER), 
        ALL_TRUE
    )
}

fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {
    return DispatchDelegate(layer, ALL_SUPER, ALL_SUPER, ALL_TRUE)
}
複製程式碼

能看到列印的事件分發跟蹤日誌:

[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept |result(false):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch |result(true):true |type:down
|layer:SView |on:Dispatch_AF |result(super):true |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:down
|layer:SActivity |on:Dispatch_AF |result(super):true |type:down
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(false):false |type:move
|layer:SView |on:Dispatch_BE |type:move
|layer:SView |on:Touch |result(true):true |type:move
|layer:SView |on:Dispatch_AF |result(super):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(true):true |type:move
|layer:SView |on:Dispatch_BE |type:cancel
|layer:SView |on:Touch_BE |type:cancel
|layer:SView |on:Touch_AF |result(super):false |type:cancel
|layer:SView |on:Dispatch_AF |result(super):false |type:cancel
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Touch |result(true):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
 
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Touch |result(true):true |type:up
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:up
|layer:SActivity |on:Dispatch_AF |result(super):true |type:up
複製程式碼
  1. 同樣因為系統控制元件和麻雀控制元件列印的日誌一模一樣,所以只貼出一份
  2. 從日誌中能清楚看到,在ViewGroup攔截事件前後,事件是如何分發的

2.3. 測試結果

除了以上場景外,我也模擬了其他複雜的場景,能看到系統控制元件和麻雀控制元件列印的日誌一模一樣,這就說明了麻雀控制元件中的事件分發邏輯,確實與系統原始碼是一致的。

而且從列印的日誌中,能清晰地看到事件分發的軌跡,對理解事件分發過程也有很大的幫助。所以大家如果有需要,也可以直接使用這個框架像這樣對觸控事件分發的各種情況進行除錯。

3. 實踐

實際上進行事件分發的實踐時,會包括兩方面內容:

  1. 一方面是就是控制事件的分發。這也是本文講的主要內容
  2. 另一方面是對事件的處理。核心內容是手勢的識別,比如識別使用者的操作是單擊、雙擊、長按、滑動,這部分也可以自己手寫,不會太難,但一般場景中我們都可以使用SDK提供的十分好用的幫助類GestureDetector,它用起來非常方便

時間關係,這部分暫時直接去看另一篇透鏡《看穿 > NestedScrolling 機制》吧,它提供了過得去的實踐場景。

(覺得對你有幫助的話,不妨點個贊再走呀~ 給作者一點繼續寫下去的動力)

4. 附錄

4.1.事件分發經典虛擬碼

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean consume = false;
    if (onInterceptTouchEvent(event)) {
        consume = onTouchEvent(event);
    } else {
        consume = child.dispatchTouchEvent(event);
    }
    return consume;
}
複製程式碼

相關文章