在开发过程中,大家或多或少的都会碰到令人头疼的手势冲突问题,正好前两天碰到一个类似的bug,于是借着这个机会了解了iOS中的事件传递与处理的相关内容,整理出来方便以后查阅。
iPhone的成功,很大的一部分在于用户可以以多种方式操纵他们的设备。大体上iOS的事件分为三类:触摸事件(手势操作),运动事件(摇一摇),远程控制事件(耳机线控),本文主要整理的是触摸事件,对其它两种就不多做介绍了,感兴趣的同学可以自己查阅资料。
事件的生命周期
从手指触摸屏幕,触摸事件的传递大概经历了3个阶段,系统响应阶段-->SpringBoard.app处理阶段-->前台App处理阶段,大致的流程如下图:
uitouchflow.png
起始阶段
cpu处于睡眠阶段,等待事件发生
手指触摸屏幕
系统响应阶段
屏幕感应到触摸事件,并将感应到的事件传递给IOKit(用来操作硬件和驱动的框架,这是一个私有API,知道这个是干嘛的就行了)
IOKit.framework封装整个触摸事件为IOHIDEvent对象,直接通过mach port(Mach属于硬件层,仅提供了诸如处理器调度、IPC进程通信等非常少量的基础服务。在Mach中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为“对象”,Mach的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。消息是Mach中最基础的概念,消息在两个端口(port)之间传递。mach port就是IPC进程间通信的核心,更多内容请查看这篇文章)转发给SpringBoard.app。
SpringBoard.app处理阶段
SpringBoard.app的主线程Runloop收到IOKit.framework转发来的消息苏醒,并触发对应mach port的Source1回调__IOHIDEventSystemClientQueueCallback()。
如果SpringBoard.app监测到有App在前台(记为xxx.app),SpringBoard.app再通过mach port转发给xxx.app,如果SpringBoard.app监测到前台没有App运行,则SpringBoard.app进入App内部响应阶段,触发自身主线程runloop的Source0时间源的回调。
SpringBoard.app是一个系统进程,可以理解为桌面系统,可以统一管理和分发系统接收到的触摸事件。
App内部响应阶段
前台App主线程Runloop收到SpringBoard.app转发来的消息而苏醒,并触发对应mach port的Source1回调__IOHIDEventSystemClientQueueCallback()。
Source1回调内部,触发Source0回调__UIApplicationHandleEventQueue()
Source0回调内部,封装IOHIDEvent为UIEvent。
Source0回调内部,调用UIApplication的sendEvent:方法,将UIEvent传给UIWindow,接下来就是寻找最佳响应者的过程,也就是命中测试hit-testing。
寻找到最佳响应者后,接下来就是事件在响应链中的传递和响应了。需要注意的是,事件除了可以被响应者处理之外,还有可能被手势识别器或者target-action捕捉并处理,这涉及到一个优先级的问题。如果触摸事件在响应链中没有找到能够响应该事件的对象,最终将被释放。
事件被处理或者释放之后,runloop如果没有其他事件进行处理,将会再次进入休眠状态。
Source0和Source1都可用于线程(或进程)交互,但交互的形式有所不同,Source1监听端口,当端口有消息到达时,响应的Source1就会被触发回调,完成响应的操作;而Source0并不监听端口,让Source0执行回调需要手动标记Source0为待处理状态,还需要呼醒Source0所在的Runloop。从Source1和Source0的交互方式了解到,Source1的交互会主动呼醒所在的Runloop,而Source0的交互则需要依赖其他线程来呼醒Source0所在的Runloop。一次Runloop只能执行一个Source1的回调,但可以执行多个待处理的Source0的回调。
寻找事件的最佳响应者(Hit-Testing)
能够响应触摸事件的例如UIView,UIButton,UIViewController,UIApplication,Appdelegate等都继承自UIResponder类,一个页面上通常会有许许多多个这种类型的对象,都可以对点击事件作出响应。为了避免冲突,这就需要有一个先后顺序,也就是响应的优先级。Hit-Testing的目的就是找到具有最高优先级的响应对象。
寻找的具体流程如下:
UIApplication首先将事件队列中的事件取出,传递给窗口对象。如果有多个窗口,则优先询问windows数组的最后一个窗口。
如果窗口不能响应事件,则将事件传递给倒数第二个窗口,以此类推。如果窗口能够响应事件,则再依次询问该窗口的子视图。
重复步骤2。
若视图的所有子视图均不是最佳响应者,则自身就是最合适的响应者。
另外需要注意的是,一下几种状态的视图无法响应事件:
不允许交互的视图:userInteractionEnabled = NO
隐藏的视图:hidden = YES
透明度alpha<0.01的视图
怎么样验证一下上面所说的Hit-Testing的顺序呢,看一下UIView的API,里面会有一个hitTest:withEvent:方法,这个方法的主要作用就是查询并返回事件在当前视图中的响应者,每个被询问到的视图对象都会调用这个方法来返回当前视图层的响应者。
如果当前视图无法响应事件,则返回nil。
如果当前视图可以响应事件,但子视图不能响应事件,则返回自身作为当前视图的响应者。
如果当前视图可以响应事件,同时有子视图可以响应事件,则返回该子视图作为当前视图的响应者。
所以我们可以根据通过观察该方法的调用顺序,来确定Hit-Testing的顺序。
屏幕快照 2017-11-27 下午2.32.23.png
如图所示,A视图上面添加了子视图B和C,B上面添加了子视图D,C上面添加了子视图E和F。创建一个继承自UIView的类HTView,重写hitTest:withEvent:方法:
@interface HTView : UIView
@property (nonatomic, strong) NSString *name; //视图的名字
@end
@implementation HTView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
NSLog(@"进入%@视图-%s", self.name, __func__);
UIView *view = [super hitTest:point withEvent:event];
NSLog(@"离开%@视图-%s", self.name, __func__);
[图片上传中...(屏幕快照 2017-11-27 下午3.58.14.png-334090-1511769555186-0)]
return view;
}
@end
在ViewController中添加如下代码:
#import "ViewController.h"
#import "HTView.h"
@interface ViewController ()
@property (weak, nonatomic) IBOutlet HTView *aView;
@property (weak, nonatomic) IBOutlet HTView *bView;
@property (weak, nonatomic) IBOutlet HTView *cView;
@property (weak, nonatomic) IBOutlet HTView *dView;
@property (weak, nonatomic) IBOutlet HTView *eView;
@property (weak, nonatomic) IBOutlet HTView *fView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.aView.name = @"A";
self.bView.name = @"B";
self.cView.name = @"C";
self.dView.name = @"D";
self.eView.name = @"E";
self.fView.name = @"F";
}
@end
点击E视图,打印的结果如下:
屏幕快照 2017-11-27 下午4.16.08.png
由打印的结果可知:
事件首先传递给视图A。
A判断自身能响应事件,继续从后向前遍历A的子视图,因为C比B后添加,因此首先传递给C。
C判断自身能响应事件,继续从后向前遍历C的子视图,因为F比E后添加,因此首先传递给F。
F判断自身不能响应事件,C又将事件传递给E。
E判断自身能响应事件,同时E已经没有子视图,因此最终E就是最佳响应者。
(这里有一个问题,为什么遍历视图的时候需要从后往前遍历呢?为什么B和C都是A的子视图,判断出了C视图能响应事件之后,B视图没有继续调用hitTest:withEvent:方法呢?)
那么视图又是怎么判断自身是否可以响应事件的呢?答案是通过poingInside:withEvent这个方法来判断触摸点是否在视图的坐标范围内。那么结合上面的hitTest调用的相关知识来看,hitTest:withEvent方法的大概实现已经呼之欲出了:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 先判断视图是否处于不能响应事件的3种状态
if (self.userInteractionEnabled == NO || self.hidden || self.alpha < 0.01) {
return nil;
}
// 判断触摸点是否在视图的坐标范围内
if ([self pointInside:point withEvent:event] == NO) {
return nil;
}
// 从后向前遍历视图的子视图
for (int i = (int)self.subviews.count - 1; i >= 0; i--) {
UIView *subView = self.subviews[i];
// 坐标转换,把触摸点的位置转换为子视图坐标系下的坐标
CGPoint subPoint = [self convertPoint:point toView:subView];
// 对子视图进行Hit-Testing
UIView *subHTView = [subView hitTest:subPoint withEvent:event];
// 如果子视图有最佳响应者,返回该最佳响应者视图,结束循环
if (subHTView) {
return subHTView;
}
}
// 如果子视图中没有最佳响应者,返回自己
return self;
}
重新点击,发现视图仍然可以正常响应点击事件,证明我们所写的实现与系统的方法基本相同。这里我们就可以回答上面括号里面的问题了,为什么要从后往前遍历呢?因为数组里面后面的视图是后添加的,后添加的视图一般都是在视图的上层,会把先添加的视图遮挡,我们自然不会想要去点击被遮挡住的位置。为什么B视图没有调用hitTest:withEvent:方法呢?因为已经确定触摸点在C视图上了。如果B和C没有重叠部分,自然不用再判断B视图能否响应,如果有重叠部分,后添加的C自然是在上层,所以C优先响应,也不会再对B视图进行判断。
我们通过这段代码还可以解释另外一种现象,子视图超出了父视图的范围,点击子视图在父视图之外的部分没有反应。这是因为在进行Hit-Testing的时候,父视图就已经判断自己不能响应事件了,自然不会再去询问子视图是否能够响应事件。
如果碰到这种需求怎么办?比如说tabBar中间的按钮凸起