該文章屬于劉小壯原創(chuàng),轉(zhuǎn)載請注明:劉小壯

好久沒寫博客了,前后算起來剛好有一年了。這期間博客也不是一直沒變化,細(xì)心的同學(xué)應(yīng)該能發(fā)現(xiàn),我一直在回復(fù)評論區(qū)和私信的問題,還更新了好幾篇之前的博客。
去年是有意義的一年,從各個方面我也學(xué)到了不少的東西,也不局限于技術(shù)方面。很多人都寫年終總結(jié),我比較懶就不寫了,內(nèi)心做自我總結(jié)吧,哈哈??。
回歸正題,在項(xiàng)目中經(jīng)常會遇到各種手勢或者點(diǎn)擊事件處理之類的,這些都屬于響應(yīng)事件處理。但是很多人對iOS中的響應(yīng)事件處理并不清楚,經(jīng)常會遇到手勢沖突、事件不響應(yīng)之類的問題,所以就去查博客。
但是現(xiàn)在很多博客寫的并不是很完整,或者說質(zhì)量并不高,我這兩天抽時間把我所學(xué)習(xí)和理解的iOS事件處理寫出來,供各位參考。
UIResponder
UIResponder是iOS中用于處理用戶事件的API,可以處理觸摸事件、按壓事件(3D touch)、遠(yuǎn)程控制事件、硬件運(yùn)動事件。可以通過touchesBegan、pressesBegan、motionBegan、remoteControlReceivedWithEvent等方法,獲取到對應(yīng)的回調(diào)消息。UIResponder不只用來接收事件,還可以處理和傳遞對應(yīng)的事件,如果當(dāng)前響應(yīng)者不能處理,則轉(zhuǎn)發(fā)給其他合適的響應(yīng)者處理。
應(yīng)用程序通過響應(yīng)者來接收和處理事件,響應(yīng)者可以是繼承自UIResponder的任何子類,例如UIView、UIViewController、UIApplication等。當(dāng)事件來到時,系統(tǒng)會將事件傳遞給合適的響應(yīng)者,并且將其成為第一響應(yīng)者。
第一響應(yīng)者未處理的事件,將會在響應(yīng)者鏈中進(jìn)行傳遞,傳遞規(guī)則由UIResponder的nextResponder決定,可以通過重寫該屬性來決定傳遞規(guī)則。當(dāng)一個事件到來時,第一響應(yīng)者沒有接收消息,則順著響應(yīng)者鏈向后傳遞。
查找第一響應(yīng)者
基礎(chǔ)API
查找第一響應(yīng)者時,有兩個非常關(guān)鍵的API,查找第一響應(yīng)者就是通過不斷調(diào)用子視圖的這兩個API完成的。
調(diào)用方法,獲取到被點(diǎn)擊的視圖,也就是第一響應(yīng)者。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
hitTest:withEvent:方法內(nèi)部會通過調(diào)用這個方法,來判斷點(diǎn)擊區(qū)域是否在視圖上,是則返回YES,不是則返回NO。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
查找第一響應(yīng)者
應(yīng)用程序接收到事件后,將事件交給keyWindow并轉(zhuǎn)發(fā)給根視圖,根視圖按照視圖層級逐級遍歷子視圖,并且遍歷的過程中不斷判斷視圖范圍,并最終找到第一響應(yīng)者。
從keyWindow開始,向前逐級遍歷子視圖,不斷調(diào)用UIView的hitTest:withEvent:方法,通過該方法查找在點(diǎn)擊區(qū)域中的視圖后,并繼續(xù)調(diào)用返回視圖的子視圖的hitTest:withEvent:方法,以此類推。如果子視圖不在點(diǎn)擊區(qū)域或沒有子視圖,則當(dāng)前視圖就是第一響應(yīng)者。
在hitTest:withEvent:方法中,會從上到下遍歷子視圖,并調(diào)用subViews的pointInside:withEvent:方法,來找到點(diǎn)擊區(qū)域內(nèi)且最上面的子視圖。如果找到子視圖則調(diào)用其hitTest:withEvent:方法,并繼續(xù)執(zhí)行這個流程,以此類推。如果子視圖不在點(diǎn)擊區(qū)域內(nèi),則忽略這個視圖及其子視圖,繼續(xù)遍歷其他視圖。
可以通過重寫對應(yīng)的方法,控制這個遍歷過程。通過重寫pointInside:withEvent:方法,來做自己的判斷并返回YES或NO,返回點(diǎn)擊區(qū)域是否在視圖上。通過重寫hitTest:withEvent:方法,返回被點(diǎn)擊的視圖。
此方法在遍歷視圖時,忽略以下三種情況的視圖,如果視圖具有以下特征則忽略。但是視圖的背景顏色是clearColor,并不在忽略范圍內(nèi)。
- 視圖的
hidden等于YES。 - 視圖的
alpha小于等于0.01。 - 視圖的
userInteractionEnabled為NO。
如果點(diǎn)擊事件是發(fā)生在視圖外,但在其子視圖內(nèi)部,子視圖也不能接收事件并成為第一響應(yīng)者。這是因?yàn)樵谄涓敢晥D進(jìn)行hitTest:withEvent:的過程中,就會將其忽略掉。
事件傳遞
傳遞過程
-
UIApplication接收到事件,將事件傳遞給keyWindow。 -
keyWindow遍歷subViews的hitTest:withEvent:方法,找到點(diǎn)擊區(qū)域內(nèi)合適的視圖來處理事件。 -
UIView的子視圖也會遍歷其subViews的hitTest:withEvent:方法,以此類推。 - 直到找到點(diǎn)擊區(qū)域內(nèi),且處于最上方的視圖,將視圖逐步返回給
UIApplication。 - 在查找第一響應(yīng)者的過程中,已經(jīng)形成了一個響應(yīng)者鏈。
- 應(yīng)用程序會先調(diào)用第一響應(yīng)者處理事件。
- 如果第一響應(yīng)者不能處理事件,則調(diào)用其
nextResponder方法,一直找響應(yīng)者鏈中能處理該事件的對象。 - 最后到
UIApplication后仍然沒有能處理該事件的對象,則該事件被廢棄。
模擬代碼
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (self.alpha <= 0.01 || self.userInteractionEnabled == NO || self.hidden) {
return nil;
}
BOOL inside = [self pointInside:point withEvent:event];
if (inside) {
NSArray *subViews = self.subviews;
// 對子視圖從上向下找
for (NSInteger i = subViews.count - 1; i >= 0; i--) {
UIView *subView = subViews[i];
CGPoint insidePoint = [self convertPoint:point toView:subView];
UIView *hitView = [subView hitTest:insidePoint withEvent:event];
if (hitView) {
return hitView;
}
}
return self;
}
return nil;
}
示例

