iOS響應(yīng)者鏈徹底掌握2019-04-22

概述

iOS響應(yīng)者鏈(Responder Chain)是支撐App界面交互的重要基礎(chǔ),點(diǎn)擊、滑動(dòng)、旋轉(zhuǎn)、搖晃等都離不開其背后的響應(yīng)者鏈,所以每個(gè)iOS開發(fā)人員都應(yīng)該徹底掌握響應(yīng)者鏈的響應(yīng)邏輯,本文旨在通過demo測(cè)試的方式展現(xiàn)響應(yīng)者鏈的具體響應(yīng)過程,幫助讀者徹底掌握響應(yīng)者鏈。

Demo

你可以在這里(GitHub地址)下載本文測(cè)試的Demo源碼,閱讀本文的同時(shí)結(jié)合Demo程序有助于更加直觀深刻的理解。

探究過程

響應(yīng)者(Responder)

當(dāng)我們觸控手機(jī)屏幕時(shí)系統(tǒng)便會(huì)將這一操作封裝成一個(gè)UIEvent放到事件隊(duì)列里面,然后Application從事件隊(duì)列取出這個(gè)事件,接著需要找到去響應(yīng)這個(gè)事件的最佳視圖也就是Responder, 所以開始的第一步應(yīng)該是找到Responder, 那么又是如何找到的呢?那就不得不引出UIView的2個(gè)方法:

  • -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
    返回視圖層級(jí)中能響應(yīng)觸控點(diǎn)的最深視圖
  • -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
    返回視圖是否包含指定的某個(gè)點(diǎn)
    通過在顯示視圖層級(jí)中依次對(duì)視圖調(diào)用這個(gè)2個(gè)方法來確認(rèn)該視圖是不是能響應(yīng)這個(gè)點(diǎn)擊的點(diǎn),首先會(huì)調(diào)用hitTest,然后hitTest會(huì)調(diào)用pointInside,最終hitTest返回的那個(gè)view就是最終的響應(yīng)者Responder, 那么問題來了,在視圖層級(jí)中是如何確定該對(duì)哪個(gè)View調(diào)用呢??jī)?yōu)先級(jí)又是什么?
    為了探尋其中的邏輯,在Demo中我們構(gòu)建了一個(gè)如下圖所示的多重視圖:
4288200824-5b07d29cd9408_articlex.jpeg

這是一個(gè)簡(jiǎn)單的控制器視圖,在Controller的視圖上添加了View1-View4共4個(gè)視圖,View1-View4和RootView都繼承自BaseView, BaseView繼承自UIView; 其中 View1、View2是RootView的子視圖,View3、View4是View2的子視圖,他們的繼承關(guān)系和父子關(guān)系圖下圖:

1435560615-5b07d7cd5d4d1_articlex.jpeg

為了能觀測(cè)到UIView的hitTest和pointInside調(diào)用過程,我們寫個(gè)分類通過方法交換來打印調(diào)用的日志:

@implementation UIView (DandJ)
+ (void)load {
    Method origin = class_getInstanceMethod([UIView class], @selector(hitTest:withEvent:));
    Method custom = class_getInstanceMethod([UIView class], @selector(dandJ_hitTest:withEvent:));
    method_exchangeImplementations(origin, custom);

    origin = class_getInstanceMethod([UIView class], @selector(pointInside:withEvent:));
    custom = class_getInstanceMethod([UIView class], @selector(dandJ_pointInside:withEvent:));
    method_exchangeImplementations(origin, custom);
}

- (UIView *)dandJ_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%@ hitTest", NSStringFromClass([self class]));
    UIView *result = [self dandJ_hitTest:point withEvent:event];
    NSLog(@"%@ hitTest return: %@", NSStringFromClass([self class]), NSStringFromClass([result class]));
    return result;
}

- (BOOL)dandJ_pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%@ pointInside", NSStringFromClass([self class]));
    BOOL result = [self dandJ_pointInside:point withEvent:event];
    NSLog(@"%@ pointInside return: %@", NSStringFromClass([self class]), result ? @"YES":@"NO");
    return result;
}

@end

當(dāng)我們點(diǎn)擊視圖中的View3(紫色)時(shí)看看日志輸出:

