调试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 注册过程的简单调试过程如下:

附注: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的视图层级

图三: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
UITextEffectsWindow
UIInputSetContainerView
UIInputSetContainerView
UIEditingOverlayGestureView
UIEditingOverlayGestureView
UIInputSetHostView
UIInputSetHostView
UIWindow
UITransitionView
UITransitionView
UIDropShadowView
UIDropShadowView
TestEventsView

至此 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
UIButtonLabel

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

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

图二:Hit-Test调用栈回落

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

UIWindow
UITransitionView
UITransitionView
UIDropShadowView
UIDropShadowView
TestEventsView
TestEventsLabel
TestEventsLabel
TestEventsLabel
TestEventsButton
TestEventsButton
UIButtonLabel

调用栈再次到达了高峰 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
UIDropShadowView
UITransitionView
UIWindow
UIWindowScene
UIApplication
AppDelegate

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)

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

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章