iOS 事件處理機制與圖像渲染過程

首先我們從runloop層面上來剖析下事件的產生和傳遞:
RunLoop主要處理以下6類事件:

static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();

Observer事件:runloop中狀態變化時進行通知。(微信卡頓監控就是利用這個事件通知來記錄下最近一次main runloop活動時間,在另一個check線程中用定時器檢測當前時間距離最后一次活動時間過久來判斷在主線程中的處理邏輯耗時和卡主線程)。這里還需要特別注意,CAAnimation是由RunloopObserver觸發回調來重繪,接下來會講到。
Block事件:非延遲的NSObject PerformSelector立即調用,dispatch_after立即調用,block回調。
Main_Dispatch_Queue事件:GCD中dispatch到main queue的block會被dispatch到main loop執行。
Timer事件:延遲的NSObject PerformSelector,延遲的dispatch_after,timer事件。
Source0事件:處理如UIEvent,CFSocket這類事件。需要手動觸發。觸摸事件其實是Source1接收系統事件后在回調 __IOHIDEventSystemClientQueueCallback() 內觸發的 Source0,Source0 再觸發的 _UIApplicationHandleEventQueue()。source0一定是要喚醒runloop及時響應并執行的,如果runloop此時在休眠等待系統的 mach_msg事件,那么就會通過source1來喚醒runloop執行。
Source1事件:處理系統內核的mach_msg事件。(推測CADisplayLink也是這里觸發)。

來一段微信工程師提供的關于Runloop執行順序的偽代碼:

SetupThisRunLoopRunTimeoutTimer(); // by GCD timer
//通知即將進入runloop__CFRUNLLOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(KCFRunLoopEntry);
do {
     __CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
     __CFRunLoopDoObservers(kCFRunLoopBeforeSources);

     __CFRunLoopDoBlocks();  //!!!:一個循環中會調用兩次,確保非延遲的NSObject PerformSelector調用和非延遲的dispatch_after調用在當前runloop執行。還有回調block
     __CFRunLoopDoSource0(); //例如UIKit處理的UIEvent事件

     CheckIfExistMessagesInMainDispatchQueue(); //GCD dispatch main queue

     __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting); //即將進入休眠,會重繪一次界面
     var wakeUpPort = SleepAndWaitForWakingUpPorts();
     // mach_msg_trap,陷入內核等待匹配的內核mach_msg事件
     // Zzz...
     // Received mach_msg, wake up
     __CFRunLoopDoObservers(kCFRunLoopAfterWaiting);
     // 處理消息,從代碼中也可以看出,喚醒runloop的三種方式。
     if (wakeUpPort == timerPort) { //方式1: timer源喚醒
          __CFRunLoopDoTimers();
     }
     else if (wakeUpPort == mainDispatchQueuePort) {//方式二:主線程隊列任務喚醒
          //GCD當調用dispatch_async(dispatch_get_main_queue(),block)時,
libDispatch會向主線程的runloop發送mach_msg消息喚醒runloop,
并在這里執行。這里僅限于執行dispatch到主線程的任務,
dispatch到其他線程的仍然是libDispatch來處理。
          __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
     }
     else { //方式三:source1喚醒
          __CFRunLoopDoSource1();  //CADisplayLink是source1的mach_msg觸發?
     }
     __CFRunLoopDoBlocks(); //!!!:這里又執行一次do blocks操作
} while (!stop && !timeout);

//通知observers,即將退出runloop
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBERVER_CALLBACK_FUNCTION__(CFRunLoopExit);

同樣還有一段代碼示例:

dispatch_async(dispatch_get_main_queue(), ^{
    _isReloadDone = NO;
    [tableView reload]; //會自動設置tableView layoutIfNeeded為YES,意味著將會在runloop結束時重繪table
    dispatch_async(dispatch_get_main_queue(),^{
        _isReloadDone = YES;
    });
});

剛看到代碼會有疑問,_isReloadDone = YES; 異步方式執行,還能準確標識tableview加載完成嗎?

分析:
(1)使用dispatch_async(dispatch_get_mainQueue,{})時,libdispatch會發送一個mach_msg消息給主線程runloop,從而喚醒runloop執行main queue中的任務。
(2)這時在block中又嵌套執行了一次dispatch_async(dispatch_get_mainQueue,{})操作,此時新的任務即使添加到主線程的queue中,也不會執行,需要等到下一次處理main queue時才回去處理執行。
(3)對照上面的runloop運行周期我們可以知道,runloop會在進入睡眠狀態前(beforeWaitting前)處理main queue當前的任務。 所以這時代碼中的_isReloadDone = YES;操作會被執行。也就是:在一次runloop中會執行兩次處理main queue 中的當前的任務操作。

1、事件傳遞

事件傳遞:

1、觸摸事件發生后,系統會將該事件加入到一個有UIApplication管理的隊列事件中。
2、UIApplication 會從事件隊列中取出最前面的事件,并將事件分發下去。一般先分發給UIWindow。
3、UIWindow會在當前的視圖層次中傳遞事件并找到合適的view。

