调试iOS用户交互事件响应流程

4,733 阅读24分钟

[TOC]

调试iOS用户交互事件响应流程

2020-03-19

通常 iOS 界面开发中处理各种用户交互事件。其中,UIControlEvent以注册的 Target-Action 的方式绑定到控件;UIGestureRecognizer通过addGestureRecognizer:添加到UIViewgestureRecognizers属性中;UIResponder提供了touchesBegin/Moved/Ended/Canceled/:withEvent:motionsXXX:withEvent:pressXX:withEvent:系列接口,将用户设备的触摸、运动、按压事件通知到UIResponder对象等等。以上都是常用开发者处理用户交互事件的方式,那么隐藏在这些接口之下,从驱动层封装交互事件对象到 UI 控件接收到用户事件的流程是怎样的呢?本文主要探讨的就是这个问题。

一、响应链

Apple Documentation 官方文档Using Responders and the Responder Chain to Handle Events介绍了利用UIResponder的响应链来处理用户事件。UIResponder实现了touchesXXXpressXXXmotionXXX分别用于响应用户的触摸、按压、运动(例如UIEventSubtypeMotionShake)交互事件。UIResponder包含nextResponder属性。UIViewUIWindowUIControllerUIApplication都是UIResponder的派生类,所以都能响应以上事件。

1.1 Next Responder

响应链结构如下图所示,基本上是通过UIRespondernextResponder成员串联而成,基本上是按照 view 的层级,从前向后由子视图向父视图传递,且另外附加其他规则。总的响应链的规则如下:

  • View 的nextResponder是其父视图;
  • 当 View 为 Controller 的根视图时,nextResponder是 Controller;
  • Controller 的nextResponder是 present Controller 的控制器;
  • 当 Controller 为根控制器时,nextResponder是 Window;
  • Window 的nextResponder是 Application;
  • Application 的nextResponder是 App Delegate(仅当 App Delegate 为UIResponder类型);

响应链

UIResponder响应touchesXXXpressXXXmotionXXX事件不需要指定userInteractionEnabledYES。但是对于UIView则需要指定userInteractionEnabled,原因是UIView重新实现了这些方法。响应UIGesture则需要指定userInteractionEnabledaddGestureRecognizer:UIView类的接口。

注意:新版本中,分离了 Window 和 View 的响应链。当 Controller 为根控制器时,nextResponder实际上是nil;Windows 的nextResponder是 Window Scene;Window Scene 的nextResponder是 Application。在后面的调试过程会有体现。

1.1.1 调试nextResponder

使用一个简单的 Demo 调试nextResponder。界面如下图所示,包含三个 Label,从颜色可以判断其层次从后往前的顺序是:A >> B >> C。下面两个按钮另做他用,先忽略。

运行 Demo,查看各个元素的nextResponder,确实如前面所述。

1.2 Target-Action和响应链

UIControl控件与关联的 target 对象通信,直接通过向 target 对象发送 action 消息。虽然 Action 消息虽然不是事件,但是 Action 消息的传递是要经过响应链的。当接收到用户交互事件的控件的 target 为nil时,会沿着控件的响应链向下搜索,直到找到实现该 action 方法的对象为止。UIKit 的编辑菜单就是通过这个机制实现的,UIKit 会沿着控件的响应链搜索实现了cut:copy:paste:等方法的对象。

1.2.1 注册UIControlEvents

UIControl控件调用addTarget:action:forControlEvents:方法注册事件时,会将构建UIControlTargetAction对象并将其添加到UIControl控件的(NSMutableArray*)_targetActions私有成员中,addTarget:action:forControlEvents:方法的 Apple Documentation 注释中有声明调用该方法时UIControl并不会持有 target 对象,因此无需考虑循环引用的问题。UIControl Events 注册过程的简单调试过程如下:

UIControl Target Action

附注:The control does not retain the object in the target parameter. It is your responsibility to maintain a strong reference to the target object while it is attached to a control.

1.2.2 调试UIControlEvents的传递

