iOS响应者链彻底掌握

点我跳转原文地址

概述

iOS响应者链(Responder Chain)是支撑App界面交互的重要基础,点击、滑动、旋转、摇晃等都离不开其背后的响应者链,所以每个iOS开发人员都应该彻底掌握响应者链的响应逻辑,本文旨在通过demo测试的方式展现响应者链的具体响应过程,帮助读者彻底掌握响应者链。

Demo

你可以在这里(GitHub地址)下载本文测试的Demo源码,阅读本文的同时结合Demo程序有助于更加直观深刻的理解。

探究过程

响应者(Responder)

当我们触控手机屏幕时系统便会将这一操作封装成一个UIEvent放到事件队列里面,然后Application从事件队列取出这个事件,接着需要找到去响应这个事件的最佳视图也就是Responder, 所以开始的第一步应该是找到Responder, 那么又是如何找到的呢?那就不得不引出UIView的2个方法:

  • -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
    返回视图层级中能响应触控点的最深视图
  • -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
    返回视图是否包含指定的某个点
    通过在显示视图层级中依次对视图调用这个2个方法来确认该视图是不是能响应这个点击的点,首先会调用hitTest,然后hitTest会调用pointInside,最终hitTest返回的那个view就是最终的响应者Responder, 那么问题来了,在视图层级中是如何确定该对哪个View调用呢?优先级又是什么?
    为了探寻其中的逻辑,在Demo中我们构建了一个如下图所示的多重视图:
Responder.png

这是一个简单的控制器视图,在Controller的视图上添加了View1-View4共4个视图,View1-View4和RootView都继承自BaseView, BaseView继承自UIView; 其中 View1、View2是RootView的子视图,View3、View4是View2的子视图,他们的继承关系和父子关系图下图:

relationship.png

为了能观测到UIView的hitTest和pointInside调用过程,我们写个分类通过方法交换来打印调用的日志:

@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

当我们点击视图中的View3(紫色)时看看日志输出:

log.png

从日志中我们可以看到,首先是从UIWindow开始调用hitTest, 然后经过一段导航控制器的视图,因为我们的控制器是在导航控制的,所以可以先忽略这一段,然后来到RootView,调用RootView的hitTest和pointInside,因为点击发生在RootView中所以继续遍历它的子视图,可以看到是从View2开始的,调用View2的hitTest和pointInside,pointInside返回YES,然后继续遍历View2的子视图,从View4开始,因为点击不发生在View4所以pointInside返回NO,而View4没有子视图了,所以返回了nil也就是打印出来的null,然后继续在View2的另外一个子视图View3(目标视图)中调用hitTest和pointInside,因为我们点击的就是View3所以pointInside返回YES,且View3没有子视图所以hitTest返回了自己View3,接着View2的hitTest也返回View3直到UIWindow返回View3, 自此我们找到了响应视图:View3!另外我们看到对其他的Window也有调用,只不过返回了nil。

  • 结论:
    1. 寻找事件的最佳响应视图是通过对视图调用hitTest和pointInside完成的

    2. hitTest的调用顺序是从UIWindow开始,对视图的每个子视图依次调用,子视图的调用顺序是从后面往前面,也可以说是从显示最上面到最下面

    3. 遍历直到找到响应视图,然后逐级返回最终到UIWindow返回此视图

    PS:
    1.关于最后一个能响应的子视图demo中是因为没有子视图而确定的,这不是唯一确定的条件,因为有些情况下视图可能会被忽略,不会调用hitTest,这与userInteractionEnabled, alpha, frame等有关,在下个demo会演示。
    2.与加速度器、陀螺仪、磁力仪相关的运动事件不遵循此响应链,他们是由Core Motion 直接派发的

处理者

在上面我们已经找到了点击事件的响应者View3,但是我们并未给View3添加相应的点击处理逻辑(UITapGestureRecognizer),所以View3并不会处理事件,那么View3不处理由会交给谁处理呢?如果View3处理了又是怎么样的呢?
能够处理UI事件都是继承UIResponder的子类对象,UIResponder主要有以下4个方法来处理事件:

// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application.  Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (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;
`

分别是对应从触摸事件的开始、移动、结束、取消,如果你想自定义响应事件可以重写这几个方法来实现。如果某个Responder没处理事件,事件会被传递,UIResponder都有一个nextResponder属性,此属性会返回在Responder Chain中的下一个事件处理者,如果每个Responder都不处理事件,那么事件将会被丢弃。所以继承自UIResponder的子类便会构成一条响应者链,所以我们可以打印下以View3为开始的响应者链是什么样的:

- (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;
    }
}
View3ResponderChain.png

可以看到响应者链一直延伸到AppDelegate, View3的下一个是View2也就是View3的父视图,View2下一个是RootView也是父视图,而RootView的下一个则是Controller, 所以下一个响应者的规则是如果有父视图则nextResponder指向父视图,如果是控制器根视图则指向控制器,控制器如果在导航控制器中则指向导航控制器的相关显示视图最后指向导航控制器,如果是根控制器则指向UIWindow,UIWindow的nexResponder指向UIApplication最后指向AppDelegate,而他们实现这一套指向都是靠重写nextReponder实现的。

为了验证点击上面的事件的处理顺序,我们继续上面那个demo,为RootView和View1-View4的基类BaseView重写这几个方法:

@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)添加相关touches方法,日志打印看调用顺序:

ChainOrder.png

可以看到先是由UIWindow通过hitTest返回所找到的最合适的响应者View3, 接着执行了View3的touchesBegan,然后是通过nextResponder依次是View2、RootView、FindResponderController,可以看到完全是按照nextResponder链条的调用顺序,touchesEnded也是同样的顺序。

PS:感兴趣的可以继续重写AppDelegate的相关touches方法,验证最终是不是会被顺序调用。

上面是View3不处理点击事件的情况,接下来我们为View3添加一个点击事件处理,看看又会是什么样的调用过程:

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

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

@end

运行程序,点击View3看看日志打印:

View3Tap.png

可以看到touchesBegan顺着nextResponder链条调用了,但是View3处理了事件,去执行了相关是事件处理方法,而touchesEnded并没有得到调用。

  • 总结
    1.找到最适合的响应视图后事件会从此视图开始沿着响应链nextResponder传递,直到找到处理事件的视图,如果没有处理的事件会被丢弃。

    2.如果视图有父视图则nextResponder指向父视图,如果是根视图则指向控制器,最终指向AppDelegate, 他们都是通过重写nextResponder来实现。

无法响应的情况

在[响应者]章节我们已经提到寻找最佳响应者是通过hitTest函数调用完成的,那么存在哪些情况下视图会被忽视,而不被调用hiTest呢?
下面我么也通过第2个demo来演示,在什么情况下hitTest不会被调用或者返回nil,在demo中从上到下我们分别模拟了Alpha=0、子视图超出父视图的情况、userInteractionEnabled=NO、hidden=YES这4中情况:

Clipboard.png
  • 结论
    1.Alpha=0、子视图超出父视图的情况、userInteractionEnabled=NO、hidden=YES视图会被忽略,不会调用hitTest

    2.父视图被忽略后其所有子视图也会被忽略,所以View3上的button不会有点击反应

    3.出现视图无法响应的情况,可以考虑上诉情况来排查问题

    应用示例

  • 点击透传
    RootView有2个重叠在一起的子视图View1和View2, View2覆盖在View1上面,如何做到点击View1触发View2的处理逻辑?
    很简单,设置View2的userInteractionEnabled=NO即可。
  • 限定点击区域
    给定一个显示为圆形的视图,实现只有在点击区域在圆形里面才视为有效。
    我们可以重写View的pointInside方法来判断点击的点是否在圆内,也就是判断点击的点到圆心的距离是否小于等于半径就可以。
@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

One More Thing

在[响应者]一节中我们提到了,触摸事件是由系统封装成UIEvent放到事件队列里面,然后Application从事件队列取出事件接着是后面的寻找响应,那么在UIEvent又是如何封装的呢?放到事件队列里面又经历了什么呢?
这就不得不提RunLoop了,RunLoop是App运行的基础机制,它一直处于接受消息->等待->处理 的循环中,当没有事件处理时会处于休眠状态,等待着下一个事件到来的唤醒,被还手去处理事件,比如我么这里的触摸事件。
当一个触摸事件发生后首先是由IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收,然后SpringBoard会通过match port将事件转发给我们是App进程,然后触发App注册在RunLoop中的Source1来处理事件,Source1会触发__IOHIDEventSystemClientQueueCallback回调,回调后又会触发Source0,再后面就是UIApplication从事件队列取出事件派发,我们可以打个断点观察:

Source0.png

而要看到最初的Source1,则需要在__IOHIDEventSystemClientQueueCallback下符号断点才能看到:

Source2.png

想了解更多关于RunLoop机制详情的可以阅读这篇文章更适合,本文不做详情介绍。

猜你喜欢

转载自blog.csdn.net/wang_gwei/article/details/92814263
今日推荐