3.1、事件在視圖層次中傳遞過程以下圖為例說明:

事件傳遞示意圖.jpg

視圖的添加順序:白1->綠2->橙2->藍3->紅3->黃4

點擊橙2,事件傳遞過程:
1、UIApplication 從事件隊列中取出事件分發給 UIWindow。
2、UIWindow判斷是否能響應事件。( 調用hitTest: withEvent:來判斷是否能響應,UIView在三種情況下不能響應事件,后續給出)。
3、UIWindow判斷觸摸點是否在自己身上(調用pointInside: withEvent:)。如果在繼續下一步。
4、UIWindow從后往前遍歷自己的子控件 (從后往前遍歷的目的是優化查詢,一般觸摸事件,都是由最上層的控件來響應),取出白1。
5、白1重復2、3步,因為觸摸點在白1范圍內,所以遍歷其子視圖,取出橙2。
6、橙2重復2、3步,因為觸摸點在橙2范圍內,所以遍歷其子視圖。取出紅3。
7、紅3重復2、3步,發現觸摸點不在其范圍內,取出藍3。
8、藍3也不滿足條件。最后確認需要來響應的控件為父控件橙2。

3.2 、這里需要說一下,view在判斷自身或者子view能否響應事件時,底層是如何實現的:

底層在判斷的過程中,共用到了兩個重要的方法:
1、hitTest: withEvent:

作用:尋找并返回能夠響應事件的view
需要注意的是:不管該view是否能處理時間,觸摸點是否在這個view上,該view的這個方法都會被調用。

hitTest: withEvent:函數實現流程圖
下圖已經描述的十分清楚,故不再重畫(十分感謝qunaer團隊的分享,大家有興趣可以直接參考qunaer團隊博客,稍后會在文末附上。)

流程圖.jpg

函數實現代碼也描述的很清晰:

hitTest: withEvent:底層具體實現如下 :
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1.判斷當前控件能否接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    // 2. 判斷點在不在當前控件
    if ([self pointInside:point withEvent:event] == NO) return nil;
    // 3.從后往前遍歷自己的子控件
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--) {
        UIView *childView = self.subviews[i];
        // 把當前控件上的坐標系轉換成子控件上的坐標系
        CGPoint childP = [self convertPoint:point toView:childView];
        UIView *fitView = [childView hitTest:childP withEvent:event];
        if (fitView) { // 尋找到最合適的view
            return fitView;
        }
    }
    // 循環結束,表示沒有比自己更合適的view
    return self;
}

視圖不響應事件的情況

經過上面對hitTest:函數實現的解析,想必大家已經明確看出視圖不響應事件的三個條件:

1、view.userInteractionEnabled == NO
2、view.alpha <0.01 (alpha級別小宇0.01,視圖也不會響應事件)
3、view.hidden = YES

為了保證我們的視圖能夠響應事件,需要注意避開這三個條件。

2、pointInside: withEvent:

作用:判斷一個觸摸point是否在view范圍內。

同樣的這個函數也比較簡單,我們不去做過多分析。

2、事件響應:

事件經過傳遞,找到能夠響應的View后,會調用view的Touches方法。

響應者對象:能夠響應事件的對象,確切點說就是繼承于UIResponder類的實例。UIApplication、UIViewController、UIView、UIWindow等都繼承于該類。因此這些類的實例都屬于響應者對象。都能夠響應處理事件。

響應者鏈條:所謂響應者鏈條是指:由眾多響應者對象按照響應順序組合起來的鏈條。

響應過程

當一個View不能響應某個事件時,默認會將該事件向上傳遞給其父控件(或控制器),如果父控件不能響應,則繼續往上傳,直到上傳至UIApplication,如果UIApplication也不能響應事件,該事件就放棄處理。

在事件向上傳遞的過程中:
如果視圖為某一控制器的rootView,則view向上傳遞時,上一個響應者就是控制器。反之,上一響應者為該視圖的父視圖。

響應鏈條

UIApplication–>UIWindow–>遞歸找到最合適處理的控件–>控件調用touches方法–>判斷是否實現touches方法–>沒有實現默認會將事件傳遞給上一個響應者–>找到上一個響應者–>找不到方法作廢

注意:事件傳遞和事件響應兩者是不同的概念,不要搞混。
事件傳遞是從UIApplication開始往下直到找到響應的控件。
事件響應是前者找到的控件開始,找不到向上傳遞。

事件響應的底層機制

1、一個觸摸事件從操作系統層傳送到應用內的main runloop中的簡單過程:

RunLoop是一個接收處理異步消息事件的循環,一個循環中:等待事件發生,然后將這個事件送到能處理它的地方。

觸摸事件傳遞到runloop的過程.jpeg

