iOS Touch Event from the inside
1 Touch Event 的生命周期
1.1 物理层面事件的生成
iPhone 采用电容触摸传感器,利用人体的电流感应工作,由一块四层复合玻璃屏的内表面和夹层各涂有一层导电层,最外层是一层矽土玻璃保护层。当我们手指触摸感应屏的时候,人体的电场让手指和触摸屏之间形成一个耦合电容,对高频电流来说电容是直接导体。于是手指从接触点吸走一个很小的电流,这个电流分从触摸屏的四脚上的电极流出,并且流经这四个电极的电流和手指到四个电极的距离成正比。控制器通过对这四个电流的比例做精确的计算,得出触摸点的距离。
1.2 iOS 操作系统下封装和分发事件
iOS 操作系统看做是一个处理复杂逻辑的程序,不同进程之间彼此通信采用消息发送方式,即 IPC (Inter-Process Communication)。现在继续说上面电容触摸传感器产生的 Touch Event,它将交由 IOKit.framework
处理封装成 IOHIDEvent
对象;下一步很自然想到通过消息发送方式将事件传递出去,至于发送给谁,何时发送等一系列的判断逻辑又该交由谁处理呢?
答案是 SpringBoard.app
,它接收到封装好的 IOHIDEvent
对象,经过逻辑判断后做进一步的调度分发。例如,它会判断前台是否运行有应用程序,有则将封装好的事件采用 mach port
机制传递给该应用的主线程。
Port 机制在 IPC 中的应用是 Mach 与其他传统内核的区别之一,在 Mach 中,用户进程调用内核交由 IPC 系统。与直接系统调用不同,用户进程首先向内核申请一个 port 的访问许可;然后利用 IPC 机制向这个 port 发送消息,本质还是系统调用,而处理是交由其他进程完成的。
1.3 IOHIDEvent -> UIEvent
应用程序主线程的 runloop 申请了一个 mach port 用于监听 IOHIDEvent
的 Source1
事件,回调方法是 __IOHIDEventSystemClientQueueCallback()
,内部又进一步分发 Source0
事件,而 Source0
事件都是自定义的,非基于端口 port,包括触摸,滚动,selector选择器事件,它的回调方法是 __UIApplicationHandleEventQueue()
,将接收到的 IOHIDEvent
事件对象封装成我们熟悉的 UIEvent
事件;然后调用 UIApplication
实例对象的 sendEvent:
方法,将 UIEvent
传递给 UIWindow
做一些逻辑判断工作:比如触摸事件产生于哪些视图上,有可能有多个,那又要确定哪个是最佳选项呢? 等等一系列操作。这里先按下不表。
1.4 Hit-Testing 寻找最佳响应者
Source0
回调中将封装好的触摸事件 UIEvent(里面有多个UITouch 即手势点击对象),传递给视图 UIWindow
,其目的在于找到最佳响应者,这个过程称之为 Hit-Testing
,字面上理解:hit 即触碰了屏幕某块区域,这个区域可能有多个视图叠加而成,那么这个触摸讲道理响应者有多个喽,那么“最佳”又该如何评判?这里要牢记几个规则:
- 事件是自下而上传递,即
UIApplication -> UIWindow -> 子视图 -> ...->子视图中的子视图
; - 后加的视图响应程度更高,即更靠近我们的视图;
- 如果某个视图不想响应,则传递给比它响应程度稍低一级的视图,若能响应,你还得继续往下传递,若某个视图能响应了,但是没有子视图 它就是最佳响应者。
- 寻找最佳响应者的过程中, UIEvent 中的 UITouch 会不断打上标签:比如
HitTest View
是哪个,superview
是哪个?关联了什么Gesture Recognizer
?
那么如何判定视图为响应者?由于 OC 中的类都继承自 NSObject
,因此默认判断逻辑已经在hitTest:withEvent
方法中实现,它有两个作用: 1.询问当前视图是否能够响应事件 2.事件传递的桥梁。若当前视图无法响应事件,返回 nil 。代码如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
// 1. 前置条件要满足
if (self.userInteractionEnabled == NO ||
self.hidden == YES ||
self.alpha <= 0.01) return nil;
// 2. 判断点是否在视图内部 这是最起码的 note point 是在当前视图坐标系的点位置
if ([self pointInside:point withEvent:event] == NO) return nil;
// 3. 现在起码能确定当前视图能够是响应者 接下去询问子视图
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;
}
- 首先满足几个前置条件,可交互
userInteractionEnabled=YES
;没有隐藏self.hidden == NO
;非透明self.alpha <= 0.01
———— 注意一旦不满足上述三个条件,当前视图及其子视图都不能作为响应者,Hit-Testing 判定也止步于此 - 接着判断触摸点是否在视图内部 ———— 这个是最基本,无可厚非的判定规则
- 此时已经能够说当前视图为响应者,但是不是最佳还不能下定论,因此需要进一步传递给子视图判定;注意
pointInside
也是默认实现的。
1.5 UIResponder Chain 响应链
Hit-Testing
过程中我们无法确定当前视图是否为“最佳”响应者,此时自然还不能处理事件。因此处理机制应该是找到所有响应者以及最佳响应者(自下而上),由它们构成了一条响应链;接着将事件沿着响应链自上而下传递下去 ———— 最顶端自然是最佳响应者,事件除了被响应者消耗,还能被手势识别器或是 target-action
模式捕获并消耗。有时候,最佳响应者可能对处理 Event
“毫无兴趣”,它们不会重写 touchBegan
touchesMove
..等四个方法;也不会添加任何手势;但如果是 control(控件)
比如 UIButton ,那么事件还是会被消耗掉的。
1.6 UITouch 、 UIEvent 、UIResponder
IOHIDEvent 前面说到是在 IOKit.framwork 中生成的然后经过一系列的分别才到达前台应用,然后应用主线程runloop处理source1回调中又进行source0事件分发,这里有个封装UIEvent的过程,那么 UITouch 呢? 是不是也是那时候呢?换种思路:一个手指一次触摸屏幕 生成一个 UITouch 对象,内部应该开始进行识别了,因为可能是多个 Touch,并且触摸的先后顺序也不同,这样识别出来的 UIEvent 也不同。所以 UIEvent 对象中包含了触发该事件的触摸对象的集合,通过 allTouches 属性获取。
每个响应者都派生自 UIResponder 类,本身具有相应事件的能力,响应者默认实现 touchesBegin
touchesMove
touchesEnded
touchesCancelled
四个方法。
事件在未截断的情况下沿着响应链传递给最佳响应者,伪代码如下:
0 - [AView touchesBegan:withEvent
1 - [UIWindow _sendTouchesForEvent]
2 - [UIWindow sendEvent]
3 - [UIApplication sendEvent]
4 __dispatchPreprocessEventFromEventQueue
5 __handleEventQueueInternal
6 _CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION_
7 _CFRunLOOPDoSource0
8 _CFRunLOOPDoSources0
9 _CFRunLoopRun
10 _CFRunLoopRunSpecific
11 GSEventRunModal
12 UIApplication
13 main
14 start
// UIApplication.m
- (void)sendEvent {
[window sendEvent];
}
// UIWindow.m
- (void)sendEvent{
[self _sendTouchesForEvent];
}
- (void)_sendTouchesForEvent{
//find AView Because we know hitTest View
[AView touchesBegan:withEvent];
}
1.8 几个注意点(待修)
- 重写 UIWindow 的sendEvent 方法,里面可以捕获 event, event中包含了 UIWindow view 等信息
-
UITouchesEvent
包含 UIEvent 以及 touches 数组,每个 touch 都因为 hitTest 判断逻辑绑定 window 和最佳响应view。 - 这里要注意:每一个响应者对象 UIResponder 类都有一个
nextResponder
方法,用于获取响应链中当前对象的下一个响应者(自上而下),因此一旦事件的最佳响应者确定了,整个响应链就确定了。UIResponder
类中默认实现touchesBegan touchesCancelled touchesMoved touchesEnded
方法 都有沿着响应链向下传递的实现!因此如果你重写了touchesBegan
但是没有调用[super touchesBegan]
,那么事件传递止步于此。 -
hit-Testing
首先进行,为了寻找最佳响应者;接着将UIEvent
沿着响应链自上而下传递,优先传递给 UIGestureRecognizer 然后才是hitTest View
最佳响应者;但是尽管先传递给手势识别器 但是手势识别是需要一定时间的,所以可能还是会暂时响应 touchesbegan 方法 ,一旦识别成功,则会调touchescancel。当然提供了cancelsTouchesInView
delaysTouchesBegan
delaysTouchesEnded
属性来控制传递流程。 - 证明是先传递给手势识别器,我们自定义一个手势识别器 然后重写touchesXXX 四个方法。不过手势识别器不是UIResponder的派生类,方法是定义在
UIGestureRecognizerSubclass.h
中 - 假如自定义手势识别器,识别一个点击事件,并且希望延迟0.15秒发送给hitTestView,倘若点击事件比较短,只有0.12秒 此时事件没识别消耗殆尽后被释放,那么也就没有可能再发送给hitTestView 的 touchesBegan
2 测试案例
Touch Event 的生命周期分为两个阶段:一、Hit-Testing 自下往上寻找到最佳响应者;二、 由于 UIEvent 中绑定了相关的 UIWindow,UIView 以及 Gesture。
2.1 Hit-Testing 检测顺序
测试方式:自定义 PTView
,然后重写 hitTest: withEvent:
以及 pointInside: withEvent:
,点击不同的位置,查看调用顺序。
#import "PTView.h"
IB_DESIGNABLE
@interface PTView()
@property (nonatomic, strong)IBInspectable NSString *identifier;
@end
@implementation PTView
- (void)drawRect:(CGRect)rect {
CGSize size = [self.identifier sizeWithAttributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14.f]}];
[self.identifier drawInRect:CGRectMake(0, CGRectGetHeight(rect) - size.height, size.width, size.height) withAttributes: @{NSFontAttributeName : [UIFont systemFontOfSize:14.f]}];
}
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"视图 %@ 响应了 %@ ", self.identifier,NSStringFromSelector(_cmd));
return [super hitTest:point withEvent:event];
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"视图 %@ 响应了 %@ ", self.identifier,NSStringFromSelector(_cmd));
return [super pointInside:point withEvent:event];
}
@end
XIB 视图层级:
Screen Shot 2017-10-18 at 11.54.07 PM.png
主视图
|—— A
| └──B
└── C
└──D
└──E
原则:
- 自下而上
UIWindow(s) -> UIView(s)->SubView(s) ...
以此类推 - 同一层级的视图,优先检查后加的视图
2.2 Touch Event 发送顺序
Reversed