前面内容提到,控件的 action 是沿着响应链传递的,那么,当两个控件在界面上存在重合的区域,那么在重合区域触发用户事件时,action 消息会在哪个控件上产生呢?在 1.1.1 中的两个重合的按钮就是为了验证这个问题。

稍微改造一下 1.1.1 的 Demo 程序,将 Label A、B、C 指定为自定义的继承自UILabel的类型TestEventsLabel,将两个 Button 指定为继承自UIButtonTestEventsButton类型。然后在TestEventsLabelTestEventsButtonViewController中,为touchesXXX:系列方法、nextResponder方法、hitTest:withEvent:方法添加打印日志的代码,以TestEventsButton的实现为例(当然也可以用 AOP 实现):

@implementation TestEventsButton

-(UIResponder *)nextResponder{
    UIResponder* responder = [super nextResponder];
    NSLog(@"Next Responder Button %@ - return responder: %@", [self titleForState:UIControlStateNormal], responder);
    return responder;
}

-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    UIView* view = [super hitTest:point withEvent:event];
    NSLog(@"Hit Test Button %@ - return view: %@", [self titleForState:UIControlStateNormal], view);
    return view;
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesBegan:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesEnded:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}
-(void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesMoved:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}
-(void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesCancelled:touches withEvent:event];
    NSLog(@"Button %@ - %s", [self titleForState:UIControlStateNormal], __func__);
}

@end
结论一:Action不会在同级视图层级中传递

一切准备就绪,运行 Demo,点击“点我前Button”,抓取到了如下日志。注意框①中指定的 target 是self,也就是 Controller。可以发现点击事件产生,调用了若干次碰撞检测(框②),若干次nextResponder(框③),最终只调用了 Controller 中“点我前Button”的 action 方法。这是因为:

  • Target-Action 消息在传递时,永远不会在同级视图层级中传递
  • Target 非空,则 UIKit 在确认控件响应某个事件后,会直接给控件的 target 对象发送 action 消息,这个过程不存在任何视图层级传递 或 响应链传递的过程;

结论二:Target为空时Action仍可以被响应

接下来将addTarget:action:中指定的 target 设为nil。然后在TestEventsButton中也添加 action 的响应代码,如下所示。

-(void)didClickBtnFront:(id)sender{
    NSLog(@"In Button 点我前Button Did Click Action %s", __func__);
}

-(void)didClickBtnBack:(id)sender{
    NSLog(@"In Button 点我后Button Did Click Action %s", __func__);
}

点击“点我前Button”,抓取到了如下日志。这次,由TestEventsButton处理了 action 消息。说明当控件注册 action 时指定的 target 为nil时,action 消息仍然可以被响应,且 action 只响应一次。请记住,此时nextResponder被调用了 5 次。

结论三:Target为空时Action沿响应链传递

再进一步修改代码,将结论二中TestEventsButton的新增代码删除,仍然将addTarget:action:中指定的 target 设为nil。点击“点我前Button”,抓取到了如下日志。这次,处理 action 消息的是 Controller。而且从日志中我们发现,这次nextResponder调用了 6 次,确切地说,是在 Button touchBegin之后,Controller 处理 action 消息之前(如图中红框所示)。这是因为,target 为nil时,action 消息会沿着响应链传递,直到找到可以响应 action 的对象为止

可以继续尝试给“点我后Button”,直接将self.btnFront的注册 Target-Action 的代码删掉。运行 Demo,再次点击“点我前Button”,此时didClickBtnBack仍然不触发。这其实只是进一步印证了“结论一”的结论,这里不再演示。

整个调试过程下来,可以发现,被 ButtonA 覆盖的 ButtonB,所有 action 都会被 ButtonA 拦截,被覆盖的 ButtonB 不会获得任何触发 action 的机会。

1.3 手势识别和响应链

Gesture Recognizer 会在 View 之前接收 Touch 和 Press 事件,当 Gesture Recognizer 对一连串的 Touch 事件手势识别失败时,UIKit 才将这些 Touch 事件发送给 View。若 View 不处理这些 Touch 事件,UIKit 将其递交到响应链。