2、UIEvent事件的形成:
1)鏈式過程:硬件事件產生后 -> IOKit.framework創建一個IOHIDEvent -> 交給UIApplication管理的Queue ->這個queue會把IOHIDEvent包裝成UIEvent并進行分發。
2)這個時間傳給App,是通過mach_port傳遞過來的,并喚醒runloop,同時這個source1的回調就會被執行。

這里有一份更為專業的解釋:

蘋果注冊了一個 Source1 (基于 mach port 的) 用來接收系統事件,其回調函數為 __IOHIDEventSystemClientQueueCallback()。
當一個硬件事件(觸摸/鎖屏/搖晃等)發生后,首先由 IOKit.framework 生成一個 IOHIDEvent 事件并由 SpringBoard 接收。
SpringBoard 只接收按鍵(鎖屏/靜音等),觸摸,加速,接近傳感器等幾種 Event,隨后用 mach port 轉發給需要的App進程。隨后蘋果注冊的那個 Source1 就會觸發回調,并調用 _UIApplicationHandleEventQueue() 進行應用內部的分發。
_UIApplicationHandleEventQueue() 會把 IOHIDEvent 處理并包裝成 UIEvent 進行處理或分發,其中包括識別 UIGesture/處理屏幕旋轉/發送給 UIWindow 等。通常事件比如 UIButton 點擊、touchesBegin/Move/End/Cancel 事件都是在這個回調中完成的

UIView中的坐標轉換

(1)[A convertPoint:pointB fromView:B]
將B視圖的pointB這個點轉換成A視圖上的點的(坐標轉換)
(2)[A convertPoint:pointA toView:B]
將A視圖中的pointA這個點轉換成,視圖B中的點(坐標轉換)

3、實際使用:

今天來補充介紹一個給view設置點擊區域的例子:

#import <objc/runtime.h>

@implementation UIView (Additions)

static NSString * const KEY_HIT_TEST_EDGE_INSETS = @"HitTestEdgeInsets";

@dynamic hitTestEdgeInsets;

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [UIView swizzleMethod:@selector(pointInside:withEvent:) withMethod:@selector(increasePointInside:withEvent:) error:nil];
    });
}

-(void)setHitTestEdgeInsets:(UIEdgeInsets)hitTestEdgeInsets {
    NSValue *value = [NSValue value:&hitTestEdgeInsets withObjCType:@encode(UIEdgeInsets)];
    objc_setAssociatedObject(self, &KEY_HIT_TEST_EDGE_INSETS, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

-(UIEdgeInsets)hitTestEdgeInsets {
    NSValue *value = objc_getAssociatedObject(self, &KEY_HIT_TEST_EDGE_INSETS);
    if(value) {
        UIEdgeInsets edgeInsets; [value getValue:&edgeInsets]; return edgeInsets;
    }else {
        return UIEdgeInsetsZero;
    }
}

//這里是關鍵
- (BOOL)increasePointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero)) {
        return [self bdt_increasePointInside:point withEvent:event];
    }
    
    CGRect relativeFrame = self.bounds;
//在原來rect的基礎上,根據邊緣距離,內切一個rect出來。
    CGRect hitFrame = UIEdgeInsetsInsetRect(relativeFrame, self.hitTestEdgeInsets);
    
    return CGRectContainsPoint(hitFrame, point);
}

這樣當點擊了某個view后,會先判斷是否指定hitTestEdgeInsets(點擊區域),如果沒有指定,則按照正常的處理邏輯去執行,反之如果指定了點擊區域,則:在視圖的bounds范圍內,根據指定的edge,內切一個rect出來,然后判斷觸摸點是否發生在這個范圍內。

這樣在使用view時,就可以通過設置XXXView.hitTestEdgeInsets = xxx; 來指定該view的點擊區域。

4、參考資料

Qunar Blog
wechat team
hitTest 應用

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 本文內容系全文轉載自微信開發團隊的《iOS 事件處理機制與圖像渲染過程》 目錄 iOS 事件處理機制與圖像渲染過程...
    Vinc閱讀 1,592評論 1 20
  • iOS 事件處理機制與圖像渲染過程原創 2015-11-19 ted WeMobileDev致歉聲明:Peter在...
    iOS_Cqlee閱讀 4,582評論 1 54
  • 在開發過程中,大家或多或少的都會碰到令人頭疼的手勢沖突問題,正好前兩天碰到一個類似的bug,于是借著這個機會了解了...
    閆仕偉閱讀 5,412評論 2 23
  • 好奇觸摸事件是如何從屏幕轉移到APP內的?困惑于Cell怎么突然不能點擊了?糾結于如何實現這個奇葩響應需求?亦或是...
    Lotheve閱讀 57,992評論 51 603
  • 一 荒原如海浪。大風吹過,一波浪起,一波浪伏。草在月光下,閃著藍綠色光芒。從未見過這般幽深古怪的草顏色。風大了,涼...
    月因心生閱讀 893評論 0 0