iOS事件传递及响应链

1,266 阅读14分钟

在iOS中,用户与APP进行交互,会产生很多事件,这些事件是如何产生,响应的链条又是怎样传递的,本文将会进行一番探究。

1.事件传递&响应流程

1.1 几个概念

事件分类

对于iOS用户来说,他们操作设备的方式主要有三种:触摸屏幕、晃动设备、远程控制设备。对应的事件类型有以下三种:

触屏事件(Touch Event)
运动事件(Motion Event)
远端控制事件(Remote-Control Event)
本文以常见的触屏事件(Touch Event)来对事件传递以及响应链来进行探究。

响应者

在iOS中,响应者是能响应事件的UIResponder子类的对象,如UIButton、UIView、UIViewController等。 UIResponder中提供了以下4个对象方法来处理触摸事件。

UIResponder内部提供了以下方法来处理事件触摸事件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
UIResponder 还可以处理 UIPress、加速计、远程控制事件,这里仅讨论触摸事件。

事件的产生

点击、摇动、滑动、旋转等会被系统封装成UIEvent,放到事件队列里等待UIApplication去取
发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中,为什么是队列而不是栈?因为队列的特点是FIFO,即先进先出,先产生的事件先处理才符合常理,所以把事件添加到队列。

UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常,先发送事件给应用程序的主窗口(keyWindow)。

主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件,这也是整个事件处理过程的第一步。

找到合适的视图控件后,就会调用视图控件的touches方法来作具体的事件处理。

1.2 事件传递&响应流程

1.2.1确定第一响应者

在iOS程序中无论是最后面的UIWindow还是最前面的某个按钮,它们的摆放是有前后关系的,一个控件可以放到另一个控件上面或下面,那么用户点击某个控件时是触发上面的控件还是下面的控件呢,这种先后关系构成一个链条就叫“响应者链”。也可以说,响应者链是由多个响应者对象连接起来的链条。在iOS中响应者链的关系可以用下图表示:

寻找响应者依靠这两个方法:

// 返回最佳响应者
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
// 判断点有没有在返回的视图范围内
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event

  • 当触摸屏幕之后,系统会利用Runloop将事件加入到UIApplication的任务队列中;
  • UIApplication分发触摸事件到UIWindow,然后UIWindow依次向下分发给UIView;
  • UIView调用hitTest:withEvent:方法看看自己能否处理事件,以及触摸点是否在自己上面;
  • 如果满足条件,就遍历UIView上的子控件。重复上面的动作。
  • 直到找到最顶层的一个满足条件(既能处理触摸事件,触摸点又在上面)的子控件,此子控件就是我们需要找到的第一响应者。

命中测试 关于图中还有一些细节需要先说明:

  • 在 检查自身可否接收事件 中,如果视图符合以下三个条件中的任一个,都会无法接收事件:
    view.isUserInteractionEnabled = false
    view.alpha <= 0.01
    view.isHidden = true

  • 检查坐标是否在自身内部 这个过程使用了 func point(inside point: CGPoint, with event: UIEvent?) -> Bool 方法来判断坐标是否在自身内部,该方法是可以被重写的。

  • 从后往前遍历子视图重复执行 指的是按照 FILO 的原则,将其所有子视图按照「后添加的先遍历」的规则进行命中测试。该规则保证了系统会优先测试视图层级树中最后添加的视图,如果视图之间有重叠,该视图也是同级视图中展示最完整的视图,即用户最可能想要点的那个视图。

  • 在 按顺序看看平级的兄弟视图 时,若发现已经没有未检查过的视图了,则应走向 诶?没有子视图符合要求?。

下面我们举个例子来解释这个流程。下图中灰色视图 A 可以看作是当前 UIViewController 的根视图,右侧表示了各个视图的层级结构,用户在屏幕上的触摸点是🌟处,并且这 5 个视图都可以正常的接收事件。