1.4 修改响应链

响应链主要通过nextResponder方法串联,因此重新实现UIResponder派生类的nextResponder方法可以实现响应链修改的效果。

二、Touch事件传递

当 touch 事件发生时,UIKit 会构建一个与 view 关联的UITouch实例,当 touch 位置变化时,仅改变 touch 的属性值,但不包括其view属性。即使 touch 移出了 view 的范围,view属性仍然是不变的。UITouchgestureRecognizers属性表示正在处理该 touch 事件的所有 gesture recognizer。UITouchtimestamp属性表示 touch 事件的发生时间或者上一次修改的时间。UITouchphase属性,表示 touch 事件当前所在的生命周期阶段,包括UITouchPhaseMovedUITouchPhaseBeganUITouchPhaseStationaryUITouchPhaseEndedUITouchPhaseCanceled

2.1 碰撞检测

UIKit 通过 hit-test 碰撞检测确定哪些 View 需要响应 touch 事件,hit-test 通过比较 touch 的位置与 View 的 bounds 判断 touch 是否与 View 相交。Hit-test 是在 View 的视图层级中,取层级最深的子视图,作为 touch 事件的 first responder,然后从前向后递归地对每个子视图进行 Hit-test,直到子视图命中,直接返回命中的子视图

Hit-test 通过UIViewhitTest:withEvent:方法实现,若 touch 的位置超出了 view 的 bounds 范围,则hitTest:withEvent:会忽略该 view 及其所有子视图。所以,当 view 的maskToBoundsNO时,即使 touch 看起来落在了某个视图上,但只要 touch 位置超出了 view 或者其 super view 的 bounds 范围,则该 view 仍然会接收不到 touch 事件。

碰撞检测方法- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;中,point参数是碰撞检测点在事件发生的 view 的坐标系中的坐标;event参数是使用本次碰撞检测的UIEvent事件。当目标检测点不在当前 view 的范围内时,该方法返回nil,反之则返回 view 本身。hitTest:withEvent:方法是通过调用UIView- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;方法实现的,该方法忽略userInteractionEnabledNO或者 alpha 值小于 0.01 的视图。

2.2 调试Touch事件传递

Touch 事件传递过程主要调用了hitTest:withEvent:方法,Touch 事件若未被 gesture recognizer 捕捉则最终会去到touchesXXX:系列方法。在响应链的调试时,已经见到不少hitTest:withEvent:调用的痕迹。

在第一章“结论一”的运行日志中,发现点击“点我前Button”时,也对 Label A、B、C 做了碰撞检测,且并没有对“点我后Button”做碰撞检测。注意到 Label 和 Button 都是self.view的子视图,且 Label A、B、C 在“点我前Button”之前,“点我后Button”之后。前面提到过:Hit-test 是在 View 的视图层级中,取层级最深的子视图,作为 touch 事件的 first responder,然后从前向后递归地对每个子视图进行 Hit-test。因此,self.view调用 Hit-Test 时,首先找到的是 Label C。然后,从前向后递归调用hitTest:withEvent:,因此才会有C >> B >> A >> 点我前Button的顺序。为什么到“点我后Button”没有递归到呢?这是因为self.viewhitTest:withEvent:在迭代到“点我前Button”时命中了目标,因此直接返回“点我前Button”。而更后面的“点我前Button”就直接被跳过了。

为验证上面的推测。继续在 Demo 中引入继承自UIViewTestEventsView类型,套路和前面的 Button、Label 一致,就是为了打印关键日志。然后将 Controller 的根视图,也就是self.view的类型设置为TestEventsView。然后再在 Controller 的viewDidLoad中增加打印 Button 信息的代码以作对照。

准备就绪,运行 Demo,点击“点我前Button”,得到以下日志,干扰信息变多了,遮挡掉其中一部分。关注到红色框中的内容,发现self.viewhitTest:forEvent:返回的正是“点我前Button”,而且“点我前Button”的hitTest:forEvent:返回了自身。与前面的推测完全符合。

