iOS Touch Event from the inside out

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 用于监听 IOHIDEventSource1 事件,回调方法是 __IOHIDEventSystemClientQueueCallback(),内部又进一步分发 Source0 事件,而 Source0 事件都是自定义的,非基于端口 port,包括触摸,滚动,selector选择器事件,它的回调方法是 __UIApplicationHandleEventQueue(),将接收到的 IOHIDEvent 事件对象封装成我们熟悉的 UIEvent 事件;然后调用 UIApplication 实例对象的 sendEvent: 方法,将 UIEvent 传递给 UIWindow 做一些逻辑判断工作:比如触摸事件产生于哪些视图上,有可能有多个,那又要确定哪个是最佳选项呢? 等等一系列操作。这里先按下不表。

1.4 Hit-Testing 寻找最佳响应者

Source0 回调中将封装好的触摸事件 UIEvent(里面有多个UITouch 即手势点击对象),传递给视图 UIWindow,其目的在于找到最佳响应者,这个过程称之为 Hit-Testing,字面上理解:hit 即触碰了屏幕某块区域,这个区域可能有多个视图叠加而成,那么这个触摸讲道理响应者有多个喽,那么“最佳”又该如何评判?这里要牢记几个规则:

  1. 事件是自下而上传递,即 UIApplication -> UIWindow -> 子视图 -> ...->子视图中的子视图;
  2. 后加的视图响应程度更高,即更靠近我们的视图;
  3. 如果某个视图不想响应,则传递给比它响应程度稍低一级的视图,若能响应,你还得继续往下传递,若某个视图能响应了,但是没有子视图 它就是最佳响应者。
  4. 寻找最佳响应者的过程中, 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;
}
  1. 首先满足几个前置条件,可交互userInteractionEnabled=YES;没有隐藏self.hidden == NO;非透明 self.alpha <= 0.01 ———— 注意一旦不满足上述三个条件,当前视图及其子视图都不能作为响应者,Hit-Testing 判定也止步于此
  2. 接着判断触摸点是否在视图内部 ———— 这个是最基本,无可厚非的判定规则
  3. 此时已经能够说当前视图为响应者,但是不是最佳还不能下定论,因此需要进一步传递给子视图判定;注意 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 几个注意点(待修)

  1. 重写 UIWindow 的sendEvent 方法,里面可以捕获 event, event中包含了 UIWindow view 等信息
  2. UITouchesEvent 包含 UIEvent 以及 touches 数组,每个 touch 都因为 hitTest 判断逻辑绑定 window 和最佳响应view。
  3. 这里要注意:每一个响应者对象 UIResponder 类都有一个 nextResponder 方法,用于获取响应链中当前对象的下一个响应者(自上而下),因此一旦事件的最佳响应者确定了,整个响应链就确定了。UIResponder 类中默认实现 touchesBegan touchesCancelled touchesMoved touchesEnded 方法 都有沿着响应链向下传递的实现!因此如果你重写了 touchesBegan 但是没有调用 [super touchesBegan],那么事件传递止步于此。
  4. hit-Testing 首先进行,为了寻找最佳响应者;接着将 UIEvent 沿着响应链自上而下传递,优先传递给 UIGestureRecognizer 然后才是 hitTest View最佳响应者;但是尽管先传递给手势识别器 但是手势识别是需要一定时间的,所以可能还是会暂时响应 touchesbegan 方法 ,一旦识别成功,则会调touchescancel。当然提供了 cancelsTouchesInView delaysTouchesBegan delaysTouchesEnded 属性来控制传递流程。
  5. 证明是先传递给手势识别器,我们自定义一个手势识别器 然后重写touchesXXX 四个方法。不过手势识别器不是UIResponder的派生类,方法是定义在 UIGestureRecognizerSubclass.h
  6. 假如自定义手势识别器,识别一个点击事件,并且希望延迟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

原则:

  1. 自下而上 UIWindow(s) -> UIView(s)->SubView(s) ... 以此类推
  2. 同一层级的视图,优先检查后加的视图

2.2 Touch Event 发送顺序

Reversed

Reference

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容