調試iOS用戶交互事件響應流程

[TOC]程序員

調試iOS用戶交互事件響應流程

2020-03-19windows

一般 iOS 界面開發中處理各類用戶交互事件。其中,UIControlEvent以註冊的 Target-Action 的方式綁定到控件;UIGestureRecognizer經過addGestureRecognizer:添加到UIViewgestureRecognizers屬性中;UIResponder提供了touchesBegin/Moved/Ended/Canceled/:withEvent:motionsXXX:withEvent:pressXX:withEvent:系列接口,將用戶設備的觸摸、運動、按壓事件通知到UIResponder對象等等。以上都是經常使用開發者處理用戶交互事件的方式,那麼隱藏在這些接口之下,從驅動層封裝交互事件對象到 UI 控件接收到用戶事件的流程是怎樣的呢?本文主要探討的就是這個問題。app

1、響應鏈

Apple Documentation 官方文檔Using Responders and the Responder Chain to Handle Events介紹了利用UIResponder的響應鏈來處理用戶事件。UIResponder實現了touchesXXXpressXXXmotionXXX分別用於響應用戶的觸摸、按壓、運動(例如UIEventSubtypeMotionShake)交互事件。UIResponder包含nextResponder屬性。UIViewUIWindowUIControllerUIApplication都是UIResponder的派生類,因此都能響應以上事件。ide

1.1 Next Responder

響應鏈結構以下圖所示,基本上是經過UIRespondernextResponder成員串聯而成,基本上是按照 view 的層級,從前向後由子視圖向父視圖傳遞,且另外附加其餘規則。總的響應鏈的規則以下:函數

  • View 的nextResponder是其父視圖;
  • 當 View 爲 Controller 的根視圖時,nextResponder是 Controller;
  • Controller 的nextResponder是 present Controller 的控制器;
  • 當 Controller 爲根控制器時,nextResponder是 Window;
  • Window 的nextResponder是 Application;
  • Application 的nextResponder是 App Delegate(僅當 App Delegate 爲UIResponder類型);

響應鏈

UIResponder響應touchesXXXpressXXXmotionXXX事件不須要指定userInteractionEnabledYES。可是對於UIView則須要指定userInteractionEnabled,緣由是UIView從新實現了這些方法。響應UIGesture則須要指定userInteractionEnabledaddGestureRecognizer:UIView類的接口。工具

注意:新版本中,分離了 Window 和 View 的響應鏈。當 Controller 爲根控制器時,nextResponder其實是nil;Windows 的nextResponder是 Window Scene;Window Scene 的nextResponder是 Application。在後面的調試過程會有體現。oop

1.1.1 調試nextResponder

使用一個簡單的 Demo 調試nextResponder。界面以下圖所示,包含三個 Label,從顏色能夠判斷其層次從後往前的順序是:A >> B >> C。下面兩個按鈕另作他用,先忽略。ui

運行 Demo,查看各個元素的nextResponder,確實如前面所述。spa

1.2 Target-Action和響應鏈

UIControl控件與關聯的 target 對象通訊,直接經過向 target 對象發送 action 消息。雖然 Action 消息雖然不是事件,可是 Action 消息的傳遞是要通過響應鏈的。當接收到用戶交互事件的控件的 target 爲nil時,會沿着控件的響應鏈向下搜索,直到找到實現該 action 方法的對象爲止。UIKit 的編輯菜單就是經過這個機制實現的,UIKit 會沿着控件的響應鏈搜索實現了cut:copy:paste:等方法的對象。3d

1.2.1 註冊UIControlEvents

UIControl控件調用addTarget:action:forControlEvents:方法註冊事件時,會將構建UIControlTargetAction對象並將其添加到UIControl控件的(NSMutableArray*)_targetActions私有成員中,addTarget:action:forControlEvents:方法的 Apple Documentation 註釋中有聲明調用該方法時UIControl並不會持有 target 對象,所以無需考慮循環引用的問題。UIControl Events 註冊過程的簡單調試過程以下:

UIControl Target Action

附註:The control does not retain the object in the target parameter. It is your responsibility to maintain a strong reference to the target object while it is attached to a control.

