1. 程式人生 > >淺談事件的分發與響應

淺談事件的分發與響應

在 iOS 開發中,當用戶用手指點選了一下螢幕,會發生什麼呢?系統是怎麼判斷使用者點選的位置呢?我們開發者又如何做出“沒有bug”的互動呢?帶著這些疑問,我們一起談談事件的分發與響應。

鋪墊

事件

顧名思義,事件就是發生的一件事,對於APP來說,就是發生的一個操作。具體的就是使用者點選一下螢幕就會出現一個事件(體現為一個UIEvent),即一個觸控事件。其實,對於 iOS 裝置的使用者來說,他們操作裝置的方式主要有四種方式:觸控式螢幕幕、晃動裝置、通過遙控設施控制裝置、按壓螢幕。 對應的事件型別UIEventType有以下三種:

  1. 觸屏事件(Touch Event)
  2. 運動事件(Motion Event)
  3. 遠端控制事件(Remote-Control Event)
  4. 按壓事件(Presses Event)

我們的主題是探索使用者用手指點選螢幕會發生什麼,所以我們將注意力放在觸控事件上。

響應者物件

上面我們瞭解到,當我們點選了螢幕,就會出現一個事件。既然事件出現了,那麼就需要一個一個響應和處理這個事件的物件,那就是我們的響應者物件。這些響應者物件都有一個共同的特徵,就是他們都繼承自UIResponder。我們熟知的響應者物件有UIApplicationUIWindowUIViewController和所有繼承自UIView的 UIKit 類

UIResponder

  • 所有響應物件的基類
  • 定義了處理上述各種事件的介面;

第一響應者

在觸控式螢幕幕的事件中:

  • 指的是當前接受觸控的響應者物件(通常是一個UIView物件);
  • 即表示當前該物件正在與使用者互動,它是響應者鏈的開端;
  • 整個響應者鏈和事件分發的使命都是找出第一響應者。

響應者鏈條

上面介紹了響應者物件,也知道了UIApplicationUIWindowUIViewControllerUIView這些都是響應者。那麼一個 APP 會存在很多響應者物件。由這一系列的響應者物件就構成了一個層次結構,那就是響應者鏈條

響應者鏈條