1977433700-5b07dc4dc6d0f_articlex.jpeg

從日志中我們可以看到,首先是從UIWindow開始調(diào)用hitTest, 然后經(jīng)過一段導(dǎo)航控制器的視圖,因?yàn)槲覀兊目刂破魇窃趯?dǎo)航控制的,所以可以先忽略這一段,然后來到RootView,調(diào)用RootView的hitTest和pointInside,因?yàn)辄c(diǎn)擊發(fā)生在RootView中所以繼續(xù)遍歷它的子視圖,可以看到是從View2開始的,調(diào)用View2的hitTest和pointInside,pointInside返回YES,然后繼續(xù)遍歷View2的子視圖,從View4開始,因?yàn)辄c(diǎn)擊不發(fā)生在View4所以pointInside返回NO,而View4沒有子視圖了,所以返回了nil也就是打印出來的null,然后繼續(xù)在View2的另外一個(gè)子視圖View3(目標(biāo)視圖)中調(diào)用hitTest和pointInside,因?yàn)槲覀凕c(diǎn)擊的就是View3所以pointInside返回YES,且View3沒有子視圖所以hitTest返回了自己View3,接著View2的hitTest也返回View3直到UIWindow返回View3, 自此我們找到了響應(yīng)視圖:View3!另外我們看到對(duì)其他的Window也有調(diào)用,只不過返回了nil。

  • 結(jié)論:
  1. 尋找事件的最佳響應(yīng)視圖是通過對(duì)視圖調(diào)用hitTest和pointInside完成的
  2. hitTest的調(diào)用順序是從UIWindow開始,對(duì)視圖的每個(gè)子視圖依次調(diào)用,子視圖的調(diào)用順序是從后面往前面,也可以說是從顯示最上面到最下面
  3. 遍歷直到找到響應(yīng)視圖,然后逐級(jí)返回最終到UIWindow返回此視圖
    PS:
    1.關(guān)于最后一個(gè)能響應(yīng)的子視圖demo中是因?yàn)闆]有子視圖而確定的,這不是唯一確定的條件,因?yàn)橛行┣闆r下視圖可能會(huì)被忽略,不會(huì)調(diào)用hitTest,這與userInteractionEnabled, alpha, frame等有關(guān),在下個(gè)demo會(huì)演示。
    2.與加速度器、陀螺儀、磁力儀相關(guān)的運(yùn)動(dòng)事件不遵循此響應(yīng)鏈,他們是由Core Motion 直接派發(fā)的

處理者

在上面我們已經(jīng)找到了點(diǎn)擊事件的響應(yīng)者View3,但是我們并未給View3添加相應(yīng)的點(diǎn)擊處理邏輯(UITapGestureRecognizer),所以View3并不會(huì)處理事件,那么View3不處理由會(huì)交給誰處理呢?如果View3處理了又是怎么樣的呢?
能夠處理UI事件都是繼承UIResponder的子類對(duì)象,UIResponder主要有以下4個(gè)方法來處理事件:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;

分別是對(duì)應(yīng)從觸摸事件的開始、移動(dòng)、結(jié)束、取消,如果你想自定義響應(yīng)事件可以重寫這幾個(gè)方法來實(shí)現(xiàn)。如果某個(gè)Responder沒處理事件,事件會(huì)被傳遞,UIResponder都有一個(gè)nextResponder屬性,此屬性會(huì)返回在Responder Chain中的下一個(gè)事件處理者,如果每個(gè)Responder都不處理事件,那么事件將會(huì)被丟棄。所以繼承自UIResponder的子類便會(huì)構(gòu)成一條響應(yīng)者鏈,所以我們可以打印下以View3為開始的響應(yīng)者鏈?zhǔn)鞘裁礃拥模?/p>

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    UIResponder *nextResponder = self.view3.nextResponder;
    NSMutableString *pre = [NSMutableString stringWithString:@"--"];
    NSLog(@"View3");
    while (nextResponder) {
        NSLog(@"%@%@", pre, NSStringFromClass([nextResponder class]));
        [pre appendString:@"--"];
        nextResponder = nextResponder.nextResponder;
    }
}
3420427165-5b07e8f1231d3_articlex.png