具体的流程如下:

  • 首先对 A 进行命中测试,显然🌟是在 A 内部的,按照流程接下来检查 A 是否有子视图。
  • 我们发现 A 有两个子视图,那我们就需要按 FILO 原则遍历子视图,先对 D 进行命中测试,后对 B 进行命中测试。
  • 我们对 D 进行命中测试,我们发现🌟不在 D 的内部,那就说明 D 及其子视图一定不是第一响应者。
  • 按顺序接下来对 B 进行命中测试,我们发现🌟在 B 的内部,按照流程接下来检查 B 是否有子视图。
  • 我们发现 B 有一个子视图 C,所以需要对 C 进行命中测试。
  • 显然🌟不在 C 的内部,这时我们得到的信息是:触摸点在 B 的内部,但不在 B 的任一子视图内。
  • 得到结论:B 是第一响应者,并且结束命中测试。
  • 整个命中测试的走向是这样的:A✅ --> D❎ --> B✅ --> C❎ >>>> B

hitTest:withEvent:方法的伪代码

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.userInteractionEnabled || !self.hidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subView in [self.subviews reverseObjectEnumerator]) {
            CGPoint subPoint = [subView convertPoint:point fromView:self];
            UIView *bestView = [subView hitTest:subPoint withEvent:event];
            if (bestView) {
                return bestView;
            }
        }
        return self;
    }
    return nil;
}

小心越界 针对这个流程举个额外的例子,如果按下图的视图层级和触摸点来判断的话,最终获得第一响应者仍然是 B,甚至整个命中测试的走向和之前是一样的:A✅ --> D❎ --> B✅ --> C❎ >>>> B,究其原因是在 D 检查触摸点是否在自身内部时,答案是否,所以不会去对 E 进行命中测试,即使看起来我们点了 E。这个例子告诉我们,要注意可点击的子视图是否会超出父视图的范围。另若有这种情况可以重写

1.2.2事件的响应流程

找到第一响应者后,需要逆着寻找第一响应者的方向(从第一响应者->UIApplication)来响应事件。

流程如下:

  • 首先通过hitTest:withEvent:确定第一响应者,以及相应的响应链;
  • 判断第一响应者能否响应事件,如果第一响应者能进行响应,那么响应链的传递终止。如果第一响应者不能响应则将事件传给nextResponder也就是通常的superview进行事件响应;
  • 如果事件继续上报至UIWindow并且无法响应,它将会把事件继续上报给UIApplication;
  • 如果事件继续上报至UIApplication并且也无法响应,将会将事件上报给其delegate;
  • 如果最终事件依旧未被响应则会被系统抛弃;

1.2.3 小结

总的来说,触摸屏幕后事件的传递可以分为以下几个步骤:

  • 通过命中测试来找到「第一响应者」
  • 由「第一响应者」来确定「响应链」
  • 将事件沿「响应链」传递
  • 事件被某个响应者接收,或没有响应者接收从而被丢弃

2.UIGestureRecognizer、UIControl

上面我们讲述了UIResponder响应触摸事件的过程,但除了UIResponder之外,UIGestureRecognizer、UIControl同样具备对事件的处理能力。

2.1 UIGestureRecognizer

当触摸发生或者触摸的状态发生变化时,UIWindow都会传递事件寻求响应。

  • -UIWindow先将触摸事件传递给响应链上绑定的手势识别器,再发送给触摸对象对应的第一响应者。

  • 手势识别器识别手势期间,若触摸对象的触摸状态发生变化,事件都是先发送给手势识别器,再发送给第一响应者。

  • 手势识别器如果成功识别手势,则通知UIApplication取消第一响应者对于事件的响应,并停止向第一响应者发送事件。

  • 如果手势识别器未能识别手势,而此时触摸并未结束,则停止向手势识别器发送事件,仅向第一响应者发送事件。

  • 如果手势识别器未能识别手势,且此时触摸已经结束,则向第一响应者发送end状态的touch事件,以停止对事件的响应。

