iOS响应链

简介: iOS响应链

自从iPhone问世以来,iPhone手机采用CocoaTouch框架,使其更注重于图形化和触摸操作,其中,基于Foundation的UIKit是实现Cocoa Touch最主要的框架,UIKit提供了iOS设备上图形化事件驱动程序的基本工具。


当一个应用程序展示在iOS设备上时,我们会考虑两个问题:界面如何展示?界面是如何交互的?本节将详细回答第二个问题。


当用户的手真正触摸到屏幕时,程序内部是如何响应的?实际上,当触摸到屏幕时会生成一个TouchEvent(触摸事件),添加到UIApplication管理的事件队列中,UIApplication会从事件队列中依次取出事件来分发到应响应的视图去处理(关于这个触摸事件的队列,我们在iOS开发中也有遇到过,例如在我们启动程序后的某一时间正执行到断点处,而我们并没有注意到此时处于断点,就在屏幕上进行各种操作,但此时是没有反应的,因为UIApplication虽然收到了我们的Touch Event,但由于系统处于暂停状态,无法继续处理交互事务,因此事件队列一直处于积累交互事件的过程中,当我们跳过断点,程序继续执行后,会发现程序会依次响应之前积累的触摸事件)。当触摸事件被UIApplication发出后,会从程序的keyWindow开始,然后依次向上传递,包括各种视图控制器以及视图,最后找到合适的处理该事件的视图来响应,这整个过程就称为事件传递。


如下图所示,展示了几个view的层级示意图,其层级关系如下。


2466108-0bf9757786be10f0.webp.jpg


响应链视图示例


A为B、C的父视图;C为D、E的父视图。

当触摸视图B时,事件传递的顺序为:UIApplication→A→B。

当触摸视图D时,事件传递的顺序为:UIApplication→A→C→D。

当触摸视图E时,事件传递的顺序为:UIApplication→A→C→E。

那么系统是根据什么来判定事件的传递顺序的呢?难道仅仅是根据子视图吗?事实上,这里涉及两个非常重要的方法:


- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;


这两个方法是事件传递机制的关键所在。这两个方法是UIView提供的,但并非表明只有UIView才能响应事件传递,因为除了UIView,UIViewController也是可以响应事件传递的,所以它们拥有事件传递的能力取决于它们共同的父类UIResponder(所以Window继承自UIView,也可以响应)。


当UIApplication发送事件到keyWindow时,keyWindow会调用-hitTest:withEvent:方法来寻找最适合处理事件的视图。假设事件已经传递到了某视图view,选择出能响应视图的逻辑如下。


(1)首先会判断该视图自身能否处理该触摸事件,如果不能响应,则不通过pointInside方法,则hitTest方法直接返回nil;


(2)如果该View可以响应,则调用-pointInside:withEvent:判断是否在显示区域上,如果不在其区域中,则返回NO,同时-hitTest:withEvent:也返回nil;


(3)如果步骤2中返回YES,表示在当前View的范围中,接着先倒序遍历该视图的子视图数组,按照当前的顺序,直到某一子视图可以响应,并且-hitTest:withEvent:返回该子视图;


(4)如果步骤3中没有子视图,或者没有任何一个子视图能够响应该触摸事件,则返回该视图自身,表示只有自身可以处理该事件。


以上步骤用代码来表示的话,或者说-hitTest:withEvent:方法的原理如下。


//point是该视图的坐标系上的点
- (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]) return nil;
    //3.从后往前遍历自己的子控件,看是否有子控件更适合响应此事件
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--) {
        UIView *childView = self.subview[i];
        CGPoint childPoint = [self convertPoint:point toView:childView];
        UIView *fitView = [childView hitTest:childPoint withEvent:event];
        if (fitView) {
            return fitView;
        }
    }
    //4.没有找到比自己更合适的view
    return self;
}


视图如果满足以下三个条件其一,则不能接收触摸事件。


(1)userInteractionEnabled=NO;

(2)hidden=YES;

(3)alpha<0.01。


注:在实际代码中,-hitTest:withEvent:和-pointInside:withEvent:两个方法会分别调用两次,或者可能会有更多次,这可能是由于iOS响应链机制的原因,当然也可能是iOS触摸事件的判断逻辑,对此官方并没有给出详细的解释。所以读者在此需要注意一下,在以下的例子中只给出一次打印来表示其逻辑。


以下通过一个实际的案例来深入了解一下响应链机制。


首先构建一个demo,如下图所示。


2466108-b378ef4fa8e974e7.webp.jpg


图中字母A~E分别表示不同的视图View,数字1~6表示之后要单击的位置。并且A为B、C、D的父视图,D为E的父视图。接下来,分成几组操作并通过控制台的打印来分析响应链的传递过程。


同时,也先介绍一下代码逻辑。在工程中,首先创建一个BaseView,这是父类,A~E都继承BaseView。在BaseView中,重写三个系统方法,并做出响应的打印。


