iOS编程中的快递小哥-Responder Chain(响应链)

2,061 阅读7分钟
原文链接: www.jianshu.com
deliver.jpeg
deliver.jpeg

今天我们来聊下iOS编程中常见点击事件从分发传递到响应的完整流程😎

1.事件类别

  • Touch events
    UIView上的常见点击事件
  • Press events
    AppleTV遥控器或者游戏控制器或其他带有实体物理键所触发的事件
  • Shake-motion events
    由加速计、陀螺仪、磁力仪触发的事件
  • Remote-control events
    额外配件如耳机上的音视频播放按键所触发的事件(视频播放、下一首)

今天我们只讲Touch events相关事件的传递响应

2.响应链工作原理

从你手指触到到屏幕中某一控件到其响应相关事件其实是分为两步:事件的传递事件的响应

事件的传递涉及到了UIView中的两个方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
//判断当前点击事件是否存在最优响应者(First Responder)
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
//判断当前点击是否在控件的Bounds之内

事件的传递其实就是在事件产生与分发之后如何寻找最优响应视图的一个过程

2.1事件的传递流程

1.触碰屏幕产生事件UIEvent并存入UIApplication中的事件队列中, 并且在整个视图结构中自上而下的进行分发
2.UIWindow接受到事件开始进行最优响应视图查询的过程(逆序遍历subviews)
3.当到UIViewController这一层时同样对其根视图(self.view及其上subviews)开始最优响应视图查询。该查询会调用上述提及到两个于UIView的方法,之所以采用逆序查询也是为了优化查找速度,毕竟后addSubview的视图在上易于命中

事件分发与传递流程
事件分发与传递流程

Note:
如果在hitTest & pointInside过程中查询到最优响应视图则后续对于其他subviews遍历查询则会停止

2.1.1视图命中查找流程

1.调用hitTest方法进行最优响应视图查询

  • hidden = YES
  • userInteractionEnabled = NO
  • alpha < 0.01
    以上三种情况会使该方法返回nil,即当前视图下无最优响应视图

2.hitTest方法内部会调用pointInside方法对点击点进行是否在当前视图bounds内进行判断,如果超出boundshitTest则返回nil,未超出范围则进行步骤3

3.对当前视图下的subviews采取逆序上述1 2步骤查询最优响应视图。如果hitTest返回了对应视图则说明在当前视图层级下有最优响应视图,可能为self或者其subview,这个要看具体返回。

下面是最优命响应图查询代码示例

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    if (self.alpha < 0.01 || !self.userInteractionEnabled || self.hidden) {
        
        return nil;
    }
    
    if (![self pointInside:point withEvent:event]) {
        
        return nil;
    }
    
    __block UIView *hitView = nil;
    [self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) {
        
        hitView = [subview hitTest:point withEvent:event];
        if (hitView) {
            
            *stop = YES;
        }
    }];
    
    return hitView ? : self;
}

好了, 事件的分发与传递流程我们已经讲完了,那我们该如果进行相关的验证呢?首先我们要明确相关要确认的点:

  • UIApplication开始自上而下的进行事件分发
  • UIView内部开始反向遍历查找最优视图

UIApplication 开始自上而下的进行事件分发

这个我们可以打开Instrument中的TimeProfiler进行一个整体的函数调用查看

在使用Instrument之前记得为其配置相对应的dSYM文件,否则到时候TimeProfiler中看到的将是调用函数的16进制地址,这不便于我们对问题的定位

屏幕快照 2017-10-14 下午10.18.07.png
屏幕快照 2017-10-14 下午10.18.07.png

然后我们在ViewController中添加一个Button和对应按钮事件就可以开始运行TimeProfiler了(Command + i

buttonActionTimeProfiler
buttonActionTimeProfiler

从图中我们可以看到分别一次调用了[UIApplication endEvent:][UIWindow sendEvent:]

这里可能会有同学注意到上面所提及到流程图中UIWindow是进行最优响应视图查询的,为什么TimeProfiler中显示了其调用了一次时间分发。这里让我们来看下Xcode文档中对于UIWindowsendEvent方法的注释

called by UIApplication to dispatch events to views inside the window

所以博主认为这里的调用是没问题的

UIView 内部开始反向遍历查找最优视图

首先我们可以利用Method Swizzling交换下我们需要监测的 hitTest方法

#import "UIView+WCQHitTest.h"
#import <objc/runtime.h>

@implementation UIView (WCQHitTest)

+ (void)load {
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        Class class = [self class];
        
        SEL oriSEL = @selector(hitTest:withEvent:);
        SEL swiSEL = @selector(wcq_hitTest:withEvent:);
        
        Method oriMethod = class_getInstanceMethod(class, oriSEL);
        Method swiMethod = class_getInstanceMethod(class, swiSEL);
        
        BOOL didAddMethod = class_addMethod(class, oriSEL,
                                            method_getImplementation(swiMethod),
                                            method_getTypeEncoding(swiMethod));
        
        if (didAddMethod) {
         
            class_replaceMethod(class,
                                swiSEL,
                                method_getImplementation(oriMethod),
                                method_getTypeEncoding(oriMethod));
        }else {
            
            method_exchangeImplementations(oriMethod, swiMethod);
        }
    });
}

