XiaoboTalk

iOS 响应者链


当我们讨论 “iOS 事件处理与响应者链” 的时候,其实我们想要了解的是:触摸事件是如何被正确的视图识别并且被正确的处理。当然我们都知道,iOS 设备上事件有三种(为了便于说明问题,全文都使用触摸事件来举例):
1: 触摸事件 2: 摇一摇 3: 远程控制事件
从软件层面开始,当手指触摸到屏幕后,触摸事件会被 SpringBoard 这个系统进程拿到并处理:
1: 如果没有 App 在前台,则触摸事件交由桌面系统处理,即主屏幕。 2: 否则,交给 App 处理,即 UIApplication 这个单例对象。

App 处理触摸事件的大致过程


整个处理过程的概括起来看非常简单,我觉得用两句话,就能说透彻:
1、寻找第一响应者(First Responder)视图。 2、把触摸事件封装为 UIEvent 并将其交给第一响应者,然后从第一响应者开始决策处理该 UIEvent。
注意顺序一定是先 1 后2。即先找到最佳响应事件的视图(称为第一响应者),然后 UIApplication 通过 UIWindow 把封装好的 UIEvent 直接交给第一响应者,让其决策如何处理这个 UIEvent。

1、寻找第一响应者


第一响应者(First Responder)的含义很简单,简单说就是 UIWindow 下,第一个接收到 UIEvent 的视图对象(除 UIWindow),往往也是最适合处理当前触摸事件的视图(一般是一个 UIView)。比如点击事件发生在一个按钮上,那么默认情况下,该按钮就是第一响应者。如果点击到输入框,那么输入框就是第一响应者。
notion image
first_reponder
当 Application 开始寻找第一响应者时,会首先询问当前的 UIWindow 对象。UIWindow 会遍历它所有的子视图,来找到最佳响应者, UIWindow 的子视图,在数据结构上是一棵树:
notion image
first_reponder
假设,UIButton 被点击,那么算法需要从 Root View 开始找到 UIButton;简单来讲,就是要找到包含了触摸点坐标最远的后裔节点。是否满足这个条件,通过下边两个方法来判断:
1:- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; 2: - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
这两个方法定义在 UIView 上;hitTest 内部会调用 pointInside 来判断,触摸点是否被当前视图包含(被子视图包含也算):
1、如果不包含触摸点,放弃当前树杈分支,回到最近的包含了触摸点的根视图(hitTest 方法会返回该视图对象),从另一个分叉继续寻找。 2、如果包含触摸点,递归遍历当前视图子视图,直到某个满足条件的视图没有子视图(hitTest 方法 return nil)的时候,那么该视图就是第一响应者。
这个递归函数其实也很好写,递归的过程就是数据结构中树的递归(这里可以采用深度优先的策略进行后序遍历),另外递归的结束条件很明确
1、pointInside 返回 True。 2、当前视图没有子视图,hitTest 返回 nil。
同时满足 1、2,递归结束,第一响应者被找到。另外,在寻找的过程中,一些属性会影响视图是否能成为第一响应者:
1、视图 hidden ,即使满足递归条件,也不能被做为第一响应者。 2、视图的 userInteractionEnabled 为 false,视图放弃响应用户交互,所以也没有资格成为第一响应者。
最后,开发中,经常会通过重写 hitTest 方法,来扩大按钮的点击区域。

2、开始响应 UIEvent 事件


经过上边的步骤,Application 已经找到了第一响应者对象,接下来 UIWindow 会将封装好的 UIEvent 对象,直接交给第一响应者,让其处理。
notion image
event process
所以,理论上,我们可以通过拦截-[UIWindow sendEvent:]拿到 App 内所有的事件 (包括手势识别)。

2.1、Responder 对象