從上圖中可以看到,響應者鏈條有以下特點

  1. 響應者鏈頭部通常是由檢視(UIView
    )構成的;
  2. 如果該檢視是屬於檢視控制器(UIViewController)的,那麼下一個響應者是該檢視控制器,然後再將事件響應到它的父檢視(Super View)中;
  3. 如果該檢視沒有檢視控制器(UIViewController),那麼下一個響應者就直接是它的父檢視(Super View);
  4. 一直響應直至其物件是單例的視窗(UIWindow
  5. 再下一個響應者就是單例的應用(UIApplication),也是響應者鏈條的終點
  6. 下一個響應者指向 nil ,結束整個迴圈

事件分發

回到開篇的情況,當用戶點選了一下螢幕。系統檢測到使用者的觸控事件,就會將其打包成一個事件(即UIEvent物件),並將這個UIEvent物件放入 Application 的事件佇列中。這時系統只是知道有這麼一個事件發生,雖然響應者鏈條中有很多有處理事件能力的響應者,但是它不知道誰才是響應這個事件的最佳人選。 因此,系統會從UIApplication開始,順著響應者鏈條向上尋找那個最佳的人選。這個尋找的過程就是事件的分發過程

傳遞過程

  • 第一步UIApplication將這個事件從事件佇列中拿出來,從頂部開始詢問誰才是最佳人選;
  • 第二步UIWindow會最先獲取到事件,並開始使用hitTest:withEvent:來判斷下面他的子控制元件中誰才是最佳人選;
  • \ldots\ldots\ldots
  • 第 N - 1 步:當前UIView繼續詢問他的子檢視是不是最佳人選;
  • 第 N 步:當前UIView不是被點選的的檢視,orz,上一個UIView就是最佳人選了。

從使用者視角來看,系統通過hitTest:withEvent:方法,從檢視的底部一直向表面尋找最佳人選。因為是一直查詢,只有在所有的查詢都完成了,判斷出當前檢視沒有子檢視或者他的子檢視都不適合了,那麼當前檢視就是最佳人選了。(所以你只是點了一個你一眼就看中的檢視,其實系統是從底部開始,一頓連續操作才找到你想要的東西[汗顏])

hitTest:withEvent:

上面的事件分發過程中,大量使用了hitTest:withEvent:這個方法,它的處理流程如下:

  • 首先呼叫當前檢視的pointInside:withEvent:方法,判斷觸控點是否在當前檢視內
    • 若返回NO,則hitTest:withEvent:返回nil
    • 若返回YES,則向當前檢視的所有子檢視傳送hitTest:withEvent:訊息,所有子檢視的遍歷順序是從最頂層檢視一直到到最底層檢視,即從subviews陣列的末尾向前遍歷。
  • 若有子檢視返回非空物件,則hitTest:withEvent:方法返回此物件,處理結束;
  • 若所有子檢視都返回nil,則hitTest:withEvent:方法返回自身,即self,處理結束。

下面我們用一個圖解來理解一下這個hitTest:withEvent:

Demo

假如使用者點選了View D,結合上圖詳細介紹一下hitTest:withEvent:過程: (hitTest:withEvent:簡稱hitTestpointInside:withEvent:簡稱pointInsideView X簡稱X

  1. A 是 UIWindow 的根檢視,因此,UIWindow 物件會首先對 A 進行hitTest
  2. 顯然使用者點選的範圍是在 A 的範圍內,這時會繼續檢查 A 的子檢視;
  3. 這時候會有 B 和 C 兩個分支,由於 C 是後新增的子檢視,因此先對 C 進行hitTest
    • 顯然點選的範圍在 C 內;
  4. 這時候有 D 和 E 兩個分支,按順序先檢查 E
    • 顯然點選的範圍不在 E 內,對應的hitTest:withEvent:返回 nil;
    • 顯然點選的範圍在 D 內,由於 D 沒有子檢視(也可以理解成對 D 的子檢視進行hitTest時返回了 nil);
  5. 因此,D 的hitTest會將 D 返回,再往回回溯,就是 C 的hitTest返回 D,A 的hitTest返回 D。

至此,本次點選事件的第一響應者就通過響應者鏈的事件分發邏輯成功找到了

除了使用pointInside:withEvent:判斷是否是響應者,還有下面三種情況會使hitTest:withEvent:返回 nil:

  • 隱藏hidden=YES的檢視;
  • 禁止使用者操作userInteractionEnabled=YES的檢視;
  • 透明度小於0.01alpha<0.01的檢視。

因此hitTest:withEvent:的實現可能是:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}
複製程式碼

事件響應

前面說了一大堆事件的分發,其實就是為了找到響應事件的最佳人選,這個最佳人選就是在介紹響應者鏈條的時候,最底下的那個View。從這個 View 開始我們沿著響應者鏈條的方向進行響應。

開篇我們的說的是使用者點選螢幕的場景,因此,響應者會按照當前UITouch的所處階段使用下面的方法進行響應:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
複製程式碼
  • 在響應方法內部,我們也可以呼叫呼叫[super touches...]將這個觸控事件繼續分發給父控制元件的對應方法處理。然後父控制元件還可以將該事件繼續向上傳遞,直到傳遞給UIApplication物件。這一系列的響應者物件就構成了一個響應者鏈條
  • 如果不呼叫[super toucher...]事件不會繼續沿著響應者鏈條進行響應

小結

事件的分發響應都是在響應者鏈條上進行的,只不過是兩者傳遞的方向不同。

傳遞方向
上面的圖片中省略了 UIViewController,這裡說明一下他的位置:

  • 事件分發過程中沒有ViewController的事
  • 事件響應的過程中,傳遞的方向如下:

UIController情況

至此,我們已經大概瞭解了當使用者用手指點選了一下螢幕,會發生什麼。

通過對這些的瞭解,我們可以通過使用下面兩種方式來實現一些特殊需求:

  • 重寫 UIView 中的hitTest:withEvent:來影響事件分發
  • 重寫 UIResponder 中的touches系列方法來影響事件響應

問題

我在測試hitTest:withEvent:的過程中,通過執行時給每個hitTest:withEvent:都添加了列印方法,在點選綠色的B View的時候出現了下面的重複尋找的情況(不單隻點選B View時候有出現)

這個現象我不太會解釋...希望有人可以解答一下。

問題
Demo地址