1.2.2 調試UIControlEvents的傳遞

前面內容提到,控件的 action 是沿着響應鏈傳遞的,那麼,當兩個控件在界面上存在重合的區域,那麼在重合區域觸發用戶事件時,action 消息會在哪一個控件上產生呢?在 1.1.1 中的兩個重合的按鈕就是爲了驗證這個問題。

稍微改造一下 1.1.1 的 Demo 程序,將 Label A、B、C 指定爲自定義的繼承自UILabel的類型TestEventsLabel,將兩個 Button 指定爲繼承自UIButtonTestEventsButton類型。而後在TestEventsLabelTestEventsButtonViewController中,爲touchesXXX:系列方法、nextResponder方法、hitTest:withEvent:方法添加打印日誌的代碼,以TestEventsButton的實現爲例(固然也能夠用 AOP 實現):

@implementation TestEventsButton

-(UIResponder *)nextResponder{
    UIResponder* responder = [super nextResponder];
    NSLog(@"Next Responder Button %@ - return responder: %@", [self titleForState:UIControlStateNormal], responder);
    return responder;
}

-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    UIView* view = [super hitTest:point withEvent:event];
    NSLog(@"Hit Test Button %@ - return view: %@", [self titleForState:UIControlStateNormal], view);
    return view;
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesBegan:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesEnded:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}
-(void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesMoved:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}
-(void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesCancelled:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}

@end
複製代碼
結論一:Action不會在同級視圖層級中傳遞

一切準備就緒,運行 Demo,點擊「點我前Button」,抓取到了以下日誌。注意框①中指定的 target 是self,也就是 Controller。能夠發現點擊事件產生,調用了若干次碰撞檢測(框②),若干次nextResponder(框③),最終只調用了 Controller 中「點我前Button」的 action 方法。這是由於:

  • Target-Action 消息在傳遞時,永遠不會在同級視圖層級中傳遞
  • Target 非空,則 UIKit 在確認控件響應某個事件後,會直接給控件的 target 對象發送 action 消息,這個過程不存在任何視圖層級傳遞 或 響應鏈傳遞的過程;

結論二:Target爲空時Action仍能夠被響應

接下來將addTarget:action:中指定的 target 設爲nil。而後在TestEventsButton中也添加 action 的響應代碼,以下所示。

-(void)didClickBtnFront:(id)sender{
    NSLog(@"In Button 點我前Button Did Click Action %s", __func__);
}

-(void)didClickBtnBack:(id)sender{
    NSLog(@"In Button 點我後Button Did Click Action %s", __func__);
}
複製代碼

點擊「點我前Button」,抓取到了以下日誌。此次,由TestEventsButton處理了 action 消息。說明當控件註冊 action 時指定的 target 爲nil時,action 消息仍然能夠被響應,且 action 只響應一次。請記住,此時nextResponder被調用了 5 次。

結論三:Target爲空時Action沿響應鏈傳遞

再進一步修改代碼,將結論二中TestEventsButton的新增代碼刪除,仍然將addTarget:action:中指定的 target 設爲nil。點擊「點我前Button」,抓取到了以下日誌。此次,處理 action 消息的是 Controller。並且從日誌中咱們發現,此次nextResponder調用了 6 次,確切地說,是在 Button touchBegin以後,Controller 處理 action 消息以前(如圖中紅框所示)。這是由於,target 爲nil時,action 消息會沿着響應鏈傳遞,直到找到能夠響應 action 的對象爲止

能夠繼續嘗試給「點我後Button」,直接將self.btnFront的註冊 Target-Action 的代碼刪掉。運行 Demo,再次點擊「點我前Button」,此時didClickBtnBack仍然不觸發。這其實只是進一步印證了「結論一」的結論,這裏再也不演示。

整個調試過程下來,能夠發現,被 ButtonA 覆蓋的 ButtonB,全部 action 都會被 ButtonA 攔截,被覆蓋的 ButtonB 不會得到任何觸發 action 的機會。

1.3 手勢識別和響應鏈