如上圖所示,響應(yīng)者鏈如下:
- 如果點(diǎn)擊
UITextField后其會成為第一響應(yīng)者。 - 如果
textField未處理事件,則會將事件傳遞給下一級響應(yīng)者鏈,也就是其父視圖。 - 父視圖未處理事件則繼續(xù)向下傳遞,也就是
UIViewController的View。 - 如果控制器的
View未處理事件,則會交給控制器處理。 - 控制器未處理則會交給
UIWindow。 - 然后會交給
UIApplication。 - 最后交給
UIApplicationDelegate,如果其未處理則丟棄事件。
事件通過UITouch進(jìn)行傳遞,在事件到來時,第一響應(yīng)者會分配對應(yīng)的UITouch,UITouch會一直跟隨著第一響應(yīng)者,并且根據(jù)當(dāng)前事件的變化UITouch也會變化,當(dāng)事件結(jié)束后則UITouch被釋放。
UIViewController沒有hitTest:withEvent:方法,所以控制器不參與查找響應(yīng)視圖的過程。但是控制器在響應(yīng)者鏈中,如果控制器的View不處理事件,會交給控制器來處理??刂破鞑惶幚淼脑?,再交給View的下一級響應(yīng)者處理。
注意
- 在執(zhí)行
hitTest:withEvent:方法時,如果該視圖是hidden等于NO的那三種被忽略的情況,則改視圖返回nil。 - 如果當(dāng)前視圖在響應(yīng)者鏈中,但其沒有處理事件,則不考慮其兄弟視圖,即使其兄弟視圖和其都在點(diǎn)擊范圍內(nèi)。
-
UIImageView的userInteractionEnabled默認(rèn)為NO,如果想要UIImageView響應(yīng)交互事件,將屬性設(shè)置為YES即可響應(yīng)事件。
事件控制
事件攔截
有時候想讓指定視圖來響應(yīng)事件,不再向其子視圖繼續(xù)傳遞事件,可以通過重寫hitTest:withEvent:方法。在執(zhí)行到方法后,直接將該視圖返回,而不再繼續(xù)遍歷子視圖,這樣響應(yīng)者鏈的終端就是當(dāng)前視圖。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
return self;
}
事件轉(zhuǎn)發(fā)
在開發(fā)過程中,經(jīng)常會遇到子視圖顯示范圍超出父視圖的情況,這時候可以重寫該視圖的pointInside:withEvent:方法,將點(diǎn)擊區(qū)域擴(kuò)大到能夠覆蓋所有子視圖。