可以看到響應(yīng)者鏈一直延伸到AppDelegate, View3的下一個(gè)是View2也就是View3的父視圖,View2下一個(gè)是RootView也是父視圖,而RootView的下一個(gè)則是Controller, 所以下一個(gè)響應(yīng)者的規(guī)則是如果有父視圖則nextResponder指向父視圖,如果是控制器根視圖則指向控制器,控制器如果在導(dǎo)航控制器中則指向?qū)Ш娇刂破鞯南嚓P(guān)顯示視圖最后指向?qū)Ш娇刂破?,如果是根控制器則指向UIWindow,UIWindow的nexResponder指向UIApplication最后指向AppDelegate,而他們實(shí)現(xiàn)這一套指向都是靠重寫nextReponder實(shí)現(xiàn)的。

為了驗(yàn)證點(diǎn)擊上面的事件的處理順序,我們繼續(xù)上面那個(gè)demo,為RootView和View1-View4的基類BaseView重寫這幾個(gè)方法:

@implementation BaseView

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%@ touchesBegan", NSStringFromClass([self class]));
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%@ touchesMoved", NSStringFromClass([self class]));
    [super touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%@ touchesEnded", NSStringFromClass([self class]));
    [super touchesEnded:touches withEvent:event];
}

@end

同樣也為控制器(FindResponderController)添加相關(guān)touches方法,日志打印看調(diào)用順序:

1502820978-5b0a5947ed99d_articlex.png

可以看到先是由UIWindow通過hitTest返回所找到的最合適的響應(yīng)者View3, 接著執(zhí)行了View3的touchesBegan,然后是通過nextResponder依次是View2、RootView、FindResponderController,可以看到完全是按照nextResponder鏈條的調(diào)用順序,touchesEnded也是同樣的順序。

PS:感興趣的可以繼續(xù)重寫AppDelegate的相關(guān)touches方法,驗(yàn)證最終是不是會(huì)被順序調(diào)用。

上面是View3不處理點(diǎn)擊事件的情況,接下來我們?yōu)閂iew3添加一個(gè)點(diǎn)擊事件處理,看看又會(huì)是什么樣的調(diào)用過程:

@implementation View3
- (void)awakeFromNib {
    [super awakeFromNib];
    [self addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAction:)]];
}

- (void)tapAction:(UITapGestureRecognizer *)recognizer {
    NSLog(@"View3 taped");
}

@end

運(yùn)行程序,點(diǎn)擊View3看看日志打?。?/p>

340777262-5b0a5ae241540_articlex.png

可以看到touchesBegan順著nextResponder鏈條調(diào)用了,但是View3處理了事件,去執(zhí)行了相關(guān)是事件處理方法,而touchesEnded并沒有得到調(diào)用。

總結(jié)

1.找到最適合的響應(yīng)視圖后事件會(huì)從此視圖開始沿著響應(yīng)鏈nextResponder傳遞,直到找到處理事件的視圖,如果沒有處理的事件會(huì)被丟棄。
2.如果視圖有父視圖則nextResponder指向父視圖,如果是根視圖則指向控制器,最終指向AppDelegate, 他們都是通過重寫nextResponder來實(shí)現(xiàn)。

無法響應(yīng)的情況

在[響應(yīng)者]章節(jié)我們已經(jīng)提到尋找最佳響應(yīng)者是通過hitTest函數(shù)調(diào)用完成的,那么存在哪些情況下視圖會(huì)被忽視,而不被調(diào)用hiTest呢?
下面我么也通過第2個(gè)demo來演示,在什么情況下hitTest不會(huì)被調(diào)用或者返回nil,在demo中從上到下我們分別模擬了Alpha=0、子視圖超出父視圖的情況、userInteractionEnabled=NO、hidden=YES這4中情況:

1130107321-5b0b6f7796245_articlex.png

結(jié)論

1.Alpha=0、子視圖超出父視圖的情況、userInteractionEnabled=NO、hidden=YES視圖會(huì)被忽略,不會(huì)調(diào)用hitTest
2.父視圖被忽略后其所有子視圖也會(huì)被忽略,所以View3上的button不會(huì)有點(diǎn)擊反應(yīng)
3.出現(xiàn)視圖無法響應(yīng)的情況,可以考慮上訴情況來排查問題

應(yīng)用示例

