iOS响应者链完全掌握

点我跳转原文地址html

概述

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

Demo

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

探究过程

响应者(Responder)

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

  • -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
    返回视图层级中能响应触控点的最深视图
  • -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
    返回视图是否包含指定的某个点

经过在显示视图层级中依次对视图调用这个2个方法来确认该视图是否是能响应这个点击的点,首先会调用hitTest,而后hitTest会调用pointInside,最终hitTest返回的那个view就是最终的响应者Responder, 那么问题来了,在视图层级中是如何肯定该对哪一个View调用呢?优先级又是什么?
为了探寻其中的逻辑,在Demo中咱们构建了一个以下图所示的多重视图:app

Responder.png

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

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(紫色)时看看日志输出:oop

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 直接派发的ui

处理者

在上面咱们已经找到了点击事件的响应者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;
    }
}

View3ReponderChain.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依次是View二、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下符号断点才能看到:

source1.png

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

以上demo都可在GitHub下载:GitHub地址, Objective-C, Xcode9.3

参考文章:
文章一
文章二
文章三

相关文章
相关标签/搜索