iOS的事件是一个由触发行为到响应的过程。本文旨在表达事件如何处理响应,如何传递事件的。
1. 前言国内智能手机要从2000年开始说起,手机的进化是飞速的,单从 *** 作来看,最开始是数字实体键盘,后来出现了全字母的实体键盘。2007年首台iPhone诞生让我们看到了新的 *** 作方式,它只有一个按键。2008年诺基亚5800作为第一款全电阻屏膜触摸手机,同年魅族M8全电容屏触摸手机紧随其后。至此手机 *** 作已完全脱离实体键盘并且可以多点触控。言归正传本文不去探索电容屏的多点触控功能和原理,只剖析一下iPhone是如何通过人的一根手指触摸到最终响应的过程。本文以触摸事件为例。
iOS开发中常见的事件有以下4种(※不包含3Dtouch):
2. 事件相关类UITouch:
定义:保存着跟手指相关的信息,比如触摸的位置、时间、阶段、大小、运动、力、角度等,一根手指只对应一个UITouch对象。
生命周期:当手指移动时,系统会更新同一个UITouch对象,使之能够一直保存该手指在的触摸位置,当手指离开屏幕时,系统会销毁相应的UITouch对象。
UIEvent:
定义:事件对象,记录事件产生的时刻和类型,每产生一个事件,就会产生一个UIEvent对象。
UIResponder:
定义:响应者对象,用来响应用户的 *** 作,处理各种事件。
封装了UIEvent、UITouch、UIPress、UIMenuBuilder等事件相关类,目的为其子类提供一系列方法可以重写处理,并可以用来获取响应的状态及相关属性。四个常用的touch方法就在这个类里定义的:
// 一根或多根手指开始触摸屏幕
- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
// 一根或多根手指在屏幕上移动,注意此方法在移动过程中会重复调用
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
// 一根或多根手指触摸结束离开屏幕
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
// 触摸意外取消(例如正在触摸时打入电话)
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
UIGestureRecognizer:
它实现了上面的四个touche方法,但是它不是一个UIResponder子类,因此它并不在响应链中。
UITouch 和 UIEvent 提供了一些方法来获取触摸和手势识别的关联性。UITouch的gestureRecognizers 会列举出当前处理这个touch的所有手势。
touchesForGestureRecognizer: 方法列举了由指定手势识别所处理的触摸。
3. 两个函数- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
这2个函数定义在UIView中,因此只有UIView及其子类才可以重写这2个方法,通过遍历调用视图栈中每个视图的这两个函数来查找第一响应者。接下来分别来说一下他们的作用。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
该方法会被系统调用(可重写),其主要作用是查找命中视图:在视图的层次结构中寻找到一个最适合的视图 (理论为最上层视图)来响应触摸事件,如果返回为nil,即事件有可能被丢弃。此过程也就是查找第一响应者的过程。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
hitTest:withEvent:方法内部会通过调用这个方法,来判断点击区域是否在当前视图上,是则返回YES,不是则返回NO。当然我们也可以重写此方法来扩展点击区域,事件区分处理等 *** 作。
4. 事件的传递过程响应者链条:是由多个响应者对象(UIResponder对象)连接起来的链条,它让每个响应者之间存在链式的联系,并且可以让一个事件由多个对象处理。
下面我们先看一下事件的传递流程,主要分成2大部分:
1.UIApplication接受事件前:
1.系统通过IOKit.framework来处理硬件 *** 作,其中屏幕处理也通过IOKit完成(IOKit可能是注册监听了屏幕输出的端口)当用户 *** 作屏幕,IOKit收到屏幕 *** 作,会将这次 *** 作封装为IOHIDEvent对象。通过 mach_Port (IPC进程间通信)将事件转发给SpringBoard来处理。
2.SpringBoard收到mach_Port发过来的事件后,会唤醒main runloop来处理。main runloop将事件交给source1处理,source1会调用__IOHIDEventSystemClientQueueCallback()函数。
3.函数内部会判断,是否有程序在前台显示,如果有则通过mach_Port将IOHIDEvent事件转发给这个程序。如果前台没有程序在显示,则表明SpringBoard的桌面程序在前台显示,也就是用户在桌面进行了 *** 作。__IOHIDEventSystemClientQueueCallback()函数会将事件交给source0处理,source0会调用__UIApplicationHandleEventQueue()函数,函数内部会做具体的处理 *** 作。
4.假如当前有程序处于活跃状态,在__UIApplicationHandleEventQueue()函数中,会将传递过来的IOHIDEvent对象转换为UIEvent对象。
5.在函数内部,调用UIApplication的sendEvent:方法,将UIEvent对象传递给第一响应者或UIControl对象处理。
总结一下:通过mach_Port将触摸事件转发给SpringBoard来处理,它会唤醒main runloop并触发source1的回调,接着会通过函数调用source0回调,source0的回调会将事件放入当期活动的UIApplication事件队列里,并通过sendEvent:方法直接调传递给第一响应者。(这里有2个特殊存在,UIControl和UIGestureRecognizer本身及其子类会优先响应接受事件,他们会跳过事件相应链的查找)。
备注:
SpringBoard:是iOS中的桌面管理器,它是iOS程序中,事件的第一个接受者。它只能接受少数的事件比如:按键(锁屏/静音等),触摸,加速,接近传感器等几种事件,随后使用mach_Port转发给需要的App进程。
Source1:基于mach_Port的,来自系统内核或者其他进程或线程的事件,可以主动唤醒休眠中的RunLoop(iOS里进程间通信开发过程中我们一般不主动使用)。mach_port大家就理解成进程间相互发送消息的一种机制就好。
Source0:非基于Port的处理事件,简单来说就是这个消息是负责App内部事件,由App负责管理触发的。
2.UIApplication接受到事件后:
1.UIApplication接收到事件,将事件传递给keyWindow。(注:keyWindow一般为多个最后添加的window)
2.keyWindow遍历subViews的hitTest:withEvent:方法,找到点击区域内合适的视图来处理事件。
3.UIView的子视图也会遍历其subViews的hitTest:withEvent:方法,以此类推。
4.直到找到点击区域内,且处于最上方的视图,将视图逐步返回给UIApplication。
5.在查找第一响应者的过程中,已经形成了一个响应者链。
6.应用程序会先调用第一响应者处理事件。
7.如果第一响应者不能处理事件,则 调用其nextResponder方法,一直找响应者链中能处理该事件的对象。
8.最后到UIApplication后仍然没有能处理该事件的对象,则该事件被废弃。
*在事件传递过程中hitTest:withEvent:方法至关重要,它的查找过程大致如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// *---------------- hitTest实现过程 ----------------*
// 1.判断当前控件能否接收事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) {
return nil;
}
// 2. 判断点在不在当前视图上
if ([self pointInside:point withEvent:event] == NO) {
return nil;
}
// 3.从后往前遍历自己的子控件
NSInteger count = self.subviews.count;
for ( int i = 0; i < count; i++)
{
UIView *subView = self.subviews[count - 1 - i];
//进行坐标转化
CGPoint coverPoint = [subView convertPoint:point fromView:self];
// 调用子视图的 hitTest 重复上面的步骤。找到了,返回hitTest view ,没找到返回有自身处理
UIView *hitTestView = [subView hitTest:coverPoint withEvent:event];
if (hitTestView)
{
return hitTestView;
}
}
// 循环结束,表示没有比自己更合适的view
return self;
}
看代码我们会发现有4种情况:会直接返回nil,也就是不接受触摸事件,这4种情况分别为:
1. 视图的hidden等于YES。
2. 视图的alpha小于等于0.01。
3. 视图的userInteractionEnabled为NO。
4. 视图超出父视图的bounds。
注意这里不包括clearColor。
当我点击B视图的时打印如下:
>:-[RootWindow hitTest:withEvent:]<-
>:-[RootView hitTest:withEvent:]<-
>:-[CView hitTest:withEvent:]<-
>:-[AView hitTest:withEvent:]<-
>:-[RootWindow hitTest:withEvent:]<-
>:-[RootView hitTest:withEvent:]<-
>:-[CView hitTest:withEvent:]<-
>:-[AView hitTest:withEvent:]<-
在这里A、C视图是同级子视图,并且A先添加,C后添加。可以分析得出若有多个同级视图时,会按添加顺序添加的逆序去查找。实际上不难看出,这个处理流程有点类似二分查找的思想,这样能以最快的速度,最精确地定位出能响应触摸事件的UIView。
5. UIControlUIControl是继承于UIView的,我们实际测试中它也确实是通过hitTest:withEvent:的方式查找第一响应者的,唯一区别就是当UIControl及其子类为第一响应者时会直接由UIApplication派发事件,无需通过事件响应连逐级传递。我猜测这也是苹果为什么要封装一个UIControl这个类的原因之一。以UIButton为例:
6. UIGestureRecognizer
UIGestureRecognizer是手势识别器,它直接继承于NSObject的。当响应者链和手势同时出现时,也就是既实现了touches方法又添加了手势,会发现touches方法有时会失效,这是因为手势的执行优先级是高于响应者链的。
根据苹果的官方文档,手势不参与响应者链传递事件,但是也通过hitTest的方式查找响应的视图,手势和响应者链一样都需要通过hitTest:withEvent:方法来确定响应者链的。在UIApplication向响应者链派发消息时,只要响应者链中存在能够处理事件的手势,则手势响应事件,如果手势不在响应者链中则不能处理事件。
详见苹果文档:UIGestureRecognizer
7. 遇到的问题1. 在测试中发现,每一次触摸 *** 作,会触发两次hitTest:withEvent:方法。 两次调用的point参数完全相同,时间戳也相同。 但是两次的调用栈不同。
最终,在 cocoa-dev 邮件列表中找到了对应话题的讨论, 大致是这样说的,系统可能会在两次调用中,做一些点击位置的微调。但是在目前的情况下,并没有发现点击位置 point 的变化。 所以,在此处我对于重复调用的情况进行了过滤,以避免执行重复的点击逻辑。
可以参考苹果官方博客: -hitTest:withEvent: called twice?
2. 在调用touchesBegan:withEvent:时,用super和nextResponder结果是一样的,都不会导致响应链错乱。
这是由于super关键字并非“父类”那么简单,它本质是一个编译器标示符。
可以参考文章:iOS的“super”关键字
3. 同一个按钮既添加了手势,又添加了target-action事件,那么会优先响应哪个?
上文已经提到了,手势的优先级高于UIResponder的,因此会优先执行。假设要想让2个都执行可行吗?可行只需要设置UIGestureRecognizer的属性cancelsTouchesInView=NO即可解决。
4. 在iOS13系统里,当下一响应者找到keyWindow时,在去打印nextResponder,会打印如下:
这是由于iOS13的新特性:UIWindowScene,支持多场景窗口(多任务)。
8. 实战学了这么多究竟能用它做什么呢?我给出几个实例,只是抛砖引玉而已:
1. 通过视图查找视图所在的控制器
- (UIViewController *)parentController {
UIResponder *responder = [self nextResponder];
while (responder) {
if ([responder isKindOfClass:[UIViewController class]]) {
return (UIViewController *)responder;
}
responder = [responder nextResponder];
}
return nil;
}
2. 通过hitTest:withEvent:方法,实现超出父视图边界时依然可以让当前视图响应点击。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//将当前A视图中的触摸点转换坐标系,转换到B视图的身上,生成一个新的点
CGPoint newPoint = [self convertPoint:point toView:self.bView];
//判断如果这个点是在B视图上,那么处理点击事件最合适的视图返回B视图
if ( [self.bView pointInside:newPoint withEvent:event])
{
return self.bView;
}
return [super hitTest:point withEvent:event];
}
3. 通过hitTest:withEvent:方法,实现扩大按钮的点击范围,四边都扩大10个像素点。
// 重新命中视图扩大手势范围
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
CGRect largeRect = CGRectMake(self.button.bounds.origin.x - 10, self.button.bounds.origin.y - 10, self.button.frame.size.width + 10 * 2, self.button.frame.size.height + 10 * 2);
if (CGRectContainsPoint(largeRect, point)) {
return self.button;
}
return [super hitTest:point withEvent:event];
}
当然也可以使用pointInside:withEvent:来实现
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGRect largeRect = CGRectMake(self.button.bounds.origin.x - 10, self.button.bounds.origin.y - 10, self.button.frame.size.width + 10 * 2, self.button.frame.size.height + 10 * 2);
if (CGRectEqualToRect(largeRect, self.bounds)) {
return [super pointInside:point withEvent:event];
}
else {
return CGRectContainsPoint(largeRect, point);
}
}
至此本文结束,有任何问题可随时联系,欢迎共同探讨研究(*^▽^*)。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)