当手指轻触屏幕,整个系统像沉睡的生灵突然被惊醒,然后经历过腥风血雨的一段奇幻旅行,最终又归于沉寂。
整个iOS触摸事件从产生到寂灭大致如下图:
系统响应阶段
- 手指触摸屏幕,屏幕硬件感应到输入事件并交由IOKit驱动处理;
I/O Kit是用于创建设备驱动程序的系统框架、库、工具和其它资源的集合,基于受限的c++形式(主要是继承和重载)实现面向对象的编程模型,简化了设备驱动传给你续开发的过程。相关的驱动开发命令行工具:
kextload/kextunload
、kextstat
、kextcache
、iostat(显示终端、磁盘和cpu操作的内核i/o统计信息)
、ioalloccount
、gcc/gdb
等。
- IOKit用户空间框架IOKit.framework将触摸事件封装成一个IOHIDEvent对象,并通过mach port传递给SpringBoard.app进程;
SpringBoard.app 是 iOS 和 iPadOS 负责管理主屏幕的基础程序,并在设备启动时启动 WindowServer、开启应用程序(实现该功能等程序称为应用启动器)和对设备进行某些设置。有时候主屏幕也被作为 SpringBoard 的代称。主要处理按键(锁屏/静音等)、触摸、加速、距离传感器等几种事件,随后通过
mac port
进程间通信转发至需要的APP。
Mac OSX中使用的是Launchpad,能让用户以从类似于iOS的SpringBoard的界面按一下图示来启动应用程式。在启动台推出之前,用户能以Dock、Finder、Spotlight或终端启动应用。不过 Launchpad 并不会占据整个主屏幕,而更像是一个 Space(类似于仪表板)。
桌面响应阶段
- SpringBoard.app进程主线程RunLoop收到IOKit.framework传递来的消息苏醒,并触发对应mach port的
Source1
回调__IOHIDEventSystemClientQueueCallback()
; - SpringBoard.app进程判断桌面是否存在前台应用,若有则直接转发给前台应用;若无(如处于桌面翻页),则触发SpringBoard.app应用内部主线程RunLoop的
Source0
事件回调,由桌面应用内部消耗;
APP响应阶段
- 应用启动时会开启
com.apple.uikit.eventfetch-thread
线程RunLoop
并注册souce1
类型事件,用于接收SpringBoard.app
发送的mach port source1
消息; -
com.apple.uikit.eventfetch-thread
线程接收到source1
消息后,执行__IOHIDEventSystemClientQueueCallback
回调,并将main runloop
中__handleEventQueue
所对应的source0
事件设置为signalled=Yes
状态,同时唤醒主线程runloop
,主线程则调用__handleEventQueue
来进行事件队列的处理,如下图所示:
主线程RunLoop
中与事件相关的关键事件源为:
<CFRunLoopSource 0x600001608240 [0x7fff8062ce40]>{signalled = No, valid = Yes, order = -1, context = <CFRunLoopSource context>{version = 0, info = 0x6000018101a0, callout = __handleEventQueue (0x7fff48c64d04)}}
其回调函数为__handleEventQueue
,类型为source0
,因此主线程不处理SpringBoard.app
进程的发送的事件接收;
com.apple.uikit.eventfetch-thread
线程runloop
如下:
该线程DefaultMode
与CommonMode
均包含source1
其回调为__IOHIDEventSystemClientQueueCallback
;
- 事件队列处理是将触摸事件添加到
UIApplication
对象的事件队列中,事件出队后,UIApplication
开始寻找最佳响应者的过程Hit-Testing
,过程如下:
大致的流程即是事件自下往上传递递归询问子视图能否响应事件的过程,其中UIWindow
继承自UIView
也可作为视图,且若同一层级则后添加的子视图优先级高(对于UIWindow而言后显示的UIWindow优先级高),具体的流程如下:
-
UIApplication
将UIEvent
事件传递给窗口对象UIWindow
,若存在多个同层级的UIWindow
,则后显示的优先级高,即顶层的窗口优先级高,视图优先级等同; - 若窗口不能响应事件,则将事件传递给其他窗口;若窗口能响应事件,则从窗口子视图自下往上询问能否响应事件;
- 若能响应事件就继续子视图自下往上传递询问,直至没有能响应的子视图为止,则自身就是最适合的响应者;
对于上述能否响应事件是通过UIView
对象的hitTest:withEvent
方法来判定,具体的规则如下:
-
若当前视图无法响应事件,则返回nil;
无法响应事件的几种状态如下:
- 不允许交互:
userInteractionEnabled = NO
; - 隐藏:
hidden = YES
如果父视图隐藏,那么子视图也会隐藏,隐藏的视图无法接收时间; - 透明度:
alphs < 0.01
如果设置的视图透明度<0.01,会直接影响子视图的透明度,即子视图也透明不会接收事件;
- 不允许交互:
若当前视图可以响应事件,但子视图可以响应事件,则返回自身作为当前视图层次中的事件接收者;
若当前视图可以响应事件,同时有子视图可以响应,则返回子视图层次中的事件响应者;
hitTest:withEvent
调用栈如下图:
大致的代码逻辑如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
//3种状态无法响应事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
//触摸点若不在当前视图上则无法响应事件
if ([self pointInside:point withEvent:event] == NO) return nil;
//从后往前遍历子视图数组
int count = (int)self.subviews.count;
for (int i = count - 1; i >= 0; i--)
{
// 获取子视图
UIView *childView = self.subviews[i];
// 坐标系的转换,把触摸点在当前视图上坐标转换为在子视图上的坐标
CGPoint childP = [self convertPoint:point toView:childView];
//询问子视图层级中的最佳响应视图
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView)
{
//如果子视图中有更合适的就返回
return fitView;
}
}
//没有在子视图中找到更合适的响应视图,那么自身就是最合适的
return self;
}
其中pointInside:withEvent
方法用于判定触摸点是否在自身坐标范围内,默认实现是若在坐标范围内则返回YES,否则返回NO。因此,可通过重写UIView的hitTest:withEvent
和pointInside:withEvent
方法来修改事件的流向。
- 寻找到最佳响应者后,
UIApplication
会通过sendEvent:
将事件传递给事件所属的UIWindow
,UIWindow
同样通过sendEvent:
再将事件传递给hit-tested view
最佳响应者,过程如下:
紧接着就是事件的响应,具体就是如下的方法调用:
//手指触碰屏幕,触摸开始
- (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;
其中每个响应触摸事件的方法都会接收两个参数,分别对应触摸对象集合touches
和事件对象UIEvent
;
对于hit-tested view
最佳响应者对象拥有响应事件的最高优先级及绝对控制权:可以独占该事件,也可以将该事件往下传递,即事件的传递(响应链),具体的响应链操作方式如下:
-
不拦截,默认操作
事件会自动沿着默认的响应链往下传递
-
拦截,不再往下分发事件
重写
touchesBegan:withEvent:
进行事件处理,不调用父类的touchesBegan:withEvent:
; -
拦截,继续往下分发事件
重写
touchesBegan:withEvent:
进行事件处理,同时调用父类的touchesBegan:withEvent
将事件往下传递;
需要注意的是,事件自下往上的传递与此处事件往下传递不同,此处事件往下传递为事件的响应,而前面事件自下往上传递为查找最佳响应者,前者为“寻找”,后者为“响应”。
响应链关系如下图所示:
继承自UIResponder
的响应者对象都可以响应事件,如UIView
、UIViewController
、UIWindow
、UIApplication
,每个响应者对象都有一个nextResponder
方法,用于获取响应链中当前对象的下一个响应者对象,默认的nextResponder
实现如下:
UIView
若视图是控制器的根视图,则其nextResponder为控制器对象;否则,其nextResponder为父视图。UIViewController
若控制器的视图是window的根视图,则其nextResponder为窗口对象;若控制器是从别的控制器present出来的,则其nextResponder为presenting view controller。UIWindow
nextResponder为UIApplication对象。UIApplication
若当前应用的app delegate是一个UIResponder对象,且不是UIView、UIViewController或app本身,则UIApplication的nextResponder为app delegate。
打印响应链对象可通过如下实现:
- (void)printResponderChain
{
UIResponder *responder = self;
printf("%s",[NSStringFromClass([responder class]) UTF8String]);
while (responder.nextResponder) {
responder = responder.nextResponder;
printf(" --> %s",[NSStringFromClass([responder class]) UTF8String]);
}
}
-
若视图存在手势识别器,由于手势识别器比
UIResponder
对象具有更高的事件响应优先级,则UIWindow
优先将事件传递给手势识别器,再传给hit-tested view
,一旦手势识别器成功识别了手势,UIApplication
就会取消hit-tesed view
对事件的响应,且后续不再收到事件;若手势识别器未能识别手势且触摸并未结束,则停止向手势识别器发送事件,仅向hit-tested view
发送事件;若手势识别器选项
cancelsTouchesInView = NO
(默认为YES),则表示手势识别器成功识别手势后事件依旧会传递给hit-tested view
;delayTouchesBegan = YES
(默认为NO),则表示手势识别器在识别手势期间,截断事件,即不会将事件发送给hit-tested view
;delayTouchesEnded = NO
(默认为YES),则表示手势识别器失败时会立即通知UIApplication
对象发送状态为end
的UITouch
事件给hit-tested view
以调用touchEnded:withEvent
结束事件响应; -
若视图中存在继承自
UIView
的UIControl
对象,如UIButton
、UISegmentedControl
、UISwitch
等控件,当UIControl
跟踪到触摸事件时,会向其上添加的target
发送事件以执行action
。因
UIControl
继承于UIView
,故也具备UIResponder
的事件处理,但其方法跟踪有所不同,如下:- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event; - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event; - (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event; - (void)cancelTrackingWithEvent:(nullable UIEvent *)event;
事实上,
UIControl
的上述方法是在UITouch
方法内部调用的,比如beginTrackingWithTouch
是在touchesBegan
方法内部调用。当UIControl
跟踪事件的过程中,识别出事件交互符合响应条件,就会触发target-action
进行响应。事实上,
UIControl
监听到需要处理的交互事件时,会调用sendAction:to:forEvent:
将target
、action
、event
对象发送给UIApplication
对象,UIApplication
对象再通过sendAction:to:from:forEvent:
向target
发送action
,因此,可以重写上述方法来自定义事件执行的target
及action
。对于
UIControl
添加手势识别器的情况,无法响应target-action
事件;