初探 iOS 事件分发机制

前言

今天看到一道面试题:UIWindowUIView 有何区别?

关于问题的答案,你可以自行 Google。要说这篇文章的由来,是因为答案中说到:UIWindow 管理和协调着事件的分发,这一点引起了我的疑问。

那么,在 iOS 中,事件分发机制的概念与具体的流程是怎么样子的呢?如果你之前也未曾了解,亦或是一知半解,不如今天就和我一起学习下事件分发机制吧!


UIEvent

UIEvent 表示用户交互的事件对象,在其类文件中,我们可以看到有一个枚举类型的属性 UIEventType,这个属性表示了当前的响应事件类型(Event Type):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//
// UIEvent.h
// UIKit
//
// Copyright (c) 2005-2015 Apple Inc. All rights reserved.
//
@property(nonatomic,readonly) UIEventType type

typedef NS_ENUM(NSInteger, UIEventType) {
UIEventTypeTouches,
UIEventTypeMotion,
UIEventTypeRemoteControl,
UIEventTypePresses NS_ENUM_AVAILABLE_IOS(9_0),
};
  1. 触控事件(UIEventTypeTouches):单点、多点触控以及各种手势操作;
  2. 传感器事件(UIEventTypeMotion):重力、加速度传感器等;
  3. 远程控制事件(UIEventTypeRemoteControl):远程遥控 iOS 设备多媒体播放等;
  4. 按压事件(UIEventTypePresses):3D Touch(iOS 9);

本文主要针对 Touch events 的分发进行讲解。


UITouch

UITouch 表示单个点击,其类文件中,我们同样可以看到有一个枚举类型的属性 UITouchPhase,这个属性是用来表示当前点击的状态:

1
2
3
4
5
6
7
8
9
@property(nonatomic,readonly) UITouchPhase phase;