手势识别器的3个属性:

  • cancelsTouchesInView:默认为YES。表示当手势识别成功后,取消最佳响应者对象对于事件的响应,并不再向最佳响应者发送事件。若设置为NO,则表示在手势识别器识别成功后仍然向最佳响应者发送事件,最佳响应者仍响应事件。

  • delaysTouchesBegan:默认为NO,即在手势识别器识别手势期间,触摸对象状态发生变化时,都会发送给最佳响应者,若设置成YES,则在识别手势期间,触摸状态发生变化时不会发送给最佳响应者。也就是说,假如设置该属性为 YES ,在整个过程中识别手势又是成功的话,视图的 touches 系列方法将不会被触发。

  • delaysTouchesEnded:默认为YES。默认情况下当手势识别器未能识别手势时,若此时触摸已经结束,则会立即通知Application发送状态为end的touch事件给最佳响应者以调用touchesEnded:withEvent:结束事件响应;若设置为YES,则会在手势识别失败时,延迟一小段时间(0.15s)再调用响应者的 touchesEnded:withEvent:。

2.2 UIControl

UIControl是系统提供的能够以target-action模式处理触摸事件的控件,iOS中UIButton、UISegmentedControl、UISwitch等控件都是UIControl的子类。

值得注意的是,UIConotrol是UIView的子类,因此本身也具备UIResponder应有的身份。 关于UIControl,此处介绍两点:

  • target-action机制

  • 触摸事件优先级 Target-Action机制

Target-action是一种设计模式,直译过来就是”目标-行为”。当我们通过代码为一个按钮添加一个点击事件时,通常是如下处理:

[button addTarget:self action:@selector(tapButton:) forControlEvents:UIControlEventTouchUpInside];

即当事件发生时,事件会被发送到控件对象中,然后再由这个控件对象去触发target对象上的action行为,来最终处理事件。因此,Target-Action机制由两部分组成:即目标对象Target和行为Selector。目标对象指定最终处理事件的对象,而行为Selector则是处理事件的方法。

UIControl作为能够响应事件的控件,必然也需要待事件交互符合条件时才去响应,因此也会跟踪事件发生的过程。不同于UIResponder以及UIGestureRecognizer通过touches系列方法跟踪,UIControl有其独特的跟踪方式:

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event {
    NSLog(@"%s",__func__);
    return YES;
}

- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event {
    NSLog(@"%s",__func__);
    return YES;
}