Gesture Recognizer 會在 View 以前接收 Touch 和 Press 事件,當 Gesture Recognizer 對一連串的 Touch 事件手勢識別失敗時,UIKit 纔將這些 Touch 事件發送給 View。若 View 不處理這些 Touch 事件,UIKit 將其遞交到響應鏈。

1.4 修改響應鏈

響應鏈主要經過nextResponder方法串聯,所以從新實現UIResponder派生類的nextResponder方法能夠實現響應鏈修改的效果。

2、Touch事件傳遞

當 touch 事件發生時,UIKit 會構建一個與 view 關聯的UITouch實例,當 touch 位置變化時,僅改變 touch 的屬性值,但不包括其view屬性。即便 touch 移出了 view 的範圍,view屬性仍然是不變的。UITouchgestureRecognizers屬性表示正在處理該 touch 事件的全部 gesture recognizer。UITouchtimestamp屬性表示 touch 事件的發生時間或者上一次修改的時間。UITouchphase屬性,表示 touch 事件當前所在的生命週期階段,包括UITouchPhaseMovedUITouchPhaseBeganUITouchPhaseStationaryUITouchPhaseEndedUITouchPhaseCanceled

2.1 碰撞檢測

UIKit 經過 hit-test 碰撞檢測肯定哪些 View 須要響應 touch 事件,hit-test 經過比較 touch 的位置與 View 的 bounds 判斷 touch 是否與 View 相交。Hit-test 是在 View 的視圖層級中,取層級最深的子視圖,做爲 touch 事件的 first responder,而後從前向後遞歸地對每一個子視圖進行 Hit-test,直到子視圖命中,直接返回命中的子視圖

Hit-test 經過UIViewhitTest:withEvent:方法實現,若 touch 的位置超出了 view 的 bounds 範圍,則hitTest:withEvent:會忽略該 view 及其全部子視圖。因此,當 view 的maskToBoundsNO時,即便 touch 看起來落在了某個視圖上,但只要 touch 位置超出了 view 或者其 super view 的 bounds 範圍,則該 view 仍然會接收不到 touch 事件。

碰撞檢測方法- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;中,point參數是碰撞檢測點在事件發生的 view 的座標系中的座標;event參數是使用本次碰撞檢測的UIEvent事件。當目標檢測點不在當前 view 的範圍內時,該方法返回nil,反之則返回 view 自己。hitTest:withEvent:方法是經過調用UIView- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;方法實現的,該方法忽略userInteractionEnabledNO或者 alpha 值小於 0.01 的視圖。

2.2 調試Touch事件傳遞

Touch 事件傳遞過程主要調用了hitTest:withEvent:方法,Touch 事件若未被 gesture recognizer 捕捉則最終會去到touchesXXX:系列方法。在響應鏈的調試時,已經見到很多hitTest:withEvent:調用的痕跡。

在第一章「結論一」的運行日誌中,發現點擊「點我前Button」時,也對 Label A、B、C 作了碰撞檢測,且並無對「點我後Button」作碰撞檢測。注意到 Label 和 Button 都是self.view的子視圖,且 Label A、B、C 在「點我前Button」以前,「點我後Button」以後。前面提到過:Hit-test 是在 View 的視圖層級中,取層級最深的子視圖,做爲 touch 事件的 first responder,而後從前向後遞歸地對每一個子視圖進行 Hit-test。所以,self.view調用 Hit-Test 時,首先找到的是 Label C。而後,從前向後遞歸調用hitTest:withEvent:,所以纔會有C >> B >> A >> 點我前Button的順序。爲何到「點我後Button」沒有遞歸到呢?這是由於self.viewhitTest:withEvent:在迭代到「點我前Button」時命中了目標,所以直接返回「點我前Button」。而更後面的「點我前Button」就直接被跳過了。

爲驗證上面的推測。繼續在 Demo 中引入繼承自UIViewTestEventsView類型,套路和前面的 Button、Label 一致,就是爲了打印關鍵日誌。而後將 Controller 的根視圖,也就是self.view的類型設置爲TestEventsView。而後再在 Controller 的viewDidLoad中增長打印 Button 信息的代碼以做對照。