步骤零:准备工作

前一小节的调试过程其实已经可以证明改结论,但是由于只是通过对有限的相关共有方法,譬如hitTest:forEvent:nextResponder的调用次序的打印似乎还不够深入。接下来用 lldb 下断点的方式,进行调试。

在这之前需要做一些准备工作,这次是使用 lldb 调试主要通过查看函数调用栈、寄存器数据、内存数据等方式分析,因此不需要打印日志的操作,况且新增的hitTest:withEventnextRespondertouchesXXX方法会徒增调用栈的层数,因此将TestEventsLabelTestEventsButtonTestEventsViewViewController的这些方法悉数屏蔽。去掉一切不必要的日志打印逻辑。

准备就绪,运行 Demo,先不急着开始,首先查看 Demo 的视图层级,先记住这个UIWindow实例,它是应用的主窗口,它的内存地址是0x7fa8f10036b0,后面会用到。

注意:从 iOS 13 开始,引入了UIWindowScene统一管理应用的窗口和屏幕,UIWindowScene包含windowsscreen属性。上图所展示UIWindowScene只包含了一个子 Window,实际真的如此吗?

步骤一:下断点

首先使用break point -n命令在四个关键方法处下断点:

  • hitTest:withEvent:
  • nextResponder
  • touchesBegan:withEvent:
  • touchesEnded:withEvent:

注意:汇编代码中的函数通常以pushq %rbpmovq %rsp, %rbp开头,其中bp是基地址寄存器(base pointer),sp是堆栈寄存器(stack pointer),bp保存当前函数栈帧的基地址(栈底),sp保存当前函数栈帧的下一个可分配地址(栈顶),函数每分配一个单元的栈空间,sp自动递增,而bp保持不变。相应地,函数返回前都会有popq %rbp操作。

步骤二:简单分析 touch 事件在 Window 层的分发

点击“点我前Button”,很快触发了第一个hitTest:withEvent:的断点。先用bt命令查看当前调用栈,发现第 0 帧调用了UIAutoRotatingWindowhitTest:withEvent:,打印寄存器数据获取到r14r15都传递了UIWindow参数,但实际上调用该方法的是一个UITextEffectsWindow实例,UITextEffectsWindowUIAutoRotatingWindow。它的内存地址是0x00007fa8ebe05050显然不是 main window

r14传递的地址是0x00007fa8f10036b0,正是 main window。之所以是UITextEffectsWindow接收到hitTest:withEvent:是因为Window 层中的碰撞检测是使用上图中红色框中的私有方法进行处理。接下来一步步弄清红框中的碰撞检测处理的 touch 事件的传递具体经由哪些 Window 实例。frame select 8跳到第 8 帧,跟踪到了一个UIWindow对象0x7fa8f10036b0。因此,Window 层级中最先接收到 touch 事件的确实是 main window

依次类推打印出所有栈帧的当前对象如下(有些层级到断点行时寄存器已经被修改,会找不到目标类型的实例,此时可以回到上一层打印需要传入下一层的所有寄存器的值即可):

frame 0: UITextEffectsWindow 0x00007fa8ebe05050 frame 1: UITextEffectsWindow 0x00007fa8ebe05050 frame 2: UITextEffectsWindow 0x00007fa8ebe05050 frame 3: UIWindow +(类方法) frame 4: UIWindowScene -(nil不需要使用self) frame 5: UIWindowScene 0x00007fa8ebd06c50 frame 6: UIWindowScene 0x00007fa8ebd06c50 frame 7: UIWindow +(类方法) frame 8: UIWindow 0x00007fa8f10036b0

可以进一步使用 lldb 调试命令理清上面几个对象之间的关系。首先是图一中 window scene 与 window 之间的关系。图二则打印出了UITextEffectsWindow的视图层级。图三是 main window 的视图层级,注意到红框中的对象,是否似曾相识?没错,到这里追踪到 Controller 的TestEventsView类型的根 view。

