標籤:
深入淺出iOS事件機制
2015年 04月 12日
本文章將講解有關iOS事件的傳遞機制,如有錯誤或者不同的見解,歡迎留言指出。轉載自:http://zhoon.github.io/ios/2015/04/12/ios-event.html
iOS的事件有好幾種:Touch Events(觸摸事件)、Motion Events(運動事件,比如重力感應和搖一搖等)、Remote Events(遠程事件,比如用耳機上得按鍵來控制手機),其中最常用的應該就是Touch Events了,基本存在於每個app的每個地方,今天我們主要就講講它,至於其他兩個事件有興趣的可以自行查閱資料。
在網頁上當我們講到事件,我們會講到事件響應鏈,我們會講到事件的響應者和事件的傳遞方式(冒泡),那麼在app上,其實也離不開這幾個問題,今天我們也重這幾個方面來介紹iOS的事件機制: 1、響應鏈是什麼時候怎樣構建的? 2、事件第一個響應者是怎麼確定的? 3、事件第一個響應者確定後,系統是怎樣傳遞事件的?
響應鏈的構建
無論是哪種事件,其傳遞和響應都與響應鏈息息相關,那麼響應鏈到底是一個什麼樣的東西呢? 在UIKit中有一個類:UIResponder,我們可以看看標頭檔的幾個屬性和方法:
UIResponder是所有可以響應事件的類的基類(從名字應該就可以看出來了),其中包括最常見的UIView和UIViewController甚至是UIApplication,所以我們的UIView和UIViewController都是作為響應事件的載體。
那麼響應鏈跟這個UIResponder有什麼關係呢?事實事件響應鏈的形成和事件的響應和傳遞,UIResponder都幫我們做了很多事。我們的app中,所有的視圖都是按照一定的結構組織起來的,即樹狀階層,每個view都有自己的superView,包括controller的topmost view(controller的self.view)。當一個view被add到superView上的時候,他的nextResponder屬性就會被指向它的superView,當controller被初始化的時候,self.view(topmost view)的nextResponder會被指向所在的controller,而controller的nextResponder會被指向self.view的superView,這樣,整個app就通過nextResponder串成了一條鏈,也就是我們所說的響應鏈。所以響應鏈就是一條虛擬鏈,並沒有一個對象來專門儲存這樣的一條鏈,而是通過UIResponder的屬性串聯起來的。如:
Hit-Testing View
文章開頭說到有iOS三種event類型,事件傳遞中UIWindow會根據不同的event,用不同的方式尋找initial object,initial object決定於當前的事件類型。比如Touch Event,UIWindow會首先試著把事件傳遞給事件發生的那個view,就是下文要說的hit-testview。對於Motion和Remote Event,UIWindow會把例如震動或者遠端控制的事件傳遞給當前的firstResponder,有關firstResponder的相關資訊請看這裡。下面主要講Touch Event的hit-testview。
有了事件響應鏈,接下來的事情就是尋找響應事件的具體響應者了,我們稱著為:Hit-Testing View,尋找這個View的過程我們稱著為Hit-Test。
那麼什麼是Hit-Test呢,我們可以把它理解為一個探測器,通過這個探測器我們可以找到並判斷手指是否點擊在某個視圖上面,換句話說就是通過Hit-Test可以找到手指點擊到的處於螢幕最前面的那個UIView。
在解釋Hit-Test是怎麼工作之前,先來看看它是什麼時候被調用的。前面說Hit-Test是一個探測器,那麼在代碼裡面其實就是一個函數,UIView有如下兩個方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
每當手指接觸螢幕,UIApplication接收到手指的事件之後,就會去調用UIWindow的hitTest:withEvent:,看看當前點擊的點是不是在window內,如果是則繼續依次調用subView的hitTest:withEvent:方法,直到找到最後需要的view。調用結束並且hit-test view確定之後,這個view和view上面依附的手勢,都會和一個UITouch的對象關聯起來,這個UITouch會作為事件傳遞的參數之一,我們可以看到UITouch標頭檔裡面有一個view和gestureRecognizers的屬性,就是hitTest view和它的手勢。
現在知道Hit-Test是什麼時候調用了,那麼接下來看看它是怎麼工作的。Hit-Test是採用遞迴的方法從view層級的根節點開始遍曆,看看下面這張圖:
UIWindow有一個MianVIew,MainView裡面有三個subView:view A、view B、view C,他們各自有兩個subView,他們層級關係是:view A在最下面,view B中間,view C最上(也就是addSubview的順序,越晚add進去越在上面),其中view A和view B有一部分重疊。如果手指在view B.1和view A.2重疊的上面點擊,按照上面說的遞迴方式,順序如所示:
遞迴是向介面的根節點UIWindow發送hitTest:withEvent:訊息開始的,從這個訊息返回的是一個UIView,也就是手指當前位置最前面的那個 hittest view。 當向UIWindow發送hitTest:withEvent:訊息時,hitTest:withEvent:裡面所做的事,就是判斷當前的點擊位置是否在window裡面,如果在則遍曆window的subview然後依次對subview發送hitTest:withEvent:訊息(注意這裡給subview發送訊息是根據當前subview的index順序,index越大就越先被訪問)。如果當前的point沒有在view上面,那麼這個view的subview也就不會被遍曆了。當事件遍曆到了view B.1,發現point在view B.1裡面,並且view B.1沒有subview,那麼他就是我們要找的hittest view了,找到之後就會一路返回直到根節點,而view B之後的view A也不會被遍曆了。
一圖勝千言:
注意hitTest裡面是有判斷當前的view是否支援點擊事件,比如userInteractionEnabled、hidden、alpha等屬性,都會影響一個view是否可以相應事件,如果不響應則直接返回nil。 我們留意到還有一個pointInside:withEvent:方法,這個方法跟hittest:withEvent:一樣都是UIView的一個方法,通過他開判斷point是否在view的frame範圍內。如果這些條件都滿足了,那麼遍曆就可以繼續往下走了,代碼錶現大概如下:
Hit-Test的應用一、擴大view的點擊地區
一個按鈕尺寸是10pt*10pt,如果要擴大按鈕的點擊地區(按鈕四周之外的10pt也可以響應按鈕的事件),可以怎麼做呢?或許重寫hittest:withEvent:是個好辦法,hitest就是返回可以響應事件的view,如果我們在button的子類裡面重寫它,在方法裡面判斷如果point在button的frame之外的10pt內,就返回button自己。
二、將事件傳遞給兄弟view
如上面第一個圖,如果需要是需要view A響應事件而不是B(即使點在重疊的部分),什麼都不做的話,當點擊在重疊的時候,A是不能響應事件的,除非B的userInteractionEnabled為NO並且者B沒有任何事件的響應函數。這個時候通過重寫B的hittest可以解決這個問題,在B的hittest裡面直接返回nil就行了。
三、將事件傳遞給subview
如,藍色的scrollView設定pagingEnabled使得image停止滾動後都會固定在置中的位置,如果在scrollView的左邊或者右邊活動,發現scrollView是無法滾動的,原因就是hittest裡面沒有滿足pointInSide這個條件,scrollView的bound只有藍色的地區。這個時候重寫UIView的hittest:withEvent:,然後返回scrollView即可解決問題。
事件的傳遞
有了響應鏈,並且找到了第一個響應事件的對象,接下來就是把事件發送個這個響應者了。 UIApplication中有個sendEvent:的方法,在UIWindow中同樣也可以發現一個同樣的方法。UIApplication是通過這個方法把事件發送給UIWindow,然後UIWindow通過同樣的介面,把事件發送給hit-testview。這個我們可以從Time Profiler裡面得到證實:
當我點擊了WRBuyBookButton之後,UIWindow會通過一個私人方法,在裡面會去調用按鈕的touchesBegan和touchesEnded方法,touchesBegan裡面有設定按鈕的高亮等之類的動作,這樣就實現了事件的傳遞。而事件的響應,也就是按鈕上綁定的action,是在touchEnded裡面通過調用UIApplication的sendAction:to:from:forEvent:方法來實現的,至於這個方法裡面是怎麼去響應action,就只能猜測了(可能是通過oc底層訊息機制的相關介面 objc_msgSend 來發送訊息實現的,可以參考message.h檔案)。如果第一響應者沒有響應這個事件,那麼就會根據響應鏈,把事件冒泡傳遞給nextResponder來響應。
注意這裡是怎麼把事件傳遞給nextResponder的呢?拿touch事件來說,UIResponder裡面touch四個階段的方法裡面,實際上是什麼事都沒有做的,UIView繼承了它進行重寫,重寫的內容也是沒有什麼東西,就是把事件傳遞給nextResponder,比如:[self.nextResponder touchesBegan:touches withEvent:event]。所以當一個view或者controller裡面沒有重寫touch事件,那麼這個事件就會一直傳遞下去,直到UIApplication,這也就是事件往上冒泡的原理。如果view重寫了touch方法,我們一般會看到的效果是,這個view響應了事件之後,事件就被截斷了(就像JavaScript裡面調用e.stopPropagation()),它的nextResponder不會收到這個事件,即使重寫了nextResponder的touch方法。這個時候如果想事件繼續傳遞下去,可以調用[super touchesBegan:touches withEvent:event],不建議直接調[self.nextResponder touchesBegan:touches withEvent:event]。
關於UIScrollView的事件
先說一個現象,我們平時加到UIScrollView(或者UITableView和UICollection)上面的UIButton,即使有設定highLighted的樣式,點擊的時候卻發現這個樣式老是不出來,但是按鈕的事件明明可以響應的,很詭異。
後來才知道,UIScrollView因為要滾動,所以對事件做了特殊的處理: 當UIScrollView接收到事件之後,會暫時劫持當前的事件300毫秒,如果300毫秒之後手指還沒有滾動,則認為你放棄滾動,放棄對事件的劫持並往下傳遞,但是從Time Profiler看到此時按鈕並不是調用自身的touch方法,而是調用自身綁定的手勢的touch事件,由於按鈕的highLighted樣式是寫在按鈕的touch方法上的,所以這個這個時候就看不到高亮了。但是長按按鈕缺可以讓按鈕有高亮的狀態,這個就不太清楚為什麼了,因為從Time Profiler裡面看按鈕的touchesBegan好像還是沒有被調。 如果300毫秒之內手指滾動了,則響應滾動的事件,事件就不會繼續傳給subView了,也就是不會繼續調用按鈕上手勢的touch方法了。
可以通過UIScrollView的一個屬性來解決這個問題:delaysContentTouches,意思是是否需要延遲處理事件的傳遞,預設是NO。把delaysContentTouches設定為YES之後,一切看起來挺好的,按鈕終於有高亮樣式了哈哈哈,但是發現另一個問題:如果手指點擊在按鈕上面並滾動UIScrollView,發現怎麼也滾動不了。原因是當手指點擊UIScrollView並在滾動之前,如果subView接收並且可以響應事件(delaysContentTouches設定為YES),則事件響應鏈會在subView響應事件之後就截斷,即UIScrollView本身不會響應到此事件,不會發生滾動。可以設定canCancelContentTouches為YES來讓UIScrollView可以滾動,與之類似的還有一個touchesShouldCancelInContentView:介面,可以根據參數view來更方便的判斷是否需要cancel,如果有需要可以在UIScrollView的子類裡面重寫這個介面。
這一塊裡面的具體實現原理我們都不知道,水太深了,只能通過Time Profiler來看到一些大概的實現,我們也沒必要去深究,大方向理解就好了。真的有興趣的同學也可以去研究研究,期待你的分享。
參考資料:
1、http://smnh.me/hit-testing-in-ios/
2、http://southpeak.github.io/blog/2015/03/07/uiresponder/
3、https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIScrollView_Class/index.html#//apple_ref/doc/uid/TP40006922
4、https://developer.apple.com/library/ios/documentation/EventHandling/Conceptual/EventHandlingiPhoneOS/event_delivery_responder_chain/event_delivery_responder_chain.html
5、https://developer.apple.com/library/ios/documentation/EventHandling/Conceptual/EventHandlingiPhoneOS/GestureRecognizer_basics/GestureRecognizer_basics.html#//apple_ref/doc/uid/TP40009541-CH2-SW2
6、https://developer.apple.com/library/ios/documentation/EventHandling/Conceptual/EventHandlingiPhoneOS/GestureRecognizer_basics/GestureRecognizer_basics.html#//apple_ref/doc/uid/TP40009541-CH2-SW2
ZhoonChen
深入淺出iOS事件機制