準備就緒,運行 Demo,點擊「點我前Button」,獲得如下日誌,干擾信息變多了,遮擋掉其中一部分。關注到紅色框中的內容,發現self.viewhitTest:forEvent:返回的正是「點我前Button」,並且「點我前Button」的hitTest:forEvent:返回了自身。與前面的推測徹底符合。

步驟零:準備工做

前一小節的調試過程其實已經能夠證實改結論,可是因爲只是經過對有限的相關共有方法,譬如hitTest:forEvent:nextResponder的調用次序的打印彷佛還不夠深刻。接下來用 lldb 下斷點的方式,進行調試。

在這以前須要作一些準備工做,此次是使用 lldb 調試主要經過查看函數調用棧、寄存器數據、內存數據等方式分析,所以不須要打印日誌的操做,何況新增的hitTest:withEventnextRespondertouchesXXX方法會徒增調用棧的層數,所以將TestEventsLabelTestEventsButtonTestEventsViewViewController的這些方法悉數屏蔽。去掉一切沒必要要的日誌打印邏輯。

準備就緒,運行 Demo,先不急着開始,首先查看 Demo 的視圖層級,先記住這個UIWindow實例,它是應用的主窗口,它的內存地址是0x7fa8f10036b0,後面會用到。

注意:從 iOS 13 開始,引入了UIWindowScene統一管理應用的窗口和屏幕,UIWindowScene包含windowsscreen屬性。上圖所展現UIWindowScene只包含了一個子 Window,實際真的如此嗎?

步驟一:下斷點

首先使用break point -n命令在四個關鍵方法處下斷點:

  • hitTest:withEvent:
  • nextResponder
  • touchesBegan:withEvent:
  • touchesEnded:withEvent:

注意:彙編代碼中的函數一般以pushq %rbpmovq %rsp, %rbp開頭,其中bp是基地址寄存器(base pointer),sp是堆棧寄存器(stack pointer),bp保存當前函數棧幀的基地址(棧底),sp保存當前函數棧幀的下一個可分配地址(棧頂),函數每分配一個單元的棧空間,sp自動遞增,而bp保持不變。相應地,函數返回前都會有popq %rbp操做。

步驟二:簡單分析 touch 事件在 Window 層的分發

點擊「點我前Button」,很快觸發了第一個hitTest:withEvent:的斷點。先用bt命令查看當前調用棧,發現第 0 幀調用了UIAutoRotatingWindowhitTest:withEvent:,打印寄存器數據獲取到r14r15都傳遞了UIWindow參數,但實際上調用該方法的是一個UITextEffectsWindow實例,UITextEffectsWindowUIAutoRotatingWindow。它的內存地址是0x00007fa8ebe05050顯然不是 main window

r14傳遞的地址是0x00007fa8f10036b0,正是 main window。之因此是UITextEffectsWindow接收到hitTest:withEvent:是由於Window 層中的碰撞檢測是使用上圖中紅色框中的私有方法進行處理。接下來一步步弄清紅框中的碰撞檢測處理的 touch 事件的傳遞具體經由哪些 Window 實例。frame select 8跳到第 8 幀,跟蹤到了一個UIWindow對象0x7fa8f10036b0。所以,Window 層級中最早接收到 touch 事件的確實是 main window

依次類推打印出全部棧幀的當前對象以下(有些層級到斷點行時寄存器已經被修改,會找不到目標類型的實例,此時能夠回到上一層打印須要傳入下一層的全部寄存器的值便可):

frame 0: UITextEffectsWindow 0x00007fa8ebe05050 frame 1: UITextEffectsWindow 0x00007fa8ebe05050 frame 2: UITextEffectsWindow 0x00007fa8ebe05050 frame 3: UIWindow +(類方法) frame 4: UIWindowScene -(nil不須要使用self) frame 5: UIWindowScene 0x00007fa8ebd06c50 frame 6: UIWindowScene 0x00007fa8ebd06c50 frame 7: UIWindow +(類方法) frame 8: UIWindow 0x00007fa8f10036b0

能夠進一步使用 lldb 調試命令理清上面幾個對象之間的關係。首先是圖一中 window scene 與 window 之間的關係。圖二則打印出了UITextEffectsWindow的視圖層級。圖三是 main window 的視圖層級,注意到紅框中的對象,是否似曾相識?沒錯,到這裏追蹤到 Controller 的TestEventsView類型的根 view。