图一:WindowScene与Window之间的关系

UITextEffectsWindow视图层级
图二:UITextEffectsWindow的视图层级

图三:Main Window的视图层级

为什么新版本 iOS 的 touch 事件传递过程,需要分离出 Window 层和 View 层阶段?是因为自 iOS 13 起引入UIWindowScene后,UITextEffectsWindow和 main window 有各自的视图层级,且两者都没有superview,因此必须修改 touch 的传递策略,让事件都能分发到两个 window 中。

注意:原本猜想,C 语言转化为汇编语言时,遵循声明一个局部变量就要分配一个栈空间的,调用函数时需要将形参和返回值地址推入堆栈,然而从调试过程中查看 Objective-C 的汇编代码,其实现并不是如此。由于现代处理器包含了大量的高效率存储器,因此 clang 编译时会最大限量地合理利用起这些寄存器(通常是通用寄存器)以提高程序执行效率。通常传递参数用到最多的是r12r13r14r15寄存器,但绝不仅限于以上列举的几个。这给源代码调试增加了很大的难度。

步骤三:分析 Touch 事件的产生

注意这里的 touch 事件并不是指 UIKit 的 touch event,UIKit 的 touch event 在 UIKit 接收到来自驱动层的点击事件信号后就构建了 touch 事件的UIEvent对象。这里的 touch 事件是指经过碰撞检测确定了 touch event 的响应者从touchesBegan:withEvent:开始传递之前产生的UITouch对象。

1、现在正式开始追踪 touch 事件。已知,步骤二中打断的第一次hitTest:withEvent:命中,其调用对象是UITextEffectsWindow实例。此时点击调试工具栏中的“continue”按钮,继续执行。

注意:由于调试过程比较长,导致继续运行时 lldb 被打断需要重新运行。不过问题不大,因为前面的工作已经确定了需要追踪的关键对象。因此重新运行后,重新下断点,再记录一次关键对象的地址即可。

开始收集断点命中(包括第一次命中):

  • UITextEffectsWindow:(Hit-Test)
  • UITextEffectsWindow:(Hit-Test)(调用 UIView 的实现)
  • UIInputSetContainerView:(Hit-Test)
  • UIInputSetContainerView:(Hit-Test)(调用 UIView 的实现)
  • UIEditingOverlayGestureView:(Hit-Test)
  • UIEditingOverlayGestureView:(Hit-Test)(调用 UIView 的实现)
  • UIInputSetHostView:(Hit-Test)
  • UIInputSetHostView:(Hit-Test)(调用 UIView 的实现)
  • UIWindow:(Hit-Test)(调用 UIView 的实现)
  • UITransitionView:(Hit-Test)
  • UITransitionView:(Hit-Test)(调用 UIView 的实现)
  • UIDropShadowView:(Hit-Test)
  • UIDropShadowView:(Hit-Test)(调用 UIView 的实现)
  • TestEventsView:(Hit-Test)(调用 UIView 的实现)

至此 Hit-Test 断点命中了之前自定义的 Controller 的TestEventsView类型的根类,在这里打印一下调用栈。调用栈增加至 38 层如下图。而且上面的层次都是在调用hitTest:withEvents方法,这是个明显的递归调用的表现。而且到此为止,Hit-Test 仍然没有命中任何视图

2、继续运行收集断点信息:

  • {TestEventsLabel: 0x7fd8d48071a0; baseClass = UILabel; frame = (121 162; 250 166); text = 'C'; opaque = NO; autoresize = RM+BM; layer = <_UILabelLayer: 0x600003399040>}:(Hit-Test)(调用超类的实现)
  • {TestEventsLabel: 0x7fd8d4806df0; baseClass = UILabel; frame = (82 116; 250 166); text = 'B'; opaque = NO; autoresize = RM+BM; layer = <_UILabelLayer: 0x600003398f50>}:(Hit-Test)(调用超类的实现)
  • {TestEventsLabel: 0x7fd8d4805aa0; baseClass = UILabel; frame = (44 75; 250 166); text = 'A'; opaque = NO; autoresize = RM+BM; layer = <_UILabelLayer: 0x600003398870>}:(Hit-Test)(调用超类的实现)
  • {TestEventsButton: 0x7fd8d48056c0; baseClass = UIButton; frame = (121 478; 173 79); opaque = NO; autoresize = RM+BM; layer = <CALayer: 0x6000010813e0>}:(Hit-Test)(调用 UIControl 的实现)

