iOS之事件的传递和响应机制

image.png

一、 基础概念:响应者对象 (UIResponder)

学习 UIKit 触摸分发首先要理解 UIResponder。参与 responder chain、接收 touchesBegan: 等 UIResponder 回调的对象需要继承 UIResponder。但手势识别器等组件也会观察触摸序列,所以不要把“所有事件处理能力”都等同于 UIResponder 子类。

常见子类包括:

  • UIView

  • UIViewController

  • UIApplication

  • UIWindow (继承自 UIView)


二、 事件的产生与底层源头(RunLoop 视角)

image.png

这是面试中区分初级和资深开发的一个点:事件是如何从硬件传到 App 的?

  1. 系统输入链路: 触摸从硬件和系统输入服务进入 UIKit,跨进程部分会涉及 Mach 消息,并唤醒应用主线程事件循环。IOHIDEvent、SpringBoard/相关系统进程和端口分发细节属于私有实现,系统版本可能变化。

  2. UIKit 转换与分发: UIKit 把底层输入转换为 UIEvent / UITouch 并进入窗口系统。不能把这条私有管线固定描述成“Source1 必然转换成 Source0”。Source0 和 Source1 是 RunLoop Source 的机制分类,不是触摸事件的公开两阶段协议。

  3. 事件顺序: 系统会维护输入事件的时序和触摸序列状态,但 UIKit 没有公开一个可让业务依赖其内部结构的 UIApplication FIFO 队列。

    • 同一触摸序列中的 began、moved、ended/cancelled 需要保持因果顺序;多来源事件还会受 RunLoop Mode、事件合并和系统调度影响,不能只用一个简单 FIFO 解释全部行为。

三、 事件的传递(Hit-Testing)—— 寻找最合适的 View

这是事件处理的第一步:自上而下(UIApplication -> Window -> View -> Subview)寻找“靶心”。

3.1 传递流程

  1. UIKit 将事件路由到对应 UIWindow。多 Scene 应用可能同时存在多个 window,keyWindow 也不再适合作为所有场景的全局唯一入口。

  2. keyWindow 判断自己能否响应,并判断点是否在自己身上。

  3. 倒序遍历子控件(从后往前,即 subviews.lastObject 开始),重复上述步骤。

    • 原因:后添加的 View 在视觉上覆盖在先添加的 View 之上,优先响应最上面的 View 符合视觉逻辑,同时能减少循环次数。
  4. 一旦找到符合条件的子 View,就将其作为 fitView 返回,停止遍历。

  5. 如果没有符合条件的子控件,但自己满足条件,则自己就是最合适的 View。

3.2 核心方法与源码模拟

寻找过程由两个核心方法实现:

  • - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;

  • - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

hitTest:withEvent: 的伪代码实现:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1. 判断是否允许交互:不允许交互、被隐藏、透明度极低均不能响应
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha < 0.01) {
        return nil;
    }

    // 2. 判断触摸点是否在当前 View 内部
    if ([self pointInside:point withEvent:event] == NO) {
        return nil;
    }

    // 3. 从后往前遍历子控件(深度优先,视觉顶层优先)
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--) {
        UIView *childView = self.subviews[i];
        
        // 关键:坐标系转换,将当前点的坐标转换到子控件的坐标系上
        CGPoint childPoint = [self convertPoint:point toView:childView];
        
        // 递归调用子控件的 hitTest
        UIView *fitView = [childView hitTest:childPoint withEvent:event];
        
        // 如果子控件找到了最合适的 View,直接返回,不再继续遍历
        if (fitView) {
            return fitView;
        }
    }

    // 4. 如果子控件都没有返回,且通过了步骤1和2,则自己就是最合适的 View
    return self;
}

3.3 拦截与Hack技巧

通过重写 hitTest:withEvent: 可以实现特殊需求:

  1. 扩大点击区域:重写 pointInside,判断点在 bounds 向外延伸的范围内即返回 YES。

  2. 穿透点击:让下层的 View 响应事件。在顶层 View 的 hitTest 中返回 nil,事件就会自动传递给被遮挡的 View(前提是父 View 继续寻找)。

  3. 子视图超出父视图范围响应:默认情况下,子视图超出父视图部分无效(因为父视图 pointInside 返回 NO,根本不会遍历子视图)。解决方案是重写父视图的 hitTestpointInside


