調試iOS使用者互動事件響應流程一、響應鍊1.1 Next Responder1.1.1 調試nextResponder1.2 Target-Action和響應鍊1.2.1 注冊UIControlEvents1.2.2 調試UIControlEvents的傳遞結論一:Action不會在同級視圖層級中傳遞結論二:Target為空時Action仍可以被響應結論三:Target為空時Action沿響應鍊傳遞1.3 手勢識别和響應鍊1.4 修改響應鍊二、Touch事件傳遞2.1 碰撞檢測2.2 調試Touch事件傳遞步驟零:準備工作步驟一:下斷點步驟二:簡單分析 touch 事件在 Window 層的分發步驟三:分析 Touch 事件的産生步驟四:分析 touch 事件開始後的傳遞情況一:點選 Button 控件時情況二:點選 Label 視圖步驟五:分析 touch 事件結束後的傳遞三、RunLoop與事件(TODO)四、總結
調試iOS使用者互動事件響應流程
2020-03-19
通常 iOS 界面開發中處理各種使用者互動事件。其中,
UIControlEvent
以注冊的 Target-Action 的方式綁定到控件;
UIGestureRecognizer
通過
addGestureRecognizer:
添加到
UIView
的
gestureRecognizers
屬性中;
UIResponder
提供了
touchesBegin/Moved/Ended/Canceled/:withEvent:
、
motionsXXX:withEvent:
、
pressXX:withEvent:
系列接口,将使用者裝置的觸摸、運動、按壓事件通知到
UIResponder
對象等等。以上都是常用開發者處理使用者互動事件的方式,那麼隐藏在這些接口之下,從驅動層封裝互動事件對象到 UI 控件接收到使用者事件的流程是怎樣的呢?本文主要探讨的就是這個問題。
一、響應鍊
Apple Documentation 官方文檔Using Responders and the Responder Chain to Handle Events介紹了利用
UIResponder
的響應鍊來處理使用者事件。
UIResponder
實作了
touchesXXX
、
pressXXX
、
motionXXX
分别用于響應使用者的觸摸、按壓、運動(例如
UIEventSubtypeMotionShake
)互動事件。
UIResponder
包含
nextResponder
屬性。
UIView
、
UIWindow
、
UIController
、
UIApplication
都是
UIResponder
的派生類,是以都能響應以上事件。
1.1 Next Responder
響應鍊結構如下圖所示,基本上是通過
UIResponder
的
nextResponder
成員串聯而成,基本上是按照 view 的層級,從前向後由子視圖向父視圖傳遞,且另外附加其他規則。總的響應鍊的規則如下:
- View 的
是其父視圖;nextResponder
- 當 View 為 Controller 的根視圖時,
是 Controller;nextResponder
- Controller 的
是 present Controller 的控制器;nextResponder
- 當 Controller 為根控制器時,
是 Window;nextResponder
- Window 的
是 Application;nextResponder
- Application 的
是 App Delegate(僅當 App Delegate 為nextResponder
類型);UIResponder

