iOS事件處理分析

該文章屬于劉小壯原創(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、motionBeganremoteControlReceivedWithEvent等方法,獲取到對應(yīng)的回調(diào)消息。UIResponder不只用來接收事件,還可以處理和傳遞對應(yīng)的事件,如果當(dāng)前響應(yīng)者不能處理,則轉(zhuǎn)發(fā)給其他合適的響應(yīng)者處理。

應(yīng)用程序通過響應(yīng)者來接收和處理事件,響應(yīng)者可以是繼承自UIResponder的任何子類,例如UIViewUIViewController、UIApplication等。當(dāng)事件來到時,系統(tǒng)會將事件傳遞給合適的響應(yīng)者,并且將其成為第一響應(yīng)者。

第一響應(yīng)者未處理的事件,將會在響應(yīng)者鏈中進(jìn)行傳遞,傳遞規(guī)則由UIRespondernextResponder決定,可以通過重寫該屬性來決定傳遞規(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)用UIViewhitTest:withEvent:方法,通過該方法查找在點(diǎn)擊區(qū)域中的視圖后,并繼續(xù)調(diào)用返回視圖的子視圖的hitTest:withEvent:方法,以此類推。如果子視圖不在點(diǎn)擊區(qū)域或沒有子視圖,則當(dāng)前視圖就是第一響應(yīng)者。

hitTest:withEvent:方法中,會從上到下遍歷子視圖,并調(diào)用subViewspointInside:withEvent:方法,來找到點(diǎn)擊區(qū)域內(nèi)且最上面的子視圖。如果找到子視圖則調(diào)用其hitTest:withEvent:方法,并繼續(xù)執(zhí)行這個流程,以此類推。如果子視圖不在點(diǎn)擊區(qū)域內(nèi),則忽略這個視圖及其子視圖,繼續(xù)遍歷其他視圖。

可以通過重寫對應(yīng)的方法,控制這個遍歷過程。通過重寫pointInside:withEvent:方法,來做自己的判斷并返回YESNO,返回點(diǎn)擊區(qū)域是否在視圖上。通過重寫hitTest:withEvent:方法,返回被點(diǎn)擊的視圖。

此方法在遍歷視圖時,忽略以下三種情況的視圖,如果視圖具有以下特征則忽略。但是視圖的背景顏色是clearColor,并不在忽略范圍內(nèi)。

  1. 視圖的hidden等于YES。
  2. 視圖的alpha小于等于0.01。
  3. 視圖的userInteractionEnabled為NO。

如果點(diǎn)擊事件是發(fā)生在視圖外,但在其子視圖內(nèi)部,子視圖也不能接收事件并成為第一響應(yīng)者。這是因?yàn)樵谄涓敢晥D進(jìn)行hitTest:withEvent:的過程中,就會將其忽略掉。

事件傳遞

傳遞過程

  1. UIApplication接收到事件,將事件傳遞給keyWindow
  2. keyWindow遍歷subViewshitTest:withEvent:方法,找到點(diǎn)擊區(qū)域內(nèi)合適的視圖來處理事件。
  3. UIView的子視圖也會遍歷其subViewshitTest:withEvent:方法,以此類推。
  4. 直到找到點(diǎn)擊區(qū)域內(nèi),且處于最上方的視圖,將視圖逐步返回給UIApplication。
  5. 在查找第一響應(yīng)者的過程中,已經(jīng)形成了一個響應(yīng)者鏈。
  6. 應(yīng)用程序會先調(diào)用第一響應(yīng)者處理事件。
  7. 如果第一響應(yīng)者不能處理事件,則調(diào)用其nextResponder方法,一直找響應(yīng)者鏈中能處理該事件的對象。
  8. 最后到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)者鏈如下:

  1. 如果點(diǎn)擊UITextField后其會成為第一響應(yīng)者。
  2. 如果textField未處理事件,則會將事件傳遞給下一級響應(yīng)者鏈,也就是其父視圖。
  3. 父視圖未處理事件則繼續(xù)向下傳遞,也就是UIViewControllerView。
  4. 如果控制器的View未處理事件,則會交給控制器處理。
  5. 控制器未處理則會交給UIWindow
  6. 然后會交給UIApplication。
  7. 最后交給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)者處理。