Hit-Test 断点终于命中了 Demo 的自定义 Label 和 Button 控件。根据收集的信息,命中顺序是 LabelC -> LabelB -> LabelA -> 点我前Button。此时,不急着继续,在调试窗口中使用bt指令,观察到调用栈深度已经来到了 43 层之多,如下图所示。但是注意到一点,以上每次断点命中,其调用栈深度都是 43 层,也就是说上面几个同层视图的碰撞检测过程是循环迭代,而不是递归,三个TestEventsLabel调用hitTest:withEvent:都可以直接返回nil不需要递归。

3、继续运行收集断点信息:

  • TestEventsButton:(Hit-Test)(调用 UIView 的实现)
  • UIButtonLabel:(Hit-Test)(调用超类的实现)

调用栈到达了第一个高峰 49 层,如下图一所示。此时若点击继续,会发现调用栈回落到 13 层,如下图二所示。说明 Hit-Test 断点在命中UIButtonLabel后,本次 Hit-Test 递归就返回了。至于具体返回什么对象,实际上在 1.2.2 的调试日志中已经打印出来了,正是“点我前Button”。

图一:Hit-Test调用栈到达顶峰

图二:Hit-Test调用栈回落

4、继续运行,Demo 会进入第二次 Hit-Test 递归,之所以一次点击事件引发了两轮递归,是因为 touch 事件在开始和结束时,各进行了一轮碰撞检测。继续收集断点信息:

  • UIWindow:(Hit-Test)(调用 UIView 的实现)
  • UITransitionView:(Hit-Test)
  • UITransitionView:(Hit-Test)(调用 UIView 的实现)
  • UIDropShadowView:(Hit-Test)
  • UIDropShadowView:(Hit-Test)(调用 UIView 的实现)
  • TestEventsView:(Hit-Test)(调用 UIView 的实现)
  • TestEventsLabel:(Hit-Test)(调用 UIView 的实现)
  • TestEventsLabel:(Hit-Test)(调用 UIView 的实现)
  • TestEventsLabel:(Hit-Test)(调用 UIView 的实现)
  • TestEventsButton:(Hit-Test)(调用 UIControl 的实现)
  • TestEventsButton:(Hit-Test)(调用 UIView 的实现)
  • UIButtonLabel:(Hit-Test)(调用 UIView 的实现)

调用栈再次到达了高峰 41 层如下图所示。

此时先不急着继续。因为以上是 Hit-Test 在本次调试中的最后一次断点命中,点击继续 Hit-Test 递归必然返回“点我前Button”,表示碰撞检测命中了该按钮控件。第二轮 Hit-Test 的调用栈明显浅许多,不难发现其原因是该轮碰撞检测没有经过UITextEffectsWindow而直接从UIWindow开始(个中原因不太确定)。

总结 Hit-Test 的处理过程的要点是:

  • 优先检测自己是否命中,不命中则直接忽略所有 subviews
  • 若自己命中,则对所有子视图按同层级视图顺序从前向后的顺序依次进行碰撞检测,因此碰撞检测也是 superview 到 subview 的按视图层级从后向前递归的过程;
  • 若所有子视图均未命中,自己的碰撞检测才返回 nil

文字表述似乎有点不太直观,还是用咱们程序员的语言吧,伪代码如下:

- (UIView * _Nullable)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    // 1. 优先检测自己,不命中则立刻排除
    BOOL isHit = [self pointInside:point withEvent:event];
    if(!isHit){
        return nil;
    }

    // 2. 从前向后循环迭代所有子视图
    for(UIView* subviews in subviews){
        // 跨视图层级从 superview 向 subview 递归
        UIView* hitView = [subviews hitTest:point withEvent:event];
        if(hitView)
            return hitView;
    }
    
    // 3. 所有子视图未命中返回nil
    return nil;
}

步骤四:分析 touch 事件开始后的传递

情况一:点击 Button 控件时

步骤三执行完成,UIKit 产生了UITouch事件并开始传递该事件。紧接在之前的基础上继续调试。再点击 continue,收集断点信息:

  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • TestEventsButton:(Touches-Began)(调用 UIControl 的实现)

此时 Button 尝试触发 touchesBegan,开始UITouch事件传递。调用栈如下,是由 UIWindow 发送过来的 touch 事件。注意上面TestEventsButton调用的是UIControl 的实现,记住这个“猫腻”,后面的部分会再次提到。

  • TestEventsButton:(Next-Responder)(调用 UIView 的实现)

终于命中了 Next-Responder 断点,从上下两个调用栈可以发现,nextResponder是在touchBegan方法内调用的。

再点击 continue,继续运行收集断点信息:

  • TestEventsView:(Next-Responder)(调用 UIView 的实现)

nextResponder是在touchBegan方法内调用的,且增加了调用栈深度,说明nextResponder也触发了递归的过程。但是递归的不是nextResponder而是UIResponder里面的一个私有方法_controlTouchBegan:withEvent:。该方法似乎只简单遍历了一轮响应链,其他的什么都没做。

再点击 continue,继续运行收集断点信息:

  • UIViewController:(Next-Responder)(调用 UIViewController 的实现)
  • UIDropShadowView:(Next-Responder)(调用 UIView 的实现)
  • UITransitionView:(Next-Responder)(调用 UIView 的实现)
  • UIWindow:(Next-Responder)
  • UIWindowScene:(Next-Responder)(调用 UIScene 的实现)
  • UIApplication:(Next-Responder)
  • AppDelegate:(Next-Responder)(调用 UIResponder 的实现)

AppDelegate层,调用栈达到顶峰,如下图所示。

在调试过程中,发现响应链上除了第一响应者“点我前Button”外的所有对象都没有调用touchesBegan:withEvent:响应该 touch 事件。那么这就是对 touch 事件该有的处理么?其实不然,由于调试时点击的是 Button 控件,因此上述是对UIControl控件作为第一响应者的情况的,通过定制UIControltouchesBegan:withEvent:方法实现的,特殊处理。上面提到的私有方法_controlTouchBegan:withEvent:就是为了告诉后面响应链后面的响应者这个 touch 事件已经被前面的 UIControl 处理了,请您不要处理该事件

那么UIResponder原始的响应流程是怎样的呢?继续调试情况二。

情况二:点击 Label 视图

流程渐渐明朗的情况下,可以先breakpoint disable终止上面的断点,然后breakpoint delete XXX删除掉hitTest:withEvent:断点,以减少打断次数。解屏蔽掉之前屏蔽的打印日志的代码,因为当断点命中 Demo 中的自定义类时,可以直接断定nextResponder的触发类。

