调试iOS用户交互事件响应流程
2020/3/22 23:03:19
本文主要是介绍调试iOS用户交互事件响应流程,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
[TOC]
调试iOS用户交互事件响应流程
2020-03-19
通常 iOS 界面开发中处理各种用户交互事件。其中,UIControlEvent
以注册的 Target-Action 的方式绑定到控件;UIGestureRecognizer
通过addGestureRecognizer:
添加到UIView
的gestureRecognizers
属性中;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
实现了touchesXXX
、pressXXX
、motionXXX
分别用于响应用户的触摸、按压、运动(例如UIEventSubtypeMotionShake
)交互事件。UIResponder
包含nextResponder
属性。UIView
、UIWindow
、UIController
、UIApplication
都是UIResponder
的派生类,所以都能响应以上事件。
1.1 Next Responder
响应链结构如下图所示,基本上是通过UIResponder
的nextResponder
成员串联而成,基本上是按照 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
响应touchesXXX
、pressXXX
、motionXXX
事件不需要指定userInteractionEnabled
为YES
。但是对于UIView
则需要指定userInteractionEnabled
,原因是UIView
重新实现了这些方法。响应UIGesture
则需要指定userInteractionEnabled
,addGestureRecognizer:
是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 指定为继承自UIButton
的TestEventsButton
类型。然后在TestEventsLabel
、TestEventsButton
、ViewController
中,为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
属性仍然是不变的。UITouch
的gestureRecognizers
属性表示正在处理该 touch 事件的所有 gesture recognizer。UITouch
的timestamp
属性表示 touch 事件的发生时间或者上一次修改的时间。UITouch
的phase
属性,表示 touch 事件当前所在的生命周期阶段,包括UITouchPhaseMoved
、UITouchPhaseBegan
、UITouchPhaseStationary
、UITouchPhaseEnded
、UITouchPhaseCanceled
。
2.1 碰撞检测
UIKit 通过 hit-test 碰撞检测确定哪些 View 需要响应 touch 事件,hit-test 通过比较 touch 的位置与 View 的 bounds 判断 touch 是否与 View 相交。Hit-test 是在 View 的视图层级中,取层级最深的子视图,作为 touch 事件的 first responder,然后从前向后递归地对每个子视图进行 Hit-test,直到子视图命中,直接返回命中的子视图。
Hit-test 通过UIView
的hitTest:withEvent:
方法实现,若 touch 的位置超出了 view 的 bounds 范围,则hitTest:withEvent:
会忽略该 view 及其所有子视图。所以,当 view 的maskToBounds
为NO
时,即使 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;
方法实现的,该方法忽略userInteractionEnabled
为NO
或者 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.view
的hitTest:withEvent:
在迭代到“点我前Button”时命中了目标,因此直接返回“点我前Button”。而更后面的“点我前Button”就直接被跳过了。
为验证上面的推测。继续在 Demo 中引入继承自UIView
的TestEventsView
类型,套路和前面的 Button、Label 一致,就是为了打印关键日志。然后将 Controller 的根视图,也就是self.view
的类型设置为TestEventsView
。然后再在 Controller 的viewDidLoad
中增加打印 Button 信息的代码以作对照。
准备就绪,运行 Demo,点击“点我前Button”,得到以下日志,干扰信息变多了,遮挡掉其中一部分。关注到红色框中的内容,发现self.view
的hitTest:forEvent:
返回的正是“点我前Button”,而且“点我前Button”的hitTest:forEvent:
返回了自身。与前面的推测完全符合。
步骤零:准备工作
前一小节的调试过程其实已经可以证明改结论,但是由于只是通过对有限的相关共有方法,譬如hitTest:forEvent:
、nextResponder
的调用次序的打印似乎还不够深入。接下来用 lldb 下断点的方式,进行调试。
在这之前需要做一些准备工作,这次是使用 lldb 调试主要通过查看函数调用栈、寄存器数据、内存数据等方式分析,因此不需要打印日志的操作,况且新增的hitTest:withEvent
、nextResponder
、touchesXXX
方法会徒增调用栈的层数,因此将TestEventsLabel
、TestEventsButton
、TestEventsView
、ViewController
的这些方法悉数屏蔽。去掉一切不必要的日志打印逻辑。
准备就绪,运行 Demo,先不急着开始,首先查看 Demo 的视图层级,先记住这个UIWindow
实例,它是应用的主窗口,它的内存地址是0x7fa8f10036b0
,后面会用到。
注意:从 iOS 13 开始,引入了
UIWindowScene
统一管理应用的窗口和屏幕,UIWindowScene
包含windows
和screen
属性。上图所展示UIWindowScene
只包含了一个子 Window,实际真的如此吗?
步骤一:下断点
首先使用break point -n
命令在四个关键方法处下断点:
hitTest:withEvent:
nextResponder
touchesBegan:withEvent:
touchesEnded:withEvent:
注意:汇编代码中的函数通常以
pushq %rbp
、movq %rsp, %rbp
开头,其中bp
是基地址寄存器(base pointer),sp
是堆栈寄存器(stack pointer),bp
保存当前函数栈帧的基地址(栈底),sp
保存当前函数栈帧的下一个可分配地址(栈顶),函数每分配一个单元的栈空间,sp
自动递增,而bp
保持不变。相应地,函数返回前都会有popq %rbp
操作。
步骤二:简单分析 touch 事件在 Window 层的分发
点击“点我前Button”,很快触发了第一个hitTest:withEvent:
的断点。先用bt
命令查看当前调用栈,发现第 0 帧调用了UIAutoRotatingWindow
的hitTest:withEvent:
,打印寄存器数据获取到r14
、r15
都传递了UIWindow
参数,但实际上调用该方法的是一个UITextEffectsWindow
实例,UITextEffectsWindow
是UIAutoRotatingWindow
。它的内存地址是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。
为什么新版本 iOS 的 touch 事件传递过程,需要分离出 Window 层和 View 层阶段?是因为自 iOS 13 起引入UIWindowScene
后,UITextEffectsWindow
和 main window 有各自的视图层级,且两者都没有superview
,因此必须修改 touch 的传递策略,让事件都能分发到两个 window 中。
注意:原本猜想,C 语言转化为汇编语言时,遵循声明一个局部变量就要分配一个栈空间的,调用函数时需要将形参和返回值地址推入堆栈,然而从调试过程中查看 Objective-C 的汇编代码,其实现并不是如此。由于现代处理器包含了大量的高效率存储器,因此 clang 编译时会最大限量地合理利用起这些寄存器(通常是通用寄存器)以提高程序执行效率。通常传递参数用到最多的是
r12
、r13
、r14
、r15
寄存器,但绝不仅限于以上列举的几个。这给源代码调试增加了很大的难度。
步骤三:分析 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”。
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
控件作为第一响应者的情况的,通过定制UIControl
类touchesBegan: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:
方法,具体传递过程和步骤四中一致。同样要区分UIControl
和UIResponder
的处理。
最后,无论是UIControl
还是UIResponder
,在完成所有touchesEnded:withEvent:
处理后,都要额外再从第一响应者开始遍历一次响应链。从调用栈可以看到是为了传递UIResponder
的_completeForwardingTouches:phase:event
消息。具体原因不太清楚。
三、RunLoop与事件(TODO)
行文至此,文章篇幅已经有点长,因此在下一篇文章中在调试这部分内容。
四、总结
- 无论是使用
UIControl
的 Target-Action 方式还是UIResponder
的touchesXXX
方式处理用户事件,都涉及到 Hit-Test 和 响应链的内容; UIControl
使用 Target-Action 注册用户事件,当后面的控件被前面的控件覆盖时,若用户事件(UIEvent
)被前面的控件拦截(无论前面的控件有没有注册 Target-Action),则后面的控件永远得不到处理事件的机会,即使前面的控件未注册 Target-Action;UIControl
使用 Target-Action 注册用户事件,指定 Target 为空时,Action 消息会沿着响应链传递,直到找到能响应 Action 的 Responder 为止,Action 一旦被其中一个 Responder 响应,响应链后面的对象就不再处理该 Action 消息;- 响应链是以 View 为起始,向 superview 延伸的一个反向树型结构,通过
UIResponder
的nextResponder
串联而成; - 当 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 事件;
这篇关于调试iOS用户交互事件响应流程的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2022-10-05Swift语法学习--基于协议进行网络请求
- 2022-08-17Apple开发_Swift语言地标注释
- 2022-07-24Swift 初见
- 2022-05-22SwiftUI App 支持多语种 All In One
- 2022-05-10SwiftUI 组件参数简写 All In One
- 2022-04-14SwiftUI 学习笔记
- 2022-02-23Swift 文件夹和文件操作
- 2022-02-17Swift中使用KVO
- 2022-02-08Swift 汇编 String array
- 2022-01-30SwiftUI3.0页面反向传值