注意

  1. 在執(zhí)行hitTest:withEvent:方法時,如果該視圖是hidden等于NO的那三種被忽略的情況,則改視圖返回nil。
  2. 如果當(dāng)前視圖在響應(yīng)者鏈中,但其沒有處理事件,則不考慮其兄弟視圖,即使其兄弟視圖和其都在點(diǎn)擊范圍內(nèi)。
  3. UIImageViewuserInteractionEnabled默認(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ò)大到能夠覆蓋所有子視圖。

擴(kuò)大響應(yīng)區(qū)域

假設(shè)有上面的視圖結(jié)構(gòu),SuperViewSubview超出了其視圖范圍,如果點(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í)行hitTestpointInside操作,通過這兩個方法找到第一響應(yīng)者,這個在上面已經(jīng)詳細(xì)講過了。當(dāng)找到第一響應(yīng)者并將其返回給UIApplication后,UIApplication會向第一響應(yīng)者派發(fā)事件,并且遍歷整個響應(yīng)者鏈。如果響應(yīng)者鏈中能夠處理當(dāng)前事件的手勢,則將事件交給手勢處理,并調(diào)用touchescancelled方法將響應(yīng)者鏈取消。

UIApplication向第一響應(yīng)者派發(fā)事件,并且遍歷響應(yīng)者鏈查找手勢時,會開始執(zhí)行響應(yīng)者鏈中的touches系列方法。會先執(zhí)行touchesBegantouchesMoved方法,如果響應(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)。

UIControl

UIButton為例,UIButton也是通過hitTest的方式查找第一響應(yīng)者的。區(qū)別在于,如果UIButton是第一響應(yīng)者,則直接由UIApplication派發(fā)事件,不通過Responder Chain派發(fā)。如果其不能處理事件,則交給手勢處理或響應(yīng)者鏈傳遞。

不只UIButton是直接由UIApplication派發(fā)事件的,所有繼承自UIControl的類,都是由UIApplication直接派發(fā)事件的。

Apple UIControl Documentation

事件傳遞優(yōu)先級

測試

為了有依據(jù)的推斷響應(yīng)事件的實(shí)現(xiàn)和傳遞機(jī)制,我們做以下測試。

示例1
示例1

假設(shè)RootView、SuperViewButton都實(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
示例3

上面的視圖中Subview1、Subview2Subview3是同級視圖,都是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:

通過上面的例子來看,雖然Subview1Subview3的下面,并且添加了手勢,點(diǎn)擊區(qū)域是在Subview1Subview3兩個視圖上的。但是由于經(jīng)過hitTestpointInside之后,響應(yīng)者鏈中并沒有Subview1,所以Subview1的手勢并沒有被響應(yīng)。

分析

根據(jù)我們上面的測試,推斷iOS響應(yīng)事件的優(yōu)先級,以及整體的響應(yīng)邏輯。

當(dāng)事件到來時,會通過hitTestpointInside兩個方法,從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)級的處理,處理流程大致如下。

  1. 系統(tǒng)通過IOKit.framework來處理硬件操作,其中屏幕處理也通過IOKit完成(IOKit可能是注冊監(jiān)聽了屏幕輸出的端口)
    當(dāng)用戶操作屏幕,IOKit收到屏幕操作,會將這次操作封裝為IOHIDEvent對象。通過mach port(IPC進(jìn)程間通信)將事件轉(zhuǎn)發(fā)給SpringBoard來處理。

  2. SpringBoard是iOS系統(tǒng)的桌面程序。SpringBoard收到mach port發(fā)過來的事件,喚醒main runloop來處理。
    main runloop將事件交給source1處理,source1會調(diào)用__IOHIDEventSystemClientQueueCallback()函數(shù)。

  3. 函數(shù)內(nèi)部會判斷,是否有程序在前臺顯示,如果有則通過mach portIOHIDEvent事件轉(zhuǎn)發(fā)給這個程序。
    如果前臺沒有程序在顯示,則表明SpringBoard的桌面程序在前臺顯示,也就是用戶在桌面進(jìn)行了操作。
    __IOHIDEventSystemClientQueueCallback()函數(shù)會將事件交給source0處理,source0會調(diào)用__UIApplicationHandleEventQueue()函數(shù),函數(shù)內(nèi)部會做具體的處理操作。

  4. 例如用戶點(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對象。

  5. 在函數(shù)內(nèi)部,調(diào)用UIApplicationsendEvent:方法,將UIEvent傳遞給第一響應(yīng)者或UIControl對象處理,在UIEvent內(nèi)部包含若干個UITouch對象。

Tips

source1runloop用來處理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;
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容