圖一:WindowScene與Window之間的關係

UITextEffectsWindow視圖層級
圖二:UITextEffectsWindow的視圖層級

圖三:Main Window的視圖層級

爲何新版本 iOS 的 touch 事件傳遞過程,須要分離出 Window 層和 View 層階段?是由於自 iOS 13 起引入UIWindowScene後,UITextEffectsWindow和 main window 有各自的視圖層級,且二者都沒有superview,所以必須修改 touch 的傳遞策略,讓事件都能分發到兩個 window 中。

注意:本來猜測,C 語言轉化爲彙編語言時,遵循聲明一個局部變量就要分配一個棧空間的,調用函數時須要將形參和返回值地址推入堆棧,然而從調試過程當中查看 Objective-C 的彙編代碼,其實現並非如此。因爲現代處理器包含了大量的高效率存儲器,所以 clang 編譯時會最大限量地合理利用起這些寄存器(一般是通用寄存器)以提升程序執行效率。一般傳遞參數用到最多的是r12r13r14r15寄存器,但毫不僅限於以上列舉的幾個。這給源代碼調試增長了很大的難度。

步驟三:分析 Touch 事件的產生

注意這裏的 touch 事件並非指 UIKit 的 touch event,UIKit 的 touch event 在 UIKit 接收到來自驅動層的點擊事件信號後就構建了 touch 事件的UIEvent對象。這裏的 touch 事件是指通過碰撞檢測肯定了 touch event 的響應者從touchesBegan:withEvent:開始傳遞以前產生的UITouch對象。

一、如今正式開始追蹤 touch 事件。已知,步驟二中打斷的第一次hitTest:withEvent:命中,其調用對象是UITextEffectsWindow實例。此時點擊調試工具欄中的「continue」按鈕,繼續執行。

注意:因爲調試過程比較長,致使繼續運行時 lldb 被打斷須要從新運行。不過問題不大,由於前面的工做已經肯定了須要追蹤的關鍵對象。所以從新運行後,從新下斷點,再記錄一次關鍵對象的地址便可。

開始收集斷點命中(包括第一次命中):

  • UITextEffectsWindow:(Hit-Test)
  • UITextEffectsWindow:(Hit-Test)(調用 UIView 的實現)
  • UIInputSetContainerView:(Hit-Test)
  • UIInputSetContainerView:(Hit-Test)(調用 UIView 的實現)
  • UIEditingOverlayGestureView:(Hit-Test)
  • UIEditingOverlayGestureView:(Hit-Test)(調用 UIView 的實現)
  • UIInputSetHostView:(Hit-Test)
  • UIInputSetHostView:(Hit-Test)(調用 UIView 的實現)
  • UIWindow:(Hit-Test)(調用 UIView 的實現)
  • UITransitionView:(Hit-Test)
  • UITransitionView:(Hit-Test)(調用 UIView 的實現)
  • UIDropShadowView:(Hit-Test)
  • UIDropShadowView:(Hit-Test)(調用 UIView 的實現)
  • TestEventsView:(Hit-Test)(調用 UIView 的實現)

至此 Hit-Test 斷點命中了以前自定義的 Controller 的TestEventsView類型的根類,在這裏打印一下調用棧。調用棧增長至 38 層以下圖。並且上面的層次都是在調用hitTest:withEvents方法,這是個明顯的遞歸調用的表現。並且到此爲止,Hit-Test 仍然沒有命中任何視圖

二、繼續運行收集斷點信息:

  • {TestEventsLabel: 0x7fd8d48071a0; baseClass = UILabel; frame = (121 162; 250 166); text = 'C'; opaque = NO; autoresize = RM+BM; layer = <_UILabelLayer: 0x600003399040>}:(Hit-Test)(調用超類的實現)
  • {TestEventsLabel: 0x7fd8d4806df0; baseClass = UILabel; frame = (82 116; 250 166); text = 'B'; opaque = NO; autoresize = RM+BM; layer = <_UILabelLayer: 0x600003398f50>}:(Hit-Test)(調用超類的實現)
  • {TestEventsLabel: 0x7fd8d4805aa0; baseClass = UILabel; frame = (44 75; 250 166); text = 'A'; opaque = NO; autoresize = RM+BM; layer = <_UILabelLayer: 0x600003398870>}:(Hit-Test)(調用超類的實現)
  • {TestEventsButton: 0x7fd8d48056c0; baseClass = UIButton; frame = (121 478; 173 79); opaque = NO; autoresize = RM+BM; layer = <CALayer: 0x6000010813e0>}:(Hit-Test)(調用 UIControl 的實現)