#import "BaseView.h"
@implementation BaseView
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSLog(@"touchBegan --- %@",[self class]);
    [super touchesBegan:touches withEvent:event];
}
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    UIView *v = [super hitTest:point withEvent:event];
    NSLog(@"hitTest ---- %@ return: %@",[self class], v.class);
    return v;
}
-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
    BOOL b = [super pointInside:point withEvent:event];
    NSLog(@"pointInside ---- %@ return: %@",[self class],b?@"YES":@"NO");
    return b;
}
@end


我们重写了touchesBegan、hitTest、pointInside三个方法,在父类中对其重写显得更为方便一些。同时基于BaseView创建AView、BView、CView、DView、EView并加到控制器的view上。


#import "ViewController.h"
#import "AView.h"
#import "BView.h"
#import "CView.h"
#import "DView.h"
#import "EView.h"
@interface ViewController ()
@property (nonatomic, strong) AView *a;
@property (nonatomic, strong) BView *b;
@property (nonatomic, strong) CView *c;
@property (nonatomic, strong) DView *d;
@property (nonatomic, strong) EView *e;
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.a = [[AView alloc]initWithFrame:[UIScreen mainScreen].bounds];
    [self.view addSubview:self.a];
    self.b = [[BView alloc]initWithFrame:CGRectMake(70, 70, 260, 260)];
    [self.a addSubview:self.b];
    self.c = [[CView alloc]initWithFrame:CGRectMake(100, 100, 200, 200)];
    [self.a addSubview:self.c];
    self.d = [[DView alloc]initWithFrame:CGRectMake(130, 130, 140, 140)];
    [self.a addSubview:self.d];
    self.e = [[EView alloc]initWithFrame:CGRectMake(-30, 30, 200, 80)];
    self.e.backgroundColor = [UIColor colorWithWhite:0 alpha:0.3];
    [self.d addSubview:self.e];
    [self setViewBorder:self.a];
    [self setViewBorder:self.b];
    [self setViewBorder:self.c];
    [self setViewBorder:self.d];
    [self setViewBorder:self.e];
}
- (void)setViewBorder:(UIView *)v{
    v.layer.borderWidth = 1;
    v.layer.borderColor = [UIColor blackColor].CGColor;
}


如之前所说的那样,A为B、C、D的superView,D为E的superView。为了方便区分每个View的显示,特地添加setViewBorder方法为相应的View的layer添加边宽。并且,我们没有为任何View设置不可交互,所以每个view都是可以响应触摸事件的。至此,一切准备就绪了,接下来开始实验。


单击位置1,打印结果如下。


2466108-0b7c8d7793f2b5f6.webp.jpg


可以看到,首先调用了AView的pointInside,因为单击的是AView的区域,所以返回的是YES,下一步是遍历AView的subViews数组,注意遍历是倒序的,在AView中的subViews数组顺序分别是:B、C、D,先判断DView的pointInside方法,然而由于不在D的区域中,返回了NO,所以D的hitTest方法直接放回nil,DView的判断结束,转到AView的subViews数组中D的上一个子视图C(因为是倒序的)。由于单击位置也不在CView的区域中,CView的pointInside也返回NO,同时CView的hitTest返回nil,即C也不能响应,同理BView也不能,至此AView的subViews倒序遍历结束,回到AView的本身,此时1位置是在A的区域中,即AView的pointInside方法返回YES,并且hitTest也返回了AView本身,表示AView可以自己处理该触摸事件。注意,最后还有一个打印touchBegan---AView,这个后面再做分析。


单击位置2,打印结果如下。


2466108-d18781ce64cbdcda.webp.jpg


位置2在BView上,属于AView的子View。首先AView先接收到触摸事件,通过pointInside方法返回YES,表示在AView的区域中,接着判断AView的子View,从DView到CView和BView,然而位置2属于BView所在位置,不在CView和DView上,因此先调用了DView的pointInside返回NO,然后DView的hitTest也返回nil,同理CView也是。一直到BView,pointInside返回YES,并且BView没有subViews,因此返回了自身,即hitTest返回了可以响应的BView。至此AView的subViews遍历结束,到AView本身,即调用AView的hitTest方法,也返回了BView。同时touchBegan方法打印了BView和AView。

单击位置3,打印结果如下。


2466108-23d4518ae2909bb8.webp.jpg


位置3属于AView的CView上,所以AView接收到触摸事件后,由于是在其响应区域,所以AView的pointInside返回YES,然后遍历AView的subViews,DView不在区域内不能响应,到CView,在其区域,可以响应,hitTest返回CView自身,同时到AView的hitTest方法,也返回了CView,所以CView为最终响应的View。touchBegan打印了CView和AView。读者看到这里可能就会产生疑问,B同样是A的子View,为什么B不会调用pointInside和hitTest打印的方法呢?原因如下。


2466108-23437e30a492d62c.webp.jpg


前面说过,对于subView的响应顺序是倒序的,也就是先从③开始,所以关于D,执行了pointInside和hitTest方法。到了②,已经找到了响应当前单击事件的视图CView,并返回,至此响应链结束,也就没有BView什么事了,因此B上没有执行pointInside和hitTest方法,也就是没有打印输出。


单击位置4,打印如下。


2466108-2e3f1e32fd2072aa.webp.jpg