假設(shè)有上面的視圖結(jié)構(gòu),SuperView的Subview超出了其視圖范圍,如果點(diǎn)擊Subview在父視圖外面的部分,則不能響應(yīng)事件。所以通過重寫pointInside:withEvent:方法,將響應(yīng)區(qū)域擴(kuò)大為虛線區(qū)域,包含SuperView的所有子視圖,即可讓子視圖響應(yīng)事件。
事件逐級傳遞
如果想讓響應(yīng)者鏈中,每一級UIResponder都可以響應(yīng)事件,可以在每級UIResponder中都實(shí)現(xiàn)touches并調(diào)用super方法,即可實(shí)現(xiàn)響應(yīng)者鏈?zhǔn)录鸺墏鬟f。
只不過這并不包含UIControl子類以及UIGestureRecognizer的子類,這兩類會直接打斷響應(yīng)者鏈。
Gesture Recognizer
如果有事件到來時,視圖有附加的手勢識別器,則手勢識別器優(yōu)先處理事件。如果手勢識別器沒有處理事件,則將事件交給視圖處理,視圖如果未處理則順著響應(yīng)者鏈繼續(xù)向后傳遞。

當(dāng)響應(yīng)者鏈和手勢同時出現(xiàn)時,也就是既實(shí)現(xiàn)了touches方法又添加了手勢,會發(fā)現(xiàn)touches方法有時會失效,這是因?yàn)槭謩莸膱?zhí)行優(yōu)先級是高于響應(yīng)者鏈的。
事件到來后先會執(zhí)行hitTest和pointInside操作,通過這兩個方法找到第一響應(yīng)者,這個在上面已經(jīng)詳細(xì)講過了。當(dāng)找到第一響應(yīng)者并將其返回給UIApplication后,UIApplication會向第一響應(yīng)者派發(fā)事件,并且遍歷整個響應(yīng)者鏈。如果響應(yīng)者鏈中能夠處理當(dāng)前事件的手勢,則將事件交給手勢處理,并調(diào)用touches的cancelled方法將響應(yīng)者鏈取消。
在UIApplication向第一響應(yīng)者派發(fā)事件,并且遍歷響應(yīng)者鏈查找手勢時,會開始執(zhí)行響應(yīng)者鏈中的touches系列方法。會先執(zhí)行touchesBegan和touchesMoved方法,如果響應(yīng)者鏈能夠繼續(xù)響應(yīng)事件,則執(zhí)行touchesEnded方法表示事件完成,如果將事件交給手勢處理則調(diào)用touchesCancelled方法將響應(yīng)者鏈打斷。
根據(jù)蘋果的官方文檔,手勢不參與響應(yīng)者鏈傳遞事件,但是也通過hitTest的方式查找響應(yīng)的視圖,手勢和響應(yīng)者鏈一樣都需要通過hitTest方法來確定響應(yīng)者鏈的。在UIApplication向響應(yīng)者鏈派發(fā)消息時,只要響應(yīng)者鏈中存在能夠處理事件的手勢,則手勢響應(yīng)事件,如果手勢不在響應(yīng)者鏈中則不能處理事件。
Apple UIGestureRecognizer Documentation
UIControl
根據(jù)上面的手勢和響應(yīng)者鏈的處理規(guī)則,我們會發(fā)現(xiàn)UIButton或者UISlider等控件,并不符合這個處理規(guī)則。UIButton可以在其父視圖已經(jīng)添加tapGestureRecognizer的情況下,依然正常響應(yīng)事件,并且tap手勢不響應(yīng)。