视图之所以能处理 UIEvent,是因为它们都是 Responder 对象;iOS API 设计了 UIResponder 对象(Mac 上是 NSReponder),UIApplication、UIView、UIViewcontroller 都继承了它。这样屏幕上显示的视图对象同时也都是 Reponder 对象。
notion image
first_reponder
UIResponder 中定义了 4 个处理事件的重要方法,用来处理 UIEvent 对象:
- (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;
如果视图响应处理 UIEvent 事件,就需要实现上述方法;默认情况下UIView 视图(除 UIControl、UIScrollView 及其子类,下同) 并没有实现该方法,此时会走 UIResponder 的默认实现,默认实现具体做的是:传递响应者链

2.2、响应者链 Responder Chain

当第一响应者不能处理 UIEvent 的时候,事件会被转发给下一个响应者(next Responder),下一个响应者不能处理,则继续传递给 next Responder ,以此类推下去,便形成了响应者链。UIResponder 的属性 nextResponder 是建立响应者链的桥梁:
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
响应者链的链路是: subview –> (viewController) –> parentView –> … –> UIWindow –> UIApplication (下图的右半部分是 macOS 上的链路) :
notion image
responder_chain
当执行addSubView:addChildViewController:方法的时候,parentView 和 parentViewController 会自动被设置为 nextResponder
当然,可以通过重写-touchesBegan等方法,来切断响应者链条。具体做法是自己的实现中不调用-[super touchesBegan ]即可。

UIControl 的 Target-Action 设计模式


在 UIControl 及其子类(UIButton …)的设计上,iOS Api 采用了 Target-Action 的设计模式。宏观上来看,这并不属于响应者链的一部分,它只是事件处理的一个末端机制。
-[button addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside];
上边的方法调用,几乎是开发者天天写的代码,也是典型的 Target-Action 设计模式,从方法名就能看出来:
第一参数就是Target 第二参数就是Action 第三参数是对 UIEvent 的抽象映射
Target 就是 UIEvent 的新的作用对象(响应者),Action 是对该 UIEvent 做出响应的具体动作(响应)。UIControl 通过这种 Target-Action 的方式对 UIEvent 进行了转发,从而可以把 UIEvent 事件转发给任意对象处理(原本只有 UIReponder 对象和手势识别器对象才能处理)。
UIControl 实现的具体做法是,重写-touchesBegan相关方法,改变响应者链来实现:
1、首先在-touchesBegan方法中调用-sendAction:to:forEvent:把消息先转发给 UIApplication,让其统一处理; 2、然后 UIApplication 调用sendAction:to:from:forEvent:把消息交给具体的 Target 处理。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self sendAction:@selector(buttonClicked:) to:target forEvent:event]; } - (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event { [[UIApplication sharedApplication] sendAction:action to:target from:self forEvent:event]; }
notion image
Target Action

关于手势识别器


为了更方便的处理一些常用的事件,UIKit 提供了手势识别器。手势识别更像一个顶层设计,通过 Target-Action 设计模式,快速实现常用事件的响应,同时又不会参与到响应者链条这种繁杂的事件传递中。
1、手势识别属于事件处理阶段,工作在找到了第一响应者之后,但不参与响应者链条的传递。 2、如果成功 hitTest 到第一响应者,且第一响应者绑定了手势识别,那么事件会优先传给手势识别器。 3、手势识别需要一定的过程,比如 pan 手势的识别,需要先经历几个 touches began 与 touches move 事件,随后 pan 手势识别成功,默认会拦截后续的所有 touches 事件。此后,view 自身的 touches 相关方法不再回调。不过可以通过修改 cancelsTouchesInView = false,让 view 和 手势识别同时响应事件。
4、手势识别和 UIControl 类似,通过 UIApplication 中转实现 Target-Action 模式。
手势识别本身还有很多强大的功能待挖掘,这里不再展开。 (全文完,可以点赞,转发,转载请注明出处,谢谢)

参考文档


  1. https://developer.apple.com/library/archive/documentation/General/Conceptual/Devpedia-CocoaApp/Responder.html#//apple_ref/doc/uid/TP40009071-CH1-SW1
  1. https://developer.apple.com/library/archive/documentation/General/Conceptual/Devpedia-CocoaApp/EventHandlingiPhone.html#//apple_ref/doc/uid/TP40009071-CH13-SW1
  1. https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Target-Action/Target-Action.html#//apple_ref/doc/uid/TP40010810-CH12
  1. https://developer.apple.com/documentation/uikit/uigesturerecognizer