點(diǎn)擊透?jìng)?/h3>

RootView有2個(gè)重疊在一起的子視圖View1和View2, View2覆蓋在View1上面,如何做到點(diǎn)擊View1觸發(fā)View2的處理邏輯?
很簡(jiǎn)單,設(shè)置View2的userInteractionEnabled=NO即可。
限定點(diǎn)擊區(qū)域
給定一個(gè)顯示為圓形的視圖,實(shí)現(xiàn)只有在點(diǎn)擊區(qū)域在圓形里面才視為有效。
我們可以重寫View的pointInside方法來判斷點(diǎn)擊的點(diǎn)是否在圓內(nèi),也就是判斷點(diǎn)擊的點(diǎn)到圓心的距離是否小于等于半徑就可以。

@implementation CircleView
- (void)awakeFromNib {
    [super awakeFromNib];
    self.layer.cornerRadius = self.frame.size.width / 2.0f;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    const CGFloat radius = self.frame.size.width / 2.0f;
    CGFloat xOffset = point.x - radius;
    CGFloat yOffset = point.y - radius;
    CGFloat distance = sqrt(xOffset * xOffset + yOffset * yOffset);
    return distance <= radius;
}
@end

個(gè)人理解與總結(jié)

1、概述

首先,當(dāng)發(fā)生事件響應(yīng)時(shí),必須知道由誰來響應(yīng)事件。在IOS中,由響應(yīng)者鏈來對(duì)事件進(jìn)行響應(yīng),所有事件響應(yīng)的類都是UIResponder的子類,響應(yīng)者鏈?zhǔn)且粋€(gè)由不同對(duì)象組成的層次結(jié)構(gòu),其中的每個(gè)對(duì)象將依次獲得響應(yīng)事件消息的機(jī)會(huì)。當(dāng)發(fā)生事件時(shí),事件首先被發(fā)送給第一響應(yīng)者,第一響應(yīng)者往往是事件發(fā)生的視圖,也就是用戶觸摸屏幕的地方。事件將沿著響應(yīng)者鏈一直向下傳遞,直到被接受并做出處理。一般來說,第一響應(yīng)者是個(gè)視圖對(duì)象或者其子類對(duì)象,當(dāng)其被觸摸后事件被交由它處理,如果它不處理,事件就會(huì)被傳遞給它的視圖控制器對(duì)象viewcontroller(如果存在),然后是它的父視圖(superview)對(duì)象(如果存在),以此類推,直到頂層視圖。接下來會(huì)沿著頂層視圖(top view)到窗口(UIWindow對(duì)象)再到程序(UIApplication對(duì)象)。如果整個(gè)過程都沒有響應(yīng)這個(gè)事件,該事件就被丟棄。一般情況下,在響應(yīng)者鏈中只要由對(duì)象處理事件,事件就停止傳遞。

2、響應(yīng)者鏈(Responder Chain)

響應(yīng)者鏈有以下特點(diǎn):

1、響應(yīng)者鏈通常是由視圖(UIView)構(gòu)成的;

2、一個(gè)視圖的下一個(gè)響應(yīng)者是它視圖控制器(UIViewController)(如果有的話),然后再轉(zhuǎn)給它的父視圖(Super View);

3、視圖控制器(如果有的話)的下一個(gè)響應(yīng)者為其管理的視圖的父視圖;

4、單例的窗口(UIWindow)的內(nèi)容視圖將指向窗口本身作為它的下一個(gè)響應(yīng)者

需要指出的是,Cocoa Touch應(yīng)用不像Cocoa應(yīng)用,它只有一個(gè)UIWindow對(duì)象,因此整個(gè)響應(yīng)者鏈要簡(jiǎn)單一點(diǎn);

5、單例的應(yīng)用(UIApplication)是一個(gè)響應(yīng)者鏈的終點(diǎn),它的下一個(gè)響應(yīng)者指向nil,以結(jié)束整個(gè)循環(huán)。

3、事件分發(fā)(Event Delivery)

第一響應(yīng)者(First responder)指的是當(dāng)前接受觸摸的響應(yīng)者對(duì)象(通常是一個(gè)UIView對(duì)象),即表示當(dāng)前該對(duì)象正在與用戶交互,它是響應(yīng)者鏈的開端。整個(gè)響應(yīng)者鏈和事件分發(fā)的使命都是找出第一響應(yīng)者。

