美文网首页
细数iOS触摸事件流动

细数iOS触摸事件流动

作者: FengyunSky | 来源:发表于2020-05-31 09:03 被阅读0次

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

    整个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内核编程》

    相关文章

      网友评论

          本文标题:细数iOS触摸事件流动

          本文链接:https://www.haomeiwen.com/subject/uihlzhtx.html