位置4在AView的DView上,但不在DView的EView上。同样先是AView区域内,倒序遍历subViews,在DView的区域中,所以DView的pointInside返回YES,然后倒序遍历DView的subViews,首先判断EView的pointInside,返回NO,表示不在EView的区域内,EView的hitTest也直接返回nil,所以只有DView本身去响应该触摸事件,即DView和AView的hitTest方法都返回了DView。touchBegan打印了DView和AView。

单击位置5,打印如下。


2466108-c51229e11daef5fc.webp.jpg


与之前的都一样,在AView的区域中,AView的pointInside返回YES,倒序遍历AView的subViews,首先DView的pointInside也返回YES,表示在DView的区域内,再遍历DView的subViews,EView的pointInside也返回了YES,表示在EView的区域内,EView没有subViews,所以调用EView的hitTest方法,返回了EView,同理DView和AView也返回了EView,表示EView响应该次触摸事件。touchBegan返回EView、DView、AView。

单击位置6,打印如下。


2466108-eade0c9d4fcd60ac.webp.jpg


位置6属于AView的DView的EView超出DView的区域上,与位置5相比,虽然都属于EView,但打印结果不同,也可以看出逻辑是不同的。同样,AView的pointInside返回YES,表示在AView上,然后判断DView的hitTest,返回NO,表示不在DView的区域中,接着判断DView同级的CView,CView的pointInside返回YES,即在其区域中,CView的hitTest返回了CView自身,AView也返回了CView,表示由CView来响应该次触摸事件。touchBegan打印了CView和AView。


至此,图示的6种打印都已结束。接下来解释一下,什么是响应者链。通过以上的单击打印测试,响应者链可以简单理解为pointInside返回YES,并且hitTest方法返回非空的View都属于响应者链的一部分,这与上面所提到的touchBegan是相对应的。这只是我们通过实验得到的结果。那什么是响应者呢?


响应者:继承自UIResponder的类都称为响应者。


问一个问题,你知道UIView和UIViewController的父类是谁吗?没错,实际开发中经常打交道的这两个类都是继承自UIResponder类,这个类对于开发者来说似乎很少见到和用到,但UIResponder类提供了一些常用的方法,例如becomeFirstResponder、touchesBegan、motionBegan等一系列方法。


如下图所示,在Apple的官方文档中提供了iOS中响应链层级。


2466108-1ff53d17534a3743.webp.jpg


响应链层级


可以看到,当寻找出一个响应者接收响应事件的时候,也确定了该次响应的响应链,包括view、控制器的view、控制器、Window、Application。这里结合图3-4提及一个场景,就是实际开发中经常采用UITabBarViewController结构来搭建APP。我们知道,在UITabBarView-Controller中,每个item都包含另外一个控制器,甚至可能是导航栏控制器。举个简单例子,例如在UITabBarViewController结构中,包含若干个item,这里先只看第一个,第一个item是一个导航栏控制器,导航栏控制器有一个根控制器,假设称之为FirstViewController,在FirstViewController的view上有一个button,当我们单击button的时候,形成的响应链是如何的呢?结合图3-4可以分析出,响应链为:


button→FirstViewController.view→FirstViewController→FirstViewController.navigationCtroller.view→FirstViewController.navigationCtroller→UITabBarViewController.view→UITabBarController→Window→Application。


本节小结


当我们单击屏幕时,系统会记录该次的触摸事件,添加到Application的事件队列中,然后从keyWindow开始依次向上寻找,结合响应者的pointInside方法和hitTest方法找出处理该触摸事件的View,从而也形成一条事件响应链。


结合本节响应链的知识点,在实际开发中有很多使用的场景,例如处理多个UIScrollView的手势冲突,扩大UIButton的响应范围,这些都是通过重写pointInside方法或者hitTest方法来实现的。但与此同时,开发者应当注意,使用前应当思考充分,避免屏蔽了其他单击事件的响应链。


目录
相关文章
|
JavaScript 前端开发 Android开发
|
7月前
|
移动开发 前端开发 安全
保护你的 iOS 应用,防止逆向破解
保护你的 iOS 应用,防止逆向破解
|
iOS开发
iOS UIGestureRecognizerDelegate
iOS UIGestureRecognizerDelegate
58 0
|
iOS开发
iOS - autoreleasePool
iOS - autoreleasePool
iOS - autoreleasePool
|
API iOS开发 UED
iOS右滑返回的实现(interactivePopGestureRecognizer)
iOS右滑返回的实现(interactivePopGestureRecognizer)
1102 0
iOS右滑返回的实现(interactivePopGestureRecognizer)
iOS NSInvocation应用与理解
iOS NSInvocation应用与理解
176 0
|
iOS开发 数据格式
你不得不知道的iOS 中的 Copying
Copying 在 iOS 中有很多概念,例如浅拷贝与深拷贝、copy 与 mutableCopy、NSCopying 协议,一直想彻底搞明白这些概念,刨根问底不搞懂不罢休嘛。于是搜 Google 看了一些博客,又去翻了 Apple 相关的文档,发现网上许多博客都理解错了,下面说说自己的理解。
4362 0
|
安全 数据安全/隐私保护 iOS开发