点击界面中的 Label C。开始收集信息(省略自定义日志打印方法只保留原始方法):

  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • _UISystemGestureGateGestureRecognizer:(Touches-Began)
  • TestEventsLabel:(Touches-Began)(调用 UIResponder 的实现)
  • TestEventsLabel:(Next-Responder)(调用 UIView 的实现)
  • TestEventsView:(Touch-Began)(调用 UIResponder 的实现)
  • TestEventsView:(Next-Responder)(调用 UIView 的实现)
  • UIViewController:(Touch-Began)(调用 UIResponder 的实现)
  • UIViewController:(Next-Responder)(调用 UIViewController 的实现)
  • UIDropShadowView:(Touch-Began)(调用 UIResponder 的实现)
  • UIDropShadowView:(Next-Responder)(调用 UIView 的实现)
  • UITransitionView:(Touch-Began)(调用 UIResponder 的实现)
  • UITransitionView:(Next-Responder)(调用 UIView 的实现)
  • UIWindow:(Touch-Began)(调用 UIResponder 的实现)
  • UIWindow:(Next-Responder)
  • UIWindowScene:(Touch-Began)(调用 UIResponder 的实现)
  • UIWindowScene:(Next-Responder)(调用 UIScene 的实现)
  • UIApplication:(Touch-Began)(调用 UIResponder 的实现)
  • UIApplication:(Next-Responder)
  • AppDelegate:(Touch-Began)(调用 UIResponder 的实现)
  • AppDelegate:(Next-Responder)(调用 UIResponder 的实现)

至此先看一下调用栈,显然touchesBegan:withEvent:也是递归的过程:

总结上面收集的信息,UIResponder作为第一响应者和UIControl作为第一响应者的区别已经相当明显了。UIResponder作为第一响应者时,是沿着响应链传递,经过的每个对象都会触发touchesBegan:withEvents:方法

步骤五:分析 touch 事件结束后的传递

Touch 事件事件结束会触发第一响应者的touchesEnded:withEvent:方法,具体传递过程和步骤四中一致。同样要区分UIControlUIResponder的处理。

最后,无论是UIControl还是UIResponder,在完成所有touchesEnded:withEvent:处理后,都要额外再从第一响应者开始遍历一次响应链。从调用栈可以看到是为了传递UIResponder_completeForwardingTouches:phase:event消息。具体原因不太清楚。

三、RunLoop与事件(TODO)

行文至此,文章篇幅已经有点长,因此在下一篇文章中在调试这部分内容。

四、总结

  • 无论是使用UIControl的 Target-Action 方式还是UIRespondertouchesXXX方式处理用户事件,都涉及到 Hit-Test 和 响应链的内容;
  • UIControl使用 Target-Action 注册用户事件,当后面的控件被前面的控件覆盖时,若用户事件(UIEvent)被前面的控件拦截(无论前面的控件有没有注册 Target-Action),则后面的控件永远得不到处理事件的机会,即使前面的控件未注册 Target-Action;
  • UIControl使用 Target-Action 注册用户事件,指定 Target 为空时,Action 消息会沿着响应链传递,直到找到能响应 Action 的 Responder 为止,Action 一旦被其中一个 Responder 响应,响应链后面的对象就不再处理该 Action 消息;
  • 响应链是以 View 为起始,向 superview 延伸的一个反向树型结构,通过UIRespondernextResponder串联而成;
  • 当 View 作为 Controller 的根 view 时,nextResponder是 Controller;
  • 当 Controller 是由其他 Controller present 而来,则nextResponder是其 present controller;
  • 当 Controller 是 Window 的根 Controller,则nextResponder是 Window,注意调试中 Controller 的nextResponder是返回nil,但实际上它们确实有这层关系;
  • Window 的nextResponder是 Window Scene;
  • Window Scene 的nextResponder是 Application;
  • Application 的nextResponder是 AppDelegate(当 AppDelegate 是UIResponder类型时);
  • Hit-Test 优先检测自己是否命中,不命中则直接忽略所有 subviews
  • Hit-Test 若自己命中,则对所有子视图按同层级视图顺序从前向后的顺序依次进行碰撞检测,因此碰撞检测也是 superview 到 subview 的按视图层级从后向前递归的过程;
  • Hit-Test 若未命中任何子视图,自己的碰撞检测才返回 nil;
  • Hit-Test 命中目标后,产生UITouch事件,UITouch事件会沿着响应链传递到后面的所有响应者;
  • UIResponder作为第一响应者响应了 touch 事件,响应链后面的所有响应者也会触发touchesXXX系列方法;
  • UIControl控件作为第一响应者响应了 touch 事件,响应链后面的所有响应者均不再处理该 touch 事件;