響應鍊
UIResponder
響應
touchesXXX
、
pressXXX
、
motionXXX
事件不需要指定
userInteractionEnabled
為
YES
。但是對于
UIView
則需要指定
userInteractionEnabled
,原因是
UIView
重新實作了這些方法。響應
UIGesture
則需要指定
userInteractionEnabled
,
addGestureRecognizer:
是
UIView
類的接口。
注意:新版本中,分離了 Window 和 View 的響應鍊。當 Controller 為根控制器時,實際上是
nextResponder
;Windows 的
nil
是 Window Scene;Window Scene 的
nextResponder
是 Application。在後面的調試過程會有展現。
nextResponder
1.1.1 調試nextResponder
使用一個簡單的 Demo 調試
nextResponder
。界面如下圖所示,包含三個 Label,從顔色可以判斷其層次從後往前的順序是:A >> B >> C。下面兩個按鈕另做他用,先忽略。
運作 Demo,檢視各個元素的
nextResponder
,确實如前面所述。
1.2 Target-Action和響應鍊
UIControl
控件與關聯的 target 對象通信,直接通過向 target 對象發送 action 消息。雖然 Action 消息雖然不是事件,但是 Action 消息的傳遞是要經過響應鍊的。當接收到使用者互動事件的控件的 target 為
nil
時,會沿着控件的響應鍊向下搜尋,直到找到實作該 action 方法的對象為止。UIKit 的編輯菜單就是通過這個機制實作的,UIKit 會沿着控件的響應鍊搜尋實作了
cut:
、
copy:
、
paste:
等方法的對象。
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 指定為繼承自
UIButton
的
TestEventsButton
類型。然後在
TestEventsLabel
、
TestEventsButton
、
ViewController
中,為
touchesXXX:
系列方法、
nextResponder
方法、
hitTest:withEvent:
方法添加列印日志的代碼,以
TestEventsButton
的實作為例(當然也可以用 AOP 實作):
結論一:Action不會在同級視圖層級中傳遞
一切準備就緒,運作 Demo,點選“點我前Button”,抓取到了如下日志。注意框①中指定的 target 是
self
,也就是 Controller。可以發現點選事件産生,調用了若幹次碰撞檢測(框②),若幹次
nextResponder
(框③),最終隻調用了 Controller 中“點我前Button”的 action 方法。這是因為:
- Target-Action 消息在傳遞時,永遠不會在同級視圖層級中傳遞;
- Target 非空,則 UIKit 在确認控件響應某個事件後,會直接給控件的 target 對象發送 action 消息,這個過程不存在任何視圖層級傳遞 或 響應鍊傳遞的過程;
結論二:Target為空時Action仍可以被響應
接下來将
addTarget:action:
中指定的 target 設為
nil
。然後在
TestEventsButton
中也添加 action 的響應代碼,如下所示。
點選“點我前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
方法可以實作響應鍊修改的效果。
二、Touch事件傳遞
當 touch 事件發生時,UIKit 會建構一個與 view 關聯的
UITouch
執行個體,當 touch 位置變化時,僅改變 touch 的屬性值,但不包括其
view
屬性。即使 touch 移出了 view 的範圍,
view
屬性仍然是不變的。
UITouch
的
gestureRecognizers
屬性表示正在處理該 touch 事件的所有 gesture recognizer。
UITouch
的
timestamp
屬性表示 touch 事件的發生時間或者上一次修改的時間。
UITouch
的
phase
屬性,表示 touch 事件目前所在的生命周期階段,包括
UITouchPhaseMoved
、
UITouchPhaseBegan
、
UITouchPhaseStationary
、
UITouchPhaseEnded
、
UITouchPhaseCanceled
。
2.1 碰撞檢測
UIKit 通過 hit-test 碰撞檢測确定哪些 View 需要響應 touch 事件,hit-test 通過比較 touch 的位置與 View 的 bounds 判斷 touch 是否與 View 相交。Hit-test 是在 View 的視圖層級中,取層級最深的子視圖,作為 touch 事件的 first responder,然後從前向後遞歸地對每個子視圖進行 Hit-test,直到子視圖命中,直接傳回命中的子視圖。
Hit-test 通過
UIView
的
hitTest:withEvent:
方法實作,若 touch 的位置超出了 view 的 bounds 範圍,則
hitTest:withEvent:
會忽略該 view 及其所有子視圖。是以,當 view 的
maskToBounds
為
NO
時,即使 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;
方法實作的,該方法忽略
userInteractionEnabled
為
NO
或者 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.view
的
hitTest:withEvent:
在疊代到“點我前Button”時命中了目标,是以直接傳回“點我前Button”。而更後面的“點我前Button”就直接被跳過了。
為驗證上面的推測。繼續在 Demo 中引入繼承自
UIView
的
TestEventsView
類型,套路和前面的 Button、Label 一緻,就是為了列印關鍵日志。然後将 Controller 的根視圖,也就是
self.view
的類型設定為
TestEventsView
。然後再在 Controller 的
viewDidLoad
中增加列印 Button 資訊的代碼以作對照。
準備就緒,運作 Demo,點選“點我前Button”,得到以下日志,幹擾資訊變多了,遮擋掉其中一部分。關注到紅色框中的内容,發現
self.view
的
hitTest:forEvent:
傳回的正是“點我前Button”,而且“點我前Button”的
hitTest:forEvent:
傳回了自身。與前面的推測完全符合。
步驟零:準備工作
前一小節的調試過程其實已經可以證明改結論,但是由于隻是通過對有限的相關共有方法,譬如
hitTest:forEvent:
、
nextResponder
的調用次序的列印似乎還不夠深入。接下來用 lldb 下斷點的方式,進行調試。
在這之前需要做一些準備工作,這次是使用 lldb 調試主要通過檢視函數調用棧、寄存器資料、記憶體資料等方式分析,是以不需要列印日志的操作,況且新增的
hitTest:withEvent
、
nextResponder
、
touchesXXX
方法會徒增調用棧的層數,是以将
TestEventsLabel
、
TestEventsButton
、
TestEventsView
、
ViewController
的這些方法悉數屏蔽。去掉一切不必要的日志列印邏輯。
準備就緒,運作 Demo,先不急着開始,首先檢視 Demo 的視圖層級,先記住這個
UIWindow
執行個體,它是應用的主視窗,它的記憶體位址是
0x7fa8f10036b0
,後面會用到。
注意:從 iOS 13 開始,引入了統一管理應用的視窗和螢幕,
UIWindowScene
包含
UIWindowScene
和
windows
屬性。上圖所展示
screen
隻包含了一個子 Window,實際真的如此嗎?
UIWindowScene
步驟一:下斷點
首先使用
break point -n
指令在四個關鍵方法處下斷點:
-
hitTest:withEvent:
-
nextResponder
-
touchesBegan:withEvent:
-
touchesEnded:withEvent:
注意:彙編代碼中的函數通常以、
pushq %rbp
開頭,其中
movq %rsp, %rbp
是基位址寄存器(base pointer),
bp
是堆棧寄存器(stack pointer),
sp
儲存目前函數棧幀的基位址(棧底),
bp
儲存目前函數棧幀的下一個可配置設定位址(棧頂),函數每配置設定一個單元的棧空間,
sp
自動遞增,而
sp
保持不變。相應地,函數傳回前都會有
bp
操作。
popq %rbp
步驟二:簡單分析 touch 事件在 Window 層的分發
點選“點我前Button”,很快觸發了第一個
hitTest:withEvent:
的斷點。先用
bt
指令檢視目前調用棧,發現第 0 幀調用了
UIAutoRotatingWindow
的
hitTest:withEvent:
,列印寄存器資料擷取到
r14
、
r15
都傳遞了
UIWindow
參數,但實際上調用該方法的是一個
UITextEffectsWindow
執行個體,
UITextEffectsWindow
是
UIAutoRotatingWindow
。它的記憶體位址是
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 編譯時會最大限量地合理利用起這些寄存器(通常是通用寄存器)以提高程式執行效率。通常傳遞參數用到最多的是、
r12
、
r13
、
r14
寄存器,但絕不僅限于以上列舉的幾個。這給源代碼調試增加了很大的難度。
r15
步驟三:分析 Touch 事件的産生
注意這裡的 touch 事件并不是指 UIKit 的 touch event,UIKit 的 touch event 在 UIKit 接收到來自驅動層的點選事件信号後就建構了 touch 事件的
UIEvent
對象。這裡的 touch 事件是指經過碰撞檢測确定了 touch event 的響應者從
touchesBegan:withEvent:
開始傳遞之前産生的
UITouch
對象。
1、現在正式開始追蹤 touch 事件。已知,步驟二中打斷的第一次
hitTest:withEvent:
命中,其調用對象是
UITextEffectsWindow
執行個體。此時點選調試工具欄中的“continue”按鈕,繼續執行。
注意:由于調試過程比較長,導緻繼續運作時 lldb 被打斷需要重新運作。不過問題不大,因為前面的工作已經确定了需要追蹤的關鍵對象。是以重新運作後,重新下斷點,再記錄一次關鍵對象的位址即可。
開始收集斷點命中(包括第一次命中):
-
:(Hit-Test)UITextEffectsWindow
-
:(Hit-Test)(調用 UIView 的實作)UITextEffectsWindow
-
:(Hit-Test)UIInputSetContainerView
-
:(Hit-Test)(調用 UIView 的實作)UIInputSetContainerView
-
:(Hit-Test)UIEditingOverlayGestureView
-
:(Hit-Test)(調用 UIView 的實作)UIEditingOverlayGestureView
-
:(Hit-Test)UIInputSetHostView
-
:(Hit-Test)(調用 UIView 的實作)UIInputSetHostView
-
:(Hit-Test)(調用 UIView 的實作)UIWindow
-
:(Hit-Test)UITransitionView
-
:(Hit-Test)(調用 UIView 的實作)UITransitionView
-
:(Hit-Test)UIDropShadowView
-
:(Hit-Test)(調用 UIView 的實作)UIDropShadowView
-
:(Hit-Test)(調用 UIView 的實作)TestEventsView
至此 Hit-Test 斷點命中了之前自定義的 Controller 的
TestEventsView
類型的根類,在這裡列印一下調用棧。調用棧增加至 38 層如下圖。而且上面的層次都是在調用
hitTest:withEvents
方法,這是個明顯的遞歸調用的表現。而且到此為止,Hit-Test 仍然沒有命中任何視圖。
2、繼續運作收集斷點資訊:
- {TestEventsLabel: 0x7fd8d48071a0; baseClass = UILabel; frame = (121 162; 250 166); text = 'C'; opaque = NO; autoresize = RM+BM; layer = <_uilabellayer:>}:(Hit-Test)(調用超類的實作)
- {TestEventsLabel: 0x7fd8d4806df0; baseClass = UILabel; frame = (82 116; 250 166); text = 'B'; opaque = NO; autoresize = RM+BM; layer = <_uilabellayer:>}:(Hit-Test)(調用超類的實作)
- {TestEventsLabel: 0x7fd8d4805aa0; baseClass = UILabel; frame = (44 75; 250 166); text = 'A'; opaque = NO; autoresize = RM+BM; layer = <_uilabellayer:>}:(Hit-Test)(調用超類的實作)
-
{TestEventsButton: 0x7fd8d48056c0; baseClass = UIButton; frame = (121 478; 173 79); opaque = NO; autoresize = RM+BM; layer =
}:(Hit-Test)(調用 UIControl 的實作)
Hit-Test 斷點終于命中了 Demo 的自定義 Label 和 Button 控件。根據收集的資訊,命中順序是 LabelC -> LabelB -> LabelA -> 點我前Button。此時,不急着繼續,在調試視窗中使用
bt
指令,觀察到調用棧深度已經來到了 43 層之多,如下圖所示。但是注意到一點,以上每次斷點命中,其調用棧深度都是 43 層,也就是說上面幾個同層視圖的碰撞檢測過程是循環疊代,而不是遞歸,三個
TestEventsLabel
調用
hitTest:withEvent:
都可以直接傳回
nil
不需要遞歸。
3、繼續運作收集斷點資訊:
-
:(Hit-Test)(調用 UIView 的實作)TestEventsButton
-
:(Hit-Test)(調用超類的實作)UIButtonLabel
調用棧到達了第一個高峰 49 層,如下圖一所示。此時若點選繼續,會發現調用棧回落到 13 層,如下圖二所示。說明 Hit-Test 斷點在命中
UIButtonLabel
後,本次 Hit-Test 遞歸就傳回了。至于具體傳回什麼對象,實際上在 1.2.2 的調試日志中已經列印出來了,正是“點我前Button”。
圖一:Hit-Test調用棧到達頂峰
圖二:Hit-Test調用棧回落
4、繼續運作,Demo 會進入第二次 Hit-Test 遞歸,之是以一次點選事件引發了兩輪遞歸,是因為 touch 事件在開始和結束時,各進行了一輪碰撞檢測。繼續收集斷點資訊:
-
:(Hit-Test)(調用 UIView 的實作)UIWindow
-
:(Hit-Test)UITransitionView
-
:(Hit-Test)(調用 UIView 的實作)UITransitionView
-
:(Hit-Test)UIDropShadowView
-
:(Hit-Test)(調用 UIView 的實作)UIDropShadowView
-
:(Hit-Test)(調用 UIView 的實作)TestEventsView
-
:(Hit-Test)(調用 UIView 的實作)TestEventsLabel
-
:(Hit-Test)(調用 UIView 的實作)TestEventsLabel
-
:(Hit-Test)(調用 UIView 的實作)TestEventsLabel
-
:(Hit-Test)(調用 UIControl 的實作)TestEventsButton
-
:(Hit-Test)(調用 UIView 的實作)TestEventsButton
-
:(Hit-Test)(調用 UIView 的實作)UIButtonLabel
調用棧再次到達了高峰 41 層如下圖所示。
此時先不急着繼續。因為以上是 Hit-Test 在本次調試中的最後一次斷點命中,點選繼續 Hit-Test 遞歸必然傳回“點我前Button”,表示碰撞檢測命中了該按鈕控件。第二輪 Hit-Test 的調用棧明顯淺許多,不難發現其原因是該輪碰撞檢測沒有經過
UITextEffectsWindow
而直接從
UIWindow
開始(個中原因不太确定)。
總結 Hit-Test 的處理過程的要點是:
- 優先檢測自己是否命中,不命中則直接忽略所有 subviews;
- 若自己命中,則對所有子視圖按同層級視圖順序從前向後的順序依次進行碰撞檢測,是以碰撞檢測也是 superview 到 subview 的按視圖層級從後向前遞歸的過程;
- 若所有子視圖均未命中,自己的碰撞檢測才傳回 nil。
文字表述似乎有點不太直覺,還是用咱們程式員的語言吧,僞代碼如下:
步驟四:分析 touch 事件開始後的傳遞
情況一:點選 Button 控件時
步驟三執行完成,UIKit 産生了
UITouch
事件并開始傳遞該事件。緊接在之前的基礎上繼續調試。再點選 continue,收集斷點資訊:
-
:(Touches-Began)_UISystemGestureGateGestureRecognizer
-
:(Touches-Began)_UISystemGestureGateGestureRecognizer
-
:(Touches-Began)(調用 UIControl 的實作)TestEventsButton
此時 Button 嘗試觸發 touchesBegan,開始
UITouch
事件傳遞。調用棧如下,是由 UIWindow 發送過來的 touch 事件。注意上面
TestEventsButton
調用的是UIControl 的實作,記住這個“存在某種問題或陰謀”,後面的部分會再次提到。
-
:(Next-Responder)(調用 UIView 的實作)TestEventsButton
終于命中了 Next-Responder 斷點,從上下兩個調用棧可以發現,
nextResponder
是在
touchBegan
方法内調用的。
再點選 continue,繼續運作收集斷點資訊:
-
:(Next-Responder)(調用 UIView 的實作)TestEventsView
nextResponder
是在
touchBegan
方法内調用的,且增加了調用棧深度,說明
nextResponder
也觸發了遞歸的過程。但是遞歸的不是
nextResponder
而是
UIResponder
裡面的一個私有方法
_controlTouchBegan:withEvent:
。該方法似乎隻簡單周遊了一輪響應鍊,其他的什麼都沒做。
再點選 continue,繼續運作收集斷點資訊:
-
:(Next-Responder)(調用 UIViewController 的實作)UIViewController
-
:(Next-Responder)(調用 UIView 的實作)UIDropShadowView
-
:(Next-Responder)(調用 UIView 的實作)UITransitionView
-
:(Next-Responder)UIWindow
-
:(Next-Responder)(調用 UIScene 的實作)UIWindowScene
-
:(Next-Responder)UIApplication
-
:(Next-Responder)(調用 UIResponder 的實作)AppDelegate
在
AppDelegate
層,調用棧達到頂峰,如下圖所示。
在調試過程中,發現響應鍊上除了第一響應者“點我前Button”外的所有對象都沒有調用
touchesBegan:withEvent:
響應該 touch 事件。那麼這就是對 touch 事件該有的處理麼?其實不然,由于調試時點選的是 Button 控件,是以上述是對
UIControl
控件作為第一響應者的情況的,通過定制
UIControl
類
touchesBegan:withEvent:
方法實作的,特殊處理。上面提到的私有方法
_controlTouchBegan:withEvent:
就是為了告訴後面響應鍊後面的響應者這個 touch 事件已經被前面的 UIControl 處理了,請您不要處理該事件。
那麼
UIResponder
原始的響應流程是怎樣的呢?繼續調試情況二。
情況二:點選 Label 視圖
流程漸漸明朗的情況下,可以先
breakpoint disable
終止上面的斷點,然後
breakpoint delete XXX
删除掉
hitTest:withEvent:
斷點,以減少打斷次數。解屏蔽掉之前屏蔽的列印日志的代碼,因為當斷點命中 Demo 中的自定義類時,可以直接斷定
nextResponder
的觸發類。
點選界面中的 Label C。開始收集資訊(省略自定義日志列印方法隻保留原始方法):
-
:(Touches-Began)_UISystemGestureGateGestureRecognizer
-
:(Touches-Began)_UISystemGestureGateGestureRecognizer
-
:(Touches-Began)(調用 UIResponder 的實作)TestEventsLabel
-
:(Next-Responder)(調用 UIView 的實作)TestEventsLabel
-
:(Touch-Began)(調用 UIResponder 的實作)TestEventsView
-
:(Next-Responder)(調用 UIView 的實作)TestEventsView
-
:(Touch-Began)(調用 UIResponder 的實作)UIViewController
-
:(Next-Responder)(調用 UIViewController 的實作)UIViewController
-
:(Touch-Began)(調用 UIResponder 的實作)UIDropShadowView
-
:(Next-Responder)(調用 UIView 的實作)UIDropShadowView
-
:(Touch-Began)(調用 UIResponder 的實作)UITransitionView
-
:(Next-Responder)(調用 UIView 的實作)UITransitionView
-
:(Touch-Began)(調用 UIResponder 的實作)UIWindow
-
:(Next-Responder)UIWindow
-
:(Touch-Began)(調用 UIResponder 的實作)UIWindowScene
-
:(Next-Responder)(調用 UIScene 的實作)UIWindowScene
-
:(Touch-Began)(調用 UIResponder 的實作)UIApplication
-
:(Next-Responder)UIApplication
-
:(Touch-Began)(調用 UIResponder 的實作)AppDelegate
-
:(Next-Responder)(調用 UIResponder 的實作)AppDelegate
至此先看一下調用棧,顯然
touchesBegan:withEvent:
也是遞歸的過程:
總結上面收集的資訊,
UIResponder
作為第一響應者和
UIControl
作為第一響應者的差別已經相當明顯了。當
UIResponder
作為第一響應者時,是沿着響應鍊傳遞,經過的每個對象都會觸發
touchesBegan:withEvents:
方法。
步驟五:分析 touch 事件結束後的傳遞
Touch 事件事件結束會觸發第一響應者的
touchesEnded:withEvent:
方法,具體傳遞過程和步驟四中一緻。同樣要區分
UIControl
和
UIResponder
的處理。
最後,無論是
UIControl
還是
UIResponder
,在完成所有
touchesEnded:withEvent:
處理後,都要額外再從第一響應者開始周遊一次響應鍊。從調用棧可以看到是為了傳遞
UIResponder
的
_completeForwardingTouches:phase:event
消息。具體原因不太清楚。
三、RunLoop與事件(TODO)
行文至此,文章篇幅已經有點長,是以在下一篇文章中在調試這部分内容。
四、總結
- 無論是使用
的 Target-Action 方式還是UIControl
的UIResponder
方式處理使用者事件,都涉及到 Hit-Test 和 響應鍊的内容;touchesXXX
-
使用 Target-Action 注冊使用者事件,當後面的控件被前面的控件覆寫時,若使用者事件(UIControl
)被前面的控件攔截(無論前面的控件有沒有注冊 Target-Action),則後面的控件永遠得不到處理事件的機會,即使前面的控件未注冊 Target-Action;UIEvent
-
使用 Target-Action 注冊使用者事件,指定 Target 為空時,Action 消息會沿着響應鍊傳遞,直到找到能響應 Action 的 Responder 為止,Action 一旦被其中一個 Responder 響應,響應鍊後面的對象就不再處理該 Action 消息;UIControl
- 響應鍊是以 View 為起始,向 superview 延伸的一個反向樹型結構,通過
的UIResponder
串聯而成;nextResponder
- 當 View 作為 Controller 的根 view 時,
是 Controller;nextResponder
- 當 Controller 是由其他 Controller present 而來,則
是其 present controller;nextResponder
- 當 Controller 是 Window 的根 Controller,則
是 Window,注意調試中 Controller 的nextResponder
是傳回nextResponder
,但實際上它們确實有這層關系;nil
- Window 的
是 Window Scene;nextResponder
- Window Scene 的
是 Application;nextResponder
- Application 的
是 AppDelegate(當 AppDelegate 是nextResponder
類型時);UIResponder
- Hit-Test 優先檢測自己是否命中,不命中則直接忽略所有 subviews;
- Hit-Test 若自己命中,則對所有子視圖按同層級視圖順序從前向後的順序依次進行碰撞檢測,是以碰撞檢測也是 superview 到 subview 的按視圖層級從後向前遞歸的過程;
- Hit-Test 若未命中任何子視圖,自己的碰撞檢測才傳回 nil;
- Hit-Test 命中目标後,産生
事件,UITouch
事件會沿着響應鍊傳遞到後面的所有響應者;UITouch
-
作為第一響應者響應了 touch 事件,響應鍊後面的所有響應者也會觸發UIResponder
系列方法;touchesXXX
-
控件作為第一響應者響應了 touch 事件,響應鍊後面的所有響應者均不再處理該 touch 事件;UIControl