- (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event {
    NSLog(@"%s",__func__);
}

- (void)cancelTrackingWithEvent:(nullable UIEvent *)event {
    NSLog(@"%s",__func__);
}

这4个方法和UIResponder的那4个方法几乎吻合,只不过UIControl只能接收单点触控,因此接收的参数是单个UITouch对象。这几个方法的职能也和UIResponder一致,用来跟踪触摸的开始、滑动、结束、取消。不过,UIControl本身也是UIResponder,因此同样有touches系列的4个方法。事实上,UIControl的 Tracking 系列方法是在touch 系列方法内部调用的。比如 beginTrackingWithTouch 是在 touchesBegan 方法内部调用的, 因此它虽然也是UIResponder,但touches 系列方法的默认实现和UIResponder本类还是有区别的。

Target-Action的管理:

UIControl通过addTarget方法和removeTarget方法来添加和删除Target-Action的操作。

// 添加
- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents
 // 删除
- (void)removeTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents

3 事件传递&响应流程总结

  • 系统通过 IOKit.framework(系统内核的库)来处理硬件操作,其中屏幕处理也通过IOKit完成(IOKit可能是注册监听了屏幕输出的端口)。当用户操作屏幕,IOKit收到屏幕操作,会将这次操作封装为IOHIDEvent对象。通过mach port(IPC进程间通信)将事件转发给SpringBoard(相当于手机的桌面)来处理。

  • SpringBoard是iOS系统的桌面程序。SpringBoard收到mach port发过来的事件,唤醒main runloop来处理。

  • main runloop将事件交给source1处理,source1会调用__IOHIDEventSystemClientQueueCallback()函数。函数内部会判断,是否有程序在前台显示,如果有则通过mach port将IOHIDEvent事件转发给这个程序。如果前台没有程序在显示,则表明SpringBoard的桌面程序在前台显示,也就是用户在桌面进行了操作。__OHIDEventSystemClientQueueCallback()函数会将事件交给source0处理,source0会调用__UIApplicationHandleEventQueue()函数,函数内部会做具体的处理操作。

  • 例如用户点击了某个应用程序的icon,会将这个程序启动。应用程序接收到SpringBoard传来的消息,会唤醒main runloop并将这个消息交给source1处理,source1调用__IOHIDEventSystemClientQueueCallback()函数,在函数内部会将事件交给source0处理,并调用source0的__UIApplicationHandleEventQueue()函数。在__UIApplicationHandleEventQueue()函数中,会将传递过来的IOHIDEvent转换为UIEvent对象。

  • 在函数内部,将事件放入UIApplication的事件队列,等到处理该事件时,将该事件出队列,UIApplication将事件传递给窗口对象(UIWindow),如果存在多个窗口,则从后往前询问最上层显示的窗口

  • 窗口UIWindow通过hitTest和pointInside操作,判断是否可以响应事件,如果窗口UIWindow不能响应事件,则将事件传递给其他窗口;若窗口能响应事件,则从后往前询问窗口的子视图。

  • 以此类推,如果当前视图不能响应事件,则将事件传递给同级的上一个子视图;如果能响应,就从后往前遍历当前视图的子视图。

  • 如果当前视图的子视图都不能响应事件,则当前视图就是第一响应者。

  • 找到第一响应者,事件的传递的响应链也就确定的。

  • 如果第一响应者非UIControl子类且响应链上也没有绑定手势识别器UIGestureRecognizer;

  • 那么由于第一响应者具有处理事件的最高优先级,因此UIApplication会先将事件传递给它供其处理。首先,UIApplication将事件通过 sendEvent: 传递给事件所属的window,window同样通过 sendEvent: 再将事件传递给hit-tested view,即第一响应者,第一响应者具有对事件的完全处理权,默认对事件不进行处理,传递给下一个响应者(nextResponder);如果响应链上的对象一直没有处理该事件,则最后会交给UIApplication,如果UIApplication实现代理,会交给UIApplicationDelegate,如果UIApplicationDelegate没处理,则该事件会被丢弃。

  • 如果第一响应者非UIControl子类但响应链上也绑定了手势识别器UIGestureRecognizer;

  • UIWindow会将事件先发送给响应链上绑定的手势识别器UIGestureRecognizer,再发送给第一响应者,如果手势识别器能成功识别事件,UIApplication默认会向第一响应者发送cancel响应事件的命令;如果手势识别器未能识别手势,而此时触摸并未结束,则停止向手势识别器发送事件,仅向第一响应者发送事件。如果手势识别器未能识别手势,且此时触摸已经结束,则向第一响应者发送end状态的touch事件,以停止对事件的响应。

  • 如果第一响应者是自定义的UIControl的子类同时响应链上也绑定了手势识别器UIGestureRecognizer;这种情况跟第一响应者非UIControl子类但响应链上也绑定了手势识别器UIGestureRecognizer处理逻辑一样;

  • 如果第一响应者是UIControl的子类且是系统类(UIButton、UISwitch)同时响应链上也绑定了手势识别器UIGestureRecognizer;

  • UIWindow会将事件先发送给响应链上绑定的手势识别器UIGestureRecognizer,再发送给第一响应者,如果第一响应者能响应事件,UIControl调用调用sendAction:to:forEvent:将target、action以及event对象发送给UIApplication,UIApplication对象再通过 sendAction:to:from:forEvent:向target发送action。

  • UITouch会给gestureRecognizers和最优响应者也就是hitTestView发送消息

4.应用及面试

hitTest:withEvent:应用实例

    //扩大Button的点击区域
	- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    if (CGRectContainsPoint(CGRectInset(self.bounds, -20, -20), point)) {
        return YES;
    }
    return NO;
}
///子view超出了父view的bounds响应事件
第一种方法:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    BOOL flag = NO;
    for (UIView *view in self.subviews) {
        if (CGRectContainsPoint(view.frame, point)){
            flag = YES;
            break;
        }
    }
    return flag;
}

第二种方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}

面试:通过view查找所在(viewController)

- (UIViewController *)findViewController:(UIView *)sourceView
{

        id target=sourceView; 

        while (target) {

           target = ((UIResponder *)target).nextResponder;

           if ([target isKindOfClass:[UIViewController class]]) {

               break;
       }
   }

   return target;
}  

参考文献:
juejin.cn/post/689451…
www.jianshu.com/p/a7ae7aca2…
limeng99.club/
www.cocoachina.com/articles/26…