- (UIView *)wcq_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    NSLog(@"%@ %s",[self class], __PRETTY_FUNCTION__);
    return [self wcq_hitTest:point withEvent:event];
}

然后我们分别新建三个UIView的子类: AViewBViewCView并依次按顺序添加到ViewController

F5B949E2-B466-4304-9299-BA646B981DB7.png
F5B949E2-B466-4304-9299-BA646B981DB7.png

然后我们依次点击AB视图看下hitTes调用顺序是否和预期一致

点击AView.png
点击AView.png 点击BView.png
点击BView.png bingo.jpeg
bingo.jpeg

2.2事件的响应流程

这里引用下苹果官方文档中的一张图

屏幕快照 2017-10-15 下午3.21.02.png
屏幕快照 2017-10-15 下午3.21.02.png

响应链 其实是由一个个UIResponder的子类构成的,UIResponder是系统一个负责接受和处理事件的类。

- (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;

而以上这几个响应触碰的方法其实也是出自于UIResponder类,
UIView作为UIResponder的子类能够处理点击事件也就无可厚非了

现在讲讲事件的响应流程:

1.首先已确定最优响应视图
2.判断最优响应视图能否响应事件,如果视图能进行响应则事件在响应链中的传递终止。如果视图不能响应则将事件传递给 nextResponder也就是通常的superview进行事件响应
3.如果事件继续上报至UIWindow并且无法响应,它将会把事件继续上报给UIApplication
4.如果事件继续上报至UIApplication并且也无法响应,它将会将事件上报给其Delegate,但前提下这个Delegate不属于 响应链 并且是UIResponder的子类
5.如果最终事件依旧未被响应则会被系统抛弃

Note:
也并非所有的nextResponder即是superview,比如UIViewController的根视图self.viewnextResponder是其所在UIViewController。而如果UIViewController如果是UIWindow的根控制器,那么它的nextResponder就是UIWindow,但如果UIViewController是另外一个 UIViewController present出来的话,那么它的nextResponder就是之前所执行present操作的那个UIViewController

流程讲完了,还是那句话: 设法证实其关键节点

  • 事件响应自下而上进行上报

我们这次可以利用该方法进行验证:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event

同时我们也看看该方法在文档中的描述,看能否查找到一些有用的信息节点 果然~

UIKit calls this method when a new touch is detected in a view or window. Many UIKit classes override this method and use it to handle the corresponding touch events. The default implementation of this method forwards the message up the responder chain. When creating your own subclasses, call super to forward any events that you do not handle yourself.

根据文档所述,该方法默认实现就是将事件沿 响应链 进行自下而上的上报。现在我们同样可以利用Method Swizzling再次对touchesBegan方法进行监测,这里有一个要注意的地方:由于这次置换的方法中调用到super方法,所以我们置换的时候置换的是UIView中的touchesBegan方法而没去置换UIResponder中的touchesBegan方法

- (void)wcq_touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    NSLog(@"%@ %s",[self class], __PRETTY_FUNCTION__);
    [super touchesBegan:touches withEvent:event];
}

同时这次我们为了验证事件的响应是自下而上,我们调整下UI的结构:

B1BB7DFA-42DA-4243-A3AD-CBB57DAF04DF.png
B1BB7DFA-42DA-4243-A3AD-CBB57DAF04DF.png

运行模拟器点击CView

点击CView.png
点击CView.png

3.总结

  • 事件分发与传递:自上而下
  • 事件响应:自下而上

当然这仅仅只是 Touch eventResponder Chain 中的传递与响应流程。不同类型的 UIEvent 分发与响应原理还不一致。

4.最后

你的点赞与指正都是我继续创作的动力,感谢你长的那么帅(漂亮)还来看我的文章😊