以UIButton為例,UIButton也是通過hitTest的方式查找第一響應(yīng)者的。區(qū)別在于,如果UIButton是第一響應(yīng)者,則直接由UIApplication派發(fā)事件,不通過Responder Chain派發(fā)。如果其不能處理事件,則交給手勢處理或響應(yīng)者鏈傳遞。
不只UIButton是直接由UIApplication派發(fā)事件的,所有繼承自UIControl的類,都是由UIApplication直接派發(fā)事件的。
事件傳遞優(yōu)先級
測試
為了有依據(jù)的推斷響應(yīng)事件的實(shí)現(xiàn)和傳遞機(jī)制,我們做以下測試。
示例1

假設(shè)RootView、SuperView、Button都實(shí)現(xiàn)touches方法,并且Button添加buttonAction:的action,點(diǎn)擊button后的調(diào)用如下。
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Button -> hitTest:withEvent:
Button -> pointInside:withEvent:
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
Button -> touchesBegan:withEvent:
Button -> touchesEnded:withEvent:
Button -> buttonAction:
示例2
還是上面的視圖結(jié)構(gòu),我們給RootView加上UITapGestureRecognizer手勢,并且通過tapAction:方法接收回調(diào),點(diǎn)擊上面的SuperView后,方法調(diào)用如下。
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Button -> hitTest:withEvent:
Button -> pointInside:withEvent:
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
RootView -> gestureRecognizer:shouldReceivePress:
RootView -> gestureRecognizer:shouldBeRequiredToFailByGestureRecognizer:
SuperView -> touchesBegan:withEvent:
RootView -> gestureRecognizerShouldBegin:
RootView -> tapAction:
SuperView -> touchesCancelled:
示例3