UIWindow對(duì)象以消息的形式將事件發(fā)送給第一響應(yīng)者,使其有機(jī)會(huì)首先處理事件。如果第一響應(yīng)者沒有進(jìn)行處理,系統(tǒng)就將事件(通過消息)傳遞給響應(yīng)者鏈中的下一個(gè)響應(yīng)者,看看它是否可以進(jìn)行處理。

iOS系統(tǒng)檢測(cè)到手指觸摸(Touch)操作時(shí)會(huì)將其打包成一個(gè)UIEvent對(duì)象,并放入當(dāng)前活動(dòng)Application的事件隊(duì)列,單例的UIApplication會(huì)從事件隊(duì)列中取出觸摸事件并傳遞給單例的UIWindow來處理,UIWindow對(duì)象首先會(huì)使用hitTest:withEvent:方法尋找此次Touch操作初始點(diǎn)所在的視圖(View),即需要將觸摸事件傳遞給其處理的視圖,這個(gè)過程稱之為hit-test view。

UIWindow實(shí)例對(duì)象會(huì)首先在它的內(nèi)容視圖上調(diào)用hitTest:withEvent:,此方法會(huì)在其視圖層級(jí)結(jié)構(gòu)中的每個(gè)視圖上調(diào)用pointInside:withEvent:(該方法用來判斷點(diǎn)擊事件發(fā)生的位置是否處于當(dāng)前視圖范圍內(nèi),以確定用戶是不是點(diǎn)擊了當(dāng)前視圖),如果pointInside:withEvent:返回YES,則繼續(xù)逐級(jí)調(diào)用,直到找到touch操作發(fā)生的位置,這個(gè)視圖也就是要找的hit-test view。
hitTest:withEvent:方法的處理流程如下:
首先調(diào)用當(dāng)前視圖的pointInside:withEvent:方法判斷觸摸點(diǎn)是否在當(dāng)前視圖內(nèi);
若返回NO,則hitTest:withEvent:返回nil;
若返回YES,則向當(dāng)前視圖的所有子視圖(subviews)發(fā)送hitTest:withEvent:消息,所有子視圖的遍歷順序是從最頂層視圖一直到到最底層視圖,即從subviews數(shù)組的末尾向前遍歷,直到有子視圖返回非空對(duì)象或者全部子視圖遍歷完畢;
若第一次有子視圖返回非空對(duì)象,則hitTest:withEvent:方法返回此對(duì)象,處理結(jié)束;
如所有子視圖都返回非,則hitTest:withEvent:方法返回自身(self)。

4、說明

1、響應(yīng)者鏈的傳遞順序是從子類逐級(jí)到父類的傳遞方向,事件分發(fā)的順序是從父類逐級(jí)到子類的順序。

2、如果最終hit-test沒有找到第一響應(yīng)者,或者第一響應(yīng)者沒有處理該事件,則該事件會(huì)沿著響應(yīng)者鏈向上回溯,如果UIWindow實(shí)例和UIApplication實(shí)例都不能處理該事件,則該事件會(huì)被丟棄;

3、hitTest:withEvent:方法將會(huì)忽略隱藏(hidden=YES)的視圖,禁止用戶操作(userInteractionEnabled=YES)的視圖,以及alpha級(jí)別小于0.01(alpha<0.01)的視圖。如果一個(gè)子視圖的區(qū)域超過父視圖的bound區(qū)域(父視圖的clipsToBounds 屬性為NO,這樣超過父視圖bound區(qū)域的子視圖內(nèi)容也會(huì)顯示),那么正常情況下對(duì)子視圖在父視圖之外區(qū)域的觸摸操作不會(huì)被識(shí)別,因?yàn)楦敢晥D的pointInside:withEvent:方法會(huì)返回NO,這樣就不會(huì)繼續(xù)向下遍歷子視圖了。當(dāng)然,也可以重寫pointInside:withEvent:方法來處理這種情況。

4、我們可以重寫hitTest:withEvent:來達(dá)到某些特定的目的,實(shí)際應(yīng)用中很少用到這些。

以上內(nèi)容參考作者原文地址:原文地址

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

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