四、 事件的响应(The Responder Chain)

找到最合适的 View(Initial View)后,如果没有手势拦截,系统会调用该 View 的 touches 系列方法。

4.1 四大核心方法

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

注意 touchesCancelled:不仅仅是电话呼入,最常见的情况是手势识别器(UIGestureRecognizer)识别成功后,会取消触摸事件,导致 View 收到此回调。

4.2 响应者链条传递规则

响应链是自下而上(View -> Superview -> Controller -> Window -> App)传递的。

  1. Initial View 处理事件(调用 touches 方法)。

  2. 如果 View 没有重写 touches 方法,或者在 touches 方法中调用了 [super touchesBegan/Moved/Ended...],事件会传递给 nextResponder

  3. Next Responder 查找规则

    • UIView: 若是 VC 的 Root View,下一个是 VC;否则是 superview
  • UIViewController: nextResponder 关系会结合 view hierarchy、parent/presentation/container controller 结构决定,不宜固定成“它的 view.superview”。

    • UIWindow: 下一个是 UIApplication

    • UIApplication: 下一个是 AppDelegate (如果它是 UIResponder)。

  1. 如果传递到最后都没人处理,事件被丢弃。!

五、 高阶难点:事件响应的冲突与特殊处理

这部分内容是资深工程师必须掌握的细节。

5.1 手势识别器 (Gesture Recognizer) vs 触摸事件 (Touches)

这是最容易混淆的地方。UIGestureRecognizer 也是通过 Hit-Testing 绑定到 View 上的,但它的优先级通常高于 View 自身的 touches 方法。

  • 默认行为

    1. 当触摸发生,系统同时将事件发送给 View (touchesBegan) 和 绑定在 View (及父视图) 上的 GestureRecognizer。

    2. 如果 GestureRecognizer 识别失败,View 继续接收 touchesEnded。

    3. 如果 GestureRecognizer 识别成功

      • 它会独占该事件。

      • 系统会向 View 发送 touchesCancelled,终止 View 的事件处理。

      • View 不会再收到 touchesEnded。

  • 关键属性

    • cancelsTouchesInView (默认 YES):识别成功后取消 View 的触摸。设为 NO 则两者共存(View 能收到 touchesEnded)。

    • delaysTouchesBegan (默认 NO):只有手势识别失败后,才把 touchesBegan 发送给 View。用于解决点击态闪烁问题。

5.2 UIControl (Target-Action) 的特殊性

UIButtonUISlider 等继承自 UIControl

  • 常见现象:点击 Button 时,父 View 自己覆写的 touchesBegan 往往不会像普通未处理触摸那样收到转发,但结果还受控件实现、手势识别器和自定义响应链代码影响。

  • 原因UIControl 内部重写了 touches 方法来处理 Target-Action 逻辑。它默认阻断了响应链的向上传递(没有调用 super)。

  • 区别

    • UILabel/UIView:默认不处理,透传给父控件。

    • UIButton:处理并吞掉事件,父控件收不到。

5.3 不要把它理解成固定优先级排序

触摸会同时交给 hit-test view、相关 responder 与附着在视图层级上的 gesture recognizer 进行竞争和状态判断。识别器之间还受 failure requirement、delegate、cancelsTouchesInViewdelaysTouchesBegan/Ended 等配置影响。UIControl 的 target-action 则建立在自身触摸跟踪之上。它们不是一个永远按 1、2、3 顺序执行的优先级队列。


六、 总结

iOS 的事件机制是一个精密设计的流程:

  1. 系统输入层:底层输入通过系统服务和事件循环进入 UIKit;Source0/Source1 细节只作实现理解,不作为稳定业务协议。

  2. 寻找层 (Hit-Test):自上而下,倒序遍历,利用 pointInside 和坐标转换找到最合适的 fitView

  3. 响应层 (Responder Chain):自下而上,nextResponder 传递。

  4. 干扰项:注意手势识别器对标准响应链的“拦截”和“取消”机制。

掌握这套逻辑,不仅能应对面试中的“如何扩大按钮点击范围”、“父视图如何拦截子视图事件”、“手势冲突解决”等问题,也能在实际开发中处理复杂的交互场景。