上面的視圖中Subview1、Subview2、Subview3是同級視圖,都是SuperView的子視圖。我們給Subview1加上UITapGestureRecognizer手勢,并且通過subView1Action:方法接收回調(diào),點(diǎn)擊上面的Subview3后,方法調(diào)用如下。
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Subview3 -> hitTest:withEvent:
Subview3 -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Subview3 -> touchesBegan:withEvent:
Subview3 -> touchesEnded:withEvent:
通過上面的例子來看,雖然Subview1在Subview3的下面,并且添加了手勢,點(diǎn)擊區(qū)域是在Subview1和Subview3兩個視圖上的。但是由于經(jīng)過hitTest和pointInside之后,響應(yīng)者鏈中并沒有Subview1,所以Subview1的手勢并沒有被響應(yīng)。
分析
根據(jù)我們上面的測試,推斷iOS響應(yīng)事件的優(yōu)先級,以及整體的響應(yīng)邏輯。
當(dāng)事件到來時,會通過hitTest和pointInside兩個方法,從Window開始向上面的視圖查找,找到第一響應(yīng)者的視圖。找到第一響應(yīng)者后,系統(tǒng)會判斷其是繼承自UIControl還是UIResponder,如果是繼承自UIControl,則直接通過UIApplication直接向其派發(fā)消息,并且不再向響應(yīng)者鏈派發(fā)消息。
如果是繼承自UIResponder的類,則調(diào)用第一響應(yīng)者的touchesBegin,并且不會立即執(zhí)行touchesEnded,而是調(diào)用之后順著響應(yīng)者鏈向后查找。如果在查找過程中,發(fā)現(xiàn)響應(yīng)者鏈中有的視圖添加了手勢,則進(jìn)入手勢的代理方法中,如果代理方法返回可以響應(yīng)這個事件,則將第一響應(yīng)者的事件取消,并調(diào)用其touchesCanceled方法,然后由手勢來響應(yīng)事件。
如果手勢不能處理事件,則交給第一響應(yīng)者來處理。如果第一響應(yīng)者也不能響應(yīng)事件,則順著響應(yīng)者鏈繼續(xù)向后查找,直到找到能夠處理事件的UIResponder對象。如果找到UIApplication還沒有對象響應(yīng)事件的話,則將這次事件丟棄。
接收事件深度剖析
在UIApplication接收到響應(yīng)事件之前,還有更復(fù)雜的系統(tǒng)級的處理,處理流程大致如下。
系統(tǒng)通過
IOKit.framework來處理硬件操作,其中屏幕處理也通過IOKit完成(IOKit可能是注冊監(jiān)聽了屏幕輸出的端口)
當(dāng)用戶操作屏幕,IOKit收到屏幕操作,會將這次操作封裝為IOHIDEvent對象。通過mach port(IPC進(jìn)程間通信)將事件轉(zhuǎn)發(fā)給SpringBoard來處理。SpringBoard是iOS系統(tǒng)的桌面程序。SpringBoard收到mach port發(fā)過來的事件,喚醒main runloop來處理。
main runloop將事件交給source1處理,source1會調(diào)用__IOHIDEventSystemClientQueueCallback()函數(shù)。函數(shù)內(nèi)部會判斷,是否有程序在前臺顯示,如果有則通過
mach port將IOHIDEvent事件轉(zhuǎn)發(fā)給這個程序。
如果前臺沒有程序在顯示,則表明SpringBoard的桌面程序在前臺顯示,也就是用戶在桌面進(jìn)行了操作。
__IOHIDEventSystemClientQueueCallback()函數(shù)會將事件交給source0處理,source0會調(diào)用__UIApplicationHandleEventQueue()函數(shù),函數(shù)內(nèi)部會做具體的處理操作。例如用戶點(diǎn)擊了某個應(yīng)用程序的icon,會將這個程序啟動。
應(yīng)用程序接收到SpringBoard傳來的消息,會喚醒main runloop并將這個消息交給source1處理,source1調(diào)用__IOHIDEventSystemClientQueueCallback()函數(shù),在函數(shù)內(nèi)部會將事件交給source0處理,并調(diào)用source0的__UIApplicationHandleEventQueue()函數(shù)。
在__UIApplicationHandleEventQueue()函數(shù)中,會將傳遞過來的IOHIDEvent轉(zhuǎn)換為UIEvent對象。在函數(shù)內(nèi)部,調(diào)用
UIApplication的sendEvent:方法,將UIEvent傳遞給第一響應(yīng)者或UIControl對象處理,在UIEvent內(nèi)部包含若干個UITouch對象。
Tips
source1是runloop用來處理mach port傳來的系統(tǒng)事件的,source0是用來處理用戶事件的。
source1收到系統(tǒng)事件后,都會調(diào)用source0的函數(shù),所以最終這些事件都是由source0處理的。
小技巧
在開發(fā)中,有時會有找到當(dāng)前View對應(yīng)的控制器的需求,這時候就可以利用我們上面所學(xué),根據(jù)響應(yīng)者鏈來找到最近的控制器。
在UIResponder中提供了nextResponder方法,通過這個方法可以找到當(dāng)前響應(yīng)環(huán)節(jié)的上一級響應(yīng)對象??梢詮漠?dāng)前UIView開始不斷調(diào)用nextResponder,查找上一級響應(yīng)者鏈的對象,就可以找到離自己最近的UIViewController。
示例代碼:
- (UIViewController *)parentController {
UIResponder *responder = [self nextResponder];
while (responder) {
if ([responder isKindOfClass:[UIViewController class]]) {
return (UIViewController *)responder;
}
responder = [responder nextResponder];
}
return nil;
}