细数iOS触摸事件流动

当手指轻触屏幕,整个系统像沉睡的生灵突然被惊醒,然后经历过腥风血雨的一段奇幻旅行,最终又归于沉寂。

整个iOS触摸事件从产生到寂灭大致如下图:


触摸事件生命周期

系统响应阶段

  1. 手指触摸屏幕,屏幕硬件感应到输入事件并交由IOKit驱动处理;

I/O Kit是用于创建设备驱动程序的系统框架、库、工具和其它资源的集合,基于受限的c++形式(主要是继承和重载)实现面向对象的编程模型,简化了设备驱动传给你续开发的过程。相关的驱动开发命令行工具:kextload/kextunloadkextstatkextcacheiostat(显示终端、磁盘和cpu操作的内核i/o统计信息)ioalloccountgcc/gdb等。

  1. IOKit用户空间框架IOKit.framework将触摸事件封装成一个IOHIDEvent对象,并通过mach port传递给SpringBoard.app进程;

SpringBoard.app 是 iOSiPadOS 负责管理主屏幕的基础程序,并在设备启动时启动 WindowServer、开启应用程序(实现该功能等程序称为应用启动器)和对设备进行某些设置。有时候主屏幕也被作为 SpringBoard 的代称。主要处理按键(锁屏/静音等)、触摸、加速、距离传感器等几种事件,随后通过mac port进程间通信转发至需要的APP。
Mac OSX中使用的是Launchpad,能让用户以从类似于iOS的SpringBoard的界面按一下图示来启动应用程式。在启动台推出之前,用户能以Dock、Finder、Spotlight或终端启动应用。不过 Launchpad 并不会占据整个主屏幕,而更像是一个 Space(类似于仪表板)。

桌面响应阶段

  1. SpringBoard.app进程主线程RunLoop收到IOKit.framework传递来的消息苏醒,并触发对应mach port的Source1回调__IOHIDEventSystemClientQueueCallback()
  2. SpringBoard.app进程判断桌面是否存在前台应用,若有则直接转发给前台应用;若无(如处于桌面翻页),则触发SpringBoard.app应用内部主线程RunLoop的Source0事件回调,由桌面应用内部消耗;

APP响应阶段

  1. 应用启动时会开启com.apple.uikit.eventfetch-thread线程RunLoop并注册souce1类型事件,用于接收SpringBoard.app发送的mach port source1消息;
  2. com.apple.uikit.eventfetch-thread线程接收到source1消息后,执行__IOHIDEventSystemClientQueueCallback回调,并将main runloop__handleEventQueue所对应的source0事件设置为signalled=Yes状态,同时唤醒主线程runloop,主线程则调用__handleEventQueue来进行事件队列的处理,如下图所示:
    UITouch调用栈

主线程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如下:

eventfetch thread runloop对象

该线程DefaultModeCommonMode均包含source1其回调为__IOHIDEventSystemClientQueueCallback;

  1. 事件队列处理是将触摸事件添加到UIApplication对象的事件队列中,事件出队后,UIApplication开始寻找最佳响应者的过程Hit-Testing,过程如下:

大致的流程即是事件自下往上传递递归询问子视图能否响应事件的过程,其中UIWindow继承自UIView也可作为视图,且若同一层级则后添加的子视图优先级高(对于UIWindow而言后显示的UIWindow优先级高),具体的流程如下:

事件传递流动

  • UIApplicationUIEvent事件传递给窗口对象UIWindow,若存在多个同层级的UIWindow,则后显示的优先级高,即顶层的窗口优先级高,视图优先级等同;
  • 若窗口不能响应事件,则将事件传递给其他窗口;若窗口能响应事件,则从窗口子视图自下往上询问能否响应事件;
  • 若能响应事件就继续子视图自下往上传递询问,直至没有能响应的子视图为止,则自身就是最适合的响应者;

对于上述能否响应事件是通过UIView对象的hitTest:withEvent方法来判定,具体的规则如下:

  • 若当前视图无法响应事件,则返回nil;

    无法响应事件的几种状态如下:

    • 不允许交互:userInteractionEnabled = NO
    • 隐藏:hidden = YES如果父视图隐藏,那么子视图也会隐藏,隐藏的视图无法接收时间;
    • 透明度:alphs < 0.01如果设置的视图透明度<0.01,会直接影响子视图的透明度,即子视图也透明不会接收事件;
  • 若当前视图可以响应事件,但子视图可以响应事件,则返回自身作为当前视图层次中的事件接收者;

  • 若当前视图可以响应事件,同时有子视图可以响应,则返回子视图层次中的事件响应者;

hitTest:withEvent调用栈如下图:

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:withEventpointInside:withEvent方法来修改事件的流向。

  1. 寻找到最佳响应者后,UIApplication会通过sendEvent:将事件传递给事件所属的UIWindowUIWindow同样通过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的响应者对象都可以响应事件,如UIViewUIViewControllerUIWindowUIApplication,每个响应者对象都有一个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]);
    }
}
  1. 若视图存在手势识别器,由于手势识别器比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对象发送状态为endUITouch事件给hit-tested view以调用touchEnded:withEvent结束事件响应;

  2. 若视图中存在继承自UIViewUIControl对象,如UIButtonUISegmentedControlUISwitch等控件,当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:targetactionevent对象发送给UIApplication对象,UIApplication对象再通过sendAction:to:from:forEvent:target发送action,因此,可以重写上述方法来自定义事件执行的targetaction

    对于UIControl添加手势识别器的情况,无法响应target-action事件;

Reference

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