typedef NS_ENUM(UITouchPhase,UITouchPhase{
NSTouchPhaseBegin,
NSTouchPhaseMoved,
NSTouchPhaseStationary,
NSTouchPhaseEnded,
NSTouchPhaseCanceled,
};

这些状态包括点击开始、移动、停止不动、结束和取消五个状态。

每次点击发生的时候,点击对象都放在一个集合中传入 UIResponder 的回调方法中,我们通过集合中对象获取用户点击的位置。

UITouch 方法

可以通过以下方法获取用户点击的位置:

1
2
3
4
//获取点击坐标点
-(CGPoint) locationInView:(UIView *)view;
//获取上个点击位置的坐标点
-(CGPoint) previousLocationInView:(UIView *)view;

Responder Object

Responder Object(响应者对象)是能够响应并处理事件的对象,是构成 Responder Chain(响应链)和事件传递链的节点。

在 iOS 中,只有 UIResponder类的子类才能响应事件,而我们熟知的 UIApplication/UIView/UIViewController 都是 UIResponder 的子类,UIResponder 声明了用于处理事件的接口,并定义了默认的行为:

1
2
3
4
- (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:(nullable NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;

这四个方法分别处理触摸开始事件、触摸移动事件、触摸终止事件、以及触摸跟踪取消事件。

再来看下 UIResponder 的继承链:

First Responder

First Responder(第一响应者)用于第一个接收事件,通常情况下,第一响应者是一个视图对象。

一个对象可以通过以下操作成为第一响应者:

  1. 重写 canBecomeFirstResponder 方法,并返回 YES
  2. 接收 becomeFirstResponder 消息,如果必要,一个对象可以给自己发这个消息

要点

  1. CALayer 不是 UIResponder 的子类,这说明 CALayer 无法响应事件,这也是 UIViewCALayer 的重要区别之一

Responder Chain

当一个事件发生时,如果 First Responder 不进行处理,事件就会继续往下传递,被下个 Responder 接收,如果下个 Responder 仍不处理,又会被下下个 Responder 接收……直到一个 Responder 处理了事件或者没有 Responder 了。这些 Responder 按照传递次序连接起来的链条就构成了 Responder Chain(响应者链)。

下图直观地反应了事件传递的流程:

从图中可以看到,响应者链有以下特点:

  1. 响应者链通常是由 initial view 开始

  2. UIView 的 nextResponder 是它的 Super View;如果 UIView 已经是其所在的 UIViewController 的 Top View,那么 UIView 的 nextResponder 就是 UIViewController

  3. UIViewController 如果有 Super ViewController,那么它的 nextResponder 为其 Super ViewController 最表层的 View;如果没有,那么它的 nextResponder 就是 UIWindow

  4. UIWindow 的 contentView 指向 UIApplication,将其作为 nextResponder

  5. UIApplication 是一个响应者链的终点,它的 nextResponder 指向 AppDelegate(文档中说是 nil,如果有同学有明确的答案请告知),整个 Responder Chain 结束

需要说明是:如果当前的 Responder 不处理事件,并希望将其传递给 nextResponder 时,需要手动编写代码,才会继续往下传递,否则事件会被废弃:

1
2
3
4
5
6
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
// 将事件传递给 nextResponder
id theNextResponder = [self nextResponder];
[theNextResponder touchesBegan:touches withEvent:event];
}

要点

  1. UIResponder 本身是不会去存储或者设置 nextResponder 的,所谓的 nextResponder 都是子类去实现的

Hit-Test View & Hit-Testing

Hit-Test View:当用户与触摸屏产生交互时,硬件就会探测到物理接触并且通知操作系统。操作系统就会创建相应的事件,并将其传递给当前正在运行的应用程序的事件队列。然后这个事件会被事件循环传递给优先响应对象,既 Hit-Test View

Hit-Testing:Hit-Test View 就是事件被触发时和用户交互的对象,寻找 Hit-Test View 的过程就叫做 Hit-Testing

UIView 中定义了如下两个函数:

1
2
3
4
5
6
7
8
//
// UIView.h
// UIKit
//
// Copyright (c) 2005-2015 Apple Inc. All rights reserved.
//
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; // default returns YES if point is in bounds

系统先调用 pointInSide: WithEvent: 判断当前视图以及这些视图的子视图是否能接收这次点击事件,然后在调用 hitTest: withEvent: 依次获取处理这个事件的所有视图对象,在获取所有的可处理事件对象后,开始调用这些对象的 touches 回调方法。

下面通过两张图来观察:

图片来自

UIWindow 有一个 MianVIew,MainView 里面有三个 Sub View:View A、View B、View C,他们各自有两个 Sub View,他们层级关系是:View A 在最下面,View B 中间,View C 最上(按照 addSubview 的顺序),其中 View A 和 View B 有一部分重叠。如果手指在 View B.1 和 View A.2 重叠的上面点击,按照上面说的递归方式,顺序如下图所示:

图片来自

递归是向界面的根节点 UIWindow 发送 hitTest:withEvent: 消息开始的,从这个消息返回的是一个 UIView,也就是手指当前位置最前面的那个 Hittest View。当向 UIWindow 发送 hitTest:withEvent: 消息时,hitTest:withEvent: 里面所做的事,就是判断当前的点击位置是否在 Window 里面,如果在则遍历 Window 的 Subview 然后依次对 Subview 发送 hitTest:withEvent: 消息(注意这里给 Subview 发送消息是根据当前 Subview 的 index 顺序,index 越大就越先被访问)。如果当前的 point 没有在 View 上面,那么这个 View 的 Subview 也就不会被遍历了。当事件遍历到了 View B.1,发现 point 在 View B.1 里面,并且 View B.1 没有 Subview,那么他就是我们要找的 Hittest View 了,找到之后就会一路返回直到根节点,而 View B 之后的 View A 也不会被遍历了。


总结

所以关于事件的链有两条:事件的响应链;Hit-Testing 时事件的传递链

  • 事件响应链:由离用户最近的 View 向系统传递:
    initial view –> Super View –> …… –> View Controller –> Window –> Application –> AppDelegate

  • 事件传递链:由系统向离用户最近的 View 传递:
    UIKit –> active app’s event queue –> Window –> Root View –> …… –> Lowest View

具体的应用可以参见:事件传递响应链


参考