Hit-Test 斷點終於命中了 Demo 的自定義 Label 和 Button 控件。根據收集的信息,命中順序是 LabelC -> LabelB -> LabelA -> 點我前Button。此時,不急着繼續,在調試窗口中使用bt指令,觀察到調用棧深度已經來到了 43 層之多,以下圖所示。可是注意到一點,以上每次斷點命中,其調用棧深度都是 43 層,也就是說上面幾個同層視圖的碰撞檢測過程是循環迭代,而不是遞歸,三個TestEventsLabel調用hitTest:withEvent:均可以直接返回nil不須要遞歸。

三、繼續運行收集斷點信息:

  • TestEventsButton:(Hit-Test)(調用 UIView 的實現)
  • UIButtonLabel:(Hit-Test)(調用超類的實現)

調用棧到達了第一個高峯 49 層,以下圖一所示。此時若點擊繼續,會發現調用棧回落到 13 層,以下圖二所示。說明 Hit-Test 斷點在命中UIButtonLabel後,本次 Hit-Test 遞歸就返回了。至於具體返回什麼對象,實際上在 1.2.2 的調試日誌中已經打印出來了,正是「點我前Button」。

圖一:Hit-Test調用棧到達頂峯

圖二:Hit-Test調用棧回落

四、繼續運行,Demo 會進入第二次 Hit-Test 遞歸,之因此一次點擊事件引起了兩輪遞歸,是由於 touch 事件在開始和結束時,各進行了一輪碰撞檢測。繼續收集斷點信息:

  • UIWindow:(Hit-Test)(調用 UIView 的實現)
  • UITransitionView:(Hit-Test)
  • UITransitionView:(Hit-Test)(調用 UIView 的實現)
  • UIDropShadowView:(Hit-Test)
  • UIDropShadowView:(Hit-Test)(調用 UIView 的實現)
  • TestEventsView:(Hit-Test)(調用 UIView 的實現)
  • TestEventsLabel:(Hit-Test)(調用 UIView 的實現)
  • TestEventsLabel:(Hit-Test)(調用 UIView 的實現)
  • TestEventsLabel:(Hit-Test)(調用 UIView 的實現)
  • TestEventsButton:(Hit-Test)(調用 UIControl 的實現)
  • TestEventsButton:(Hit-Test)(調用 UIView 的實現)
  • UIButtonLabel:(Hit-Test)(調用 UIView 的實現)

調用棧再次到達了高峯 41 層以下圖所示。

此時先不急着繼續。由於以上是 Hit-Test 在本次調試中的最後一次斷點命中,點擊繼續 Hit-Test 遞歸必然返回「點我前Button」,表示碰撞檢測命中了該按鈕控件。第二輪 Hit-Test 的調用棧明顯淺許多,不難發現其緣由是該輪碰撞檢測沒有通過UITextEffectsWindow而直接從UIWindow開始(箇中緣由不太肯定)。

總結 Hit-Test 的處理過程的要點是:

  • 優先檢測本身是否命中,不命中則直接忽略全部 subviews
  • 若本身命中,則對全部子視圖按同層級視圖順序從前向後的順序依次進行碰撞檢測,所以碰撞檢測也是 superview 到 subview 的按視圖層級從後向前遞歸的過程;
  • 若全部子視圖均未命中,本身的碰撞檢測才返回 nil

文字表述彷佛有點不太直觀,仍是用我們程序員的語言吧,僞代碼以下:

- (UIView * _Nullable)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    // 1. 優先檢測本身,不命中則馬上排除
    BOOL isHit = [self pointInside:point withEvent:event];
    if(!isHit){
        return nil;
    }

    // 2. 從前向後循環迭代全部子視圖
    for(UIView* subviews in subviews){
        // 跨視圖層級從 superview 向 subview 遞歸
        UIView* hitView = [subviews hitTest:point withEvent:event];
        if(hitView)
            return hitView;
    }
    
    // 3. 全部子視圖未命中返回nil
    return nil;
}
複製代碼

步驟四:分析 touch 事件開始後的傳遞

狀況一:點擊 Button 控件時

步驟三執行完成,UIKit 產生了UITouch事件並開始傳遞該事件。緊接在以前的基礎上繼續調試。再點擊 continue,收集斷點信息:

  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • TestEventsButton:(Touches-Began)(調用 UIControl 的實現)

此時 Button 嘗試觸發 touchesBegan,開始UITouch事件傳遞。調用棧以下,是由 UIWindow 發送過來的 touch 事件。注意上面TestEventsButton調用的是UIControl 的實現,記住這個「貓膩」,後面的部分會再次提到。

  • TestEventsButton:(Next-Responder)(調用 UIView 的實現)

終於命中了 Next-Responder 斷點,從上下兩個調用棧能夠發現,nextResponder是在touchBegan方法內調用的。

再點擊 continue,繼續運行收集斷點信息:

  • TestEventsView:(Next-Responder)(調用 UIView 的實現)

nextResponder是在touchBegan方法內調用的,且增長了調用棧深度,說明nextResponder也觸發了遞歸的過程。可是遞歸的不是nextResponder而是UIResponder裏面的一個私有方法_controlTouchBegan:withEvent:。該方法彷佛只簡單遍歷了一輪響應鏈,其餘的什麼都沒作。

再點擊 continue,繼續運行收集斷點信息:

  • UIViewController:(Next-Responder)(調用 UIViewController 的實現)
  • UIDropShadowView:(Next-Responder)(調用 UIView 的實現)
  • UITransitionView:(Next-Responder)(調用 UIView 的實現)
  • UIWindow:(Next-Responder)
  • UIWindowScene:(Next-Responder)(調用 UIScene 的實現)
  • UIApplication:(Next-Responder)
  • AppDelegate:(Next-Responder)(調用 UIResponder 的實現)

AppDelegate層,調用棧達到頂峯,以下圖所示。

在調試過程當中,發現響應鏈上除了第一響應者「點我前Button」外的全部對象都沒有調用touchesBegan:withEvent:響應該 touch 事件。那麼這就是對 touch 事件該有的處理麼?其實否則,因爲調試時點擊的是 Button 控件,所以上述是對UIControl控件做爲第一響應者的狀況的,經過定製UIControltouchesBegan:withEvent:方法實現的,特殊處理。上面提到的私有方法_controlTouchBegan:withEvent:就是爲了告訴後面響應鏈後面的響應者這個 touch 事件已經被前面的 UIControl 處理了,請您不要處理該事件

那麼UIResponder原始的響應流程是怎樣的呢?繼續調試狀況二。

狀況二:點擊 Label 視圖

流程漸漸明朗的狀況下,能夠先breakpoint disable終止上面的斷點,而後breakpoint delete XXX刪除掉hitTest:withEvent:斷點,以減小打斷次數。解屏蔽掉以前屏蔽的打印日誌的代碼,由於當斷點命中 Demo 中的自定義類時,能夠直接判定nextResponder的觸發類。

點擊界面中的 Label C。開始收集信息(省略自定義日誌打印方法只保留原始方法):

  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • TestEventsLabel:(Touches-Began)(調用 UIResponder 的實現)
  • TestEventsLabel:(Next-Responder)(調用 UIView 的實現)
  • TestEventsView:(Touch-Began)(調用 UIResponder 的實現)
  • TestEventsView:(Next-Responder)(調用 UIView 的實現)
  • UIViewController:(Touch-Began)(調用 UIResponder 的實現)
  • UIViewController:(Next-Responder)(調用 UIViewController 的實現)
  • UIDropShadowView:(Touch-Began)(調用 UIResponder 的實現)
  • UIDropShadowView:(Next-Responder)(調用 UIView 的實現)
  • UITransitionView:(Touch-Began)(調用 UIResponder 的實現)
  • UITransitionView:(Next-Responder)(調用 UIView 的實現)
  • UIWindow:(Touch-Began)(調用 UIResponder 的實現)
  • UIWindow:(Next-Responder)
  • UIWindowScene:(Touch-Began)(調用 UIResponder 的實現)
  • UIWindowScene:(Next-Responder)(調用 UIScene 的實現)
  • UIApplication:(Touch-Began)(調用 UIResponder 的實現)
  • UIApplication:(Next-Responder)
  • AppDelegate:(Touch-Began)(調用 UIResponder 的實現)
  • AppDelegate:(Next-Responder)(調用 UIResponder 的實現)

至此先看一下調用棧,顯然touchesBegan:withEvent:也是遞歸的過程:

總結上面收集的信息,UIResponder做爲第一響應者和UIControl做爲第一響應者的區別已經至關明顯了。UIResponder做爲第一響應者時,是沿着響應鏈傳遞,通過的每一個對象都會觸發touchesBegan:withEvents:方法

步驟五:分析 touch 事件結束後的傳遞

Touch 事件事件結束會觸發第一響應者的touchesEnded:withEvent:方法,具體傳遞過程和步驟四中一致。一樣要區分UIControlUIResponder的處理。

最後,不管是UIControl仍是UIResponder,在完成全部touchesEnded:withEvent:處理後,都要額外再從第一響應者開始遍歷一次響應鏈。從調用棧能夠看到是爲了傳遞UIResponder_completeForwardingTouches:phase:event消息。具體緣由不太清楚。

3、RunLoop與事件(TODO)

行文至此,文章篇幅已經有點長,所以在下一篇文章中在調試這部份內容。

4、總結

  • 不管是使用UIControl的 Target-Action 方式仍是UIRespondertouchesXXX方式處理用戶事件,都涉及到 Hit-Test 和 響應鏈的內容;
  • UIControl使用 Target-Action 註冊用戶事件,當後面的控件被前面的控件覆蓋時,若用戶事件(UIEvent)被前面的控件攔截(不管前面的控件有沒有註冊 Target-Action),則後面的控件永遠得不處處理事件的機會,即便前面的控件未註冊 Target-Action;
  • UIControl使用 Target-Action 註冊用戶事件,指定 Target 爲空時,Action 消息會沿着響應鏈傳遞,直到找到能響應 Action 的 Responder 爲止,Action 一旦被其中一個 Responder 響應,響應鏈後面的對象就再也不處理該 Action 消息;
  • 響應鏈是以 View 爲起始,向 superview 延伸的一個反向樹型結構,經過UIRespondernextResponder串聯而成;
  • 當 View 做爲 Controller 的根 view 時,nextResponder是 Controller;
  • 當 Controller 是由其餘 Controller present 而來,則nextResponder是其 present controller;
  • 當 Controller 是 Window 的根 Controller,則nextResponder是 Window,注意調試中 Controller 的nextResponder是返回nil,但實際上它們確實有這層關係;
  • Window 的nextResponder是 Window Scene;
  • Window Scene 的nextResponder是 Application;
  • Application 的nextResponder是 AppDelegate(當 AppDelegate 是UIResponder類型時);
  • Hit-Test 優先檢測本身是否命中,不命中則直接忽略全部 subviews
  • Hit-Test 若本身命中,則對全部子視圖按同層級視圖順序從前向後的順序依次進行碰撞檢測,所以碰撞檢測也是 superview 到 subview 的按視圖層級從後向前遞歸的過程;
  • Hit-Test 若未命中任何子視圖,本身的碰撞檢測才返回 nil;
  • Hit-Test 命中目標後,產生UITouch事件,UITouch事件會沿着響應鏈傳遞到後面的全部響應者;
  • UIResponder做爲第一響應者響應了 touch 事件,響應鏈後面的全部響應者也會觸發touchesXXX系列方法;
  • UIControl控件做爲第一響應者響應了 touch 事件,響應鏈後面的全部響應者均再也不處理該 touch 事件;
相關文章
相關標籤/搜索