实践 iOS Touch 响应链:从 sendEvent 到 nextResponder 的完整闭环

0 阅读6分钟

前言

平时我们写业务,很少直接碰 hitTest、pointInside、nextResponder 这些 API。但一旦遇到"按钮点不动""子视图超出父视图点不中""蒙层挡住了事件"这类问题,如果对响应链没有完整认知,就只能靠猜和试。

与其背结论,不如动手验证。这篇文章基于我在项目中落地的一个 TouchProbe 探针模块,用最少代码把整条事件链"打出来",边看日志边理解原理。

一、先建立宏观认知:一次点击到底经过了什么

一次触摸从手指按下到方法回调,完整链路如下:

flowchart TD
    A[&#34;硬件触摸<br/>屏幕采集触点&#34;] --> B[&#34;系统捕获<br/>生成 IOHIDEvent&#34;]
    B --> C[&#34;投递到 App 进程<br/>UIApplication 收到事件&#34;]
    C --> D[&#34;sendEvent:<br/>事件入口&#34;]
    D --> E[&#34;分发给 keyWindow<br/>窗口开始处理&#34;]
    E --> F[&#34;命中测试 hitTest:<br/>从根视图递归向下&#34;]
    F --> G{&#34;pointInside: ?&#34;}
    G -->|&#34;在视图内&#34;| H[&#34;继续递归子视图<br/>逆序遍历&#34;]
    G -->|&#34;在视图外&#34;| I[&#34;跳过该子树&#34;]
    H --> J[&#34;找到最佳响应者<br/>hit-test view&#34;]
    J --> K[&#34;touchesBegan&#34;]
    K --> L[&#34;沿 nextResponder<br/>逐级向上传递&#34;]
    L --> M[&#34;touchesMoved / touchesEnded /<br/>touchesCancelled&#34;]

记住一句话:入口是 sendEvent:,定位靠 hitTest:,投递靠 touches* 沿 nextResponder 上溯。

二、最小探针设计:三个切面,一个页面

为了把上面每一步都"看见",我设计了三个探针 + 一个调试页:

组件挂钩点验证目标
UIApplication+TouchProbesendEvent:事件入口、目标窗口
TouchProbeViewhitTest: / pointInside: / touchesBegan:命中顺序、坐标系、传递链
TouchProbeDebugViewController视图层级对照实验开关

1. 入口探针:勾住 sendEvent:

- (void)probe_sendEvent:(UIEvent *)event {
    UITouch *touch = event.allTouches.anyObject;
    if (touch) {
        UIWindow *eventWindow = touch.window;
        UIWindow *activeKeyWindow = [self probe_activeKeyWindow];
        NSLog(@"[sendEvent] phase=%ld eventWindow=%@ activeKeyWindow=%@",
              (long)touch.phase,
              eventWindow ? NSStringFromClass(eventWindow.class) : @"nil",
              activeKeyWindow ? NSStringFromClass(activeKeyWindow.class) : @"nil");
    }
    [self probe_sendEvent:event]; // 调用原实现
}

这里有个多 Scene 时代的坑:UIApplication.keyWindow 在 iOS 13+ 已经废弃,直接用它拿到的经常是 nil。所以我在分类里补了一个 probe_activeKeyWindow,遍历 connectedScenes 找前台激活场景的 isKeyWindow:

- (UIWindow *)probe_activeKeyWindow {
    for (UIScene *scene in self.connectedScenes) {
        if (![scene isKindOfClass:[UIWindowScene class]]) { continue; }
        if (scene.activationState != UISceneActivationStateForegroundActive &&
            scene.activationState != UISceneActivationStateForegroundInactive) { continue; }
        UIWindowScene *windowScene = (UIWindowScene *)scene;
        for (UIWindow *window in windowScene.windows) {
            if (window.isKeyWindow) { return window; }
        }
    }
    return nil;
}

对比 eventWindow(事件实际投递的窗口)和 activeKeyWindow(当前键窗口),能快速判断"事件是不是打到了你以为的那个窗口"。

2. 命中探针:print 出 hitTest 顺序

TouchProbeView 重写三个方法,进入/返回各打一行:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"[hitTest] 进入 %@", self.probeName);
    UIView *hit = [super hitTest:point withEvent:event];
    NSLog(@"[hitTest] %@ 命中 -> %@", self.probeName, NSStringFromClass(hit.class));
    return hit;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    BOOL inside = [super pointInside:point withEvent:event];
    NSLog(@"[pointInside] %@ point=%@ inside=%d", self.probeName, NSStringFromCGPoint(point), inside);
    return inside;
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"[touchesBegan] %@ next=%@", self.probeName, NSStringFromClass(self.nextResponder.class));
    [super touchesBegan:touches withEvent:event];
}

3. 视图层级:故意设计成"会出问题"的结构

调试页搭了这样一棵树,每个设计都有目的:

flowchart TD
    Root[&#34;Root 红色容器&#34;]
    Root --> Middle[&#34;Middle 绿色父视图&#34;]
    Root --> Overlap[&#34;Overlap 橙色兄弟视图<br/>最后添加 = 最上层&#34;]
    Middle --> Inner[&#34;Inner 蓝色子视图<br/>故意部分超出父边界&#34;]
  • Overlap 后添加:用来验证 hitTest 的逆序遍历(后添加的先被测试)。
  • Inner 超出 Middle 边界:用来验证命中边界由 pointInside: 决定。
  • 页面提供 4 个开关:hidden / alpha<0.01 / userInteractionEnabled / clipsToBounds。

三、跑起来看日志:命中顺序全暴露

点击重叠区域,控制台输出顺序如下:

[sendEvent] phase=0 eventWindow=UIWindow activeKeyWindow=UIWindow
[hitTest] 进入 Root-红色容器
[pointInside] Root-红色容器 point={...} inside=1
[hitTest] 进入 Overlap-橙色兄弟视图(最上层)
[pointInside] Overlap-橙色兄弟视图(最上层) point={...} inside=1
[hitTest] Overlap-橙色兄弟视图(最上层) 命中 -> TouchProbeView
[hitTest] 进入 Middle-绿色父视图
[pointInside] Middle-绿色父视图 point={...} inside=1
...
[hitTest] Root-红色容器 命中 -> TouchProbeView
[touchesBegan] Overlap-橙色兄弟视图(最上层) next=Root-红色容器
[touchesBegan] Root-红色容器 next=UIViewControllerWrapperView

从日志能直接读出三条铁律:

① 父先于子,子按逆序 Root 一定先于 Overlap / Middle。Overlap 后添加,所以先于 Middle 被测试。

② 命中即返回,兄弟不再测 一旦 Overlap 返回非 nil,Root 就不会再测 Middle 那条分支。这就是"最上层视图优先响应"的本质。

③ pointInside 用的是父坐标系 每个视图打印出的 point 都不同,因为子视图收到的是经过 convertPoint:fromView: 转换后的坐标。这不是 bug,是命中测试的坐标系规则。

四、四个过滤条件对照实验

这是整篇文章最实用的部分。用调试页的开关逐个验证:

变量结果原因
hidden = YES整个子树被跳过隐藏视图不参与命中
alpha < 0.01整个子树被跳过透明阈值(0.01)以下不命中
userInteractionEnabled = NO整个子树被跳过不接收交互
clipsToBounds默认不影响命中只影响显示裁剪

切换 hidden 后你会发现一个关键现象:对应视图的 pointInside 和 hitTest 两行日志同时消失。因为它根本不会被进入,pointInside 自然也不会被调用。

这里要纠正一个常见误解:网上常说 clipsToBounds 会影响命中,其实默认实现里,hitTest 的边界判定只来自 pointInside:(默认实现是 bounds 判定)。clipsToBounds 只负责"子视图超出部分是否显示"。如果想让超出父视图的区域也能被点中,必须重写父视图的 pointInside::

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    // 扩大命中范围,允许子视图溢出区域也能响应
    return CGRectContainsPoint(CGRectInset(self.bounds, -20, -20), point);
}

五、touches 阶段:方向与 hitTest 相反

flowchart LR
    subgraph Down[&#34;hitTest 阶段(自顶向下)&#34;]
        A1[&#34;Window&#34;] --> A2[&#34;Root&#34;] --> A3[&#34;Middle&#34;] --> A4[&#34;Inner&#34;]
    end
    subgraph Up[&#34;touches 阶段(自底向上)&#34;]
        B4[&#34;Inner&#34;] --> B3[&#34;Middle&#34;] --> B2[&#34;Root&#34;] --> B1[&#34;Window → Application&#34;]
    end

两个方向必须分清:

  • hitTest:从根视图逐层向下,找到最深的命中视图。
  • touches:从命中视图沿 nextResponder 逐级向上传递。

上溯典型顺序是:Inner → Middle → Root → ViewController.view → ViewController → UIWindow → UIApplication。

两个容易翻车的点:

  1. 探针必须调用 [super touchesBegan:...],否则事件不会继续上抛,日志看着像"链断了",其实是自己把链掐断了。
  2. 只有实现了 touches* 的响应者才会打印,中间没实现方法的层级会被跳过,别误判为链断裂。

还有一个高频混淆点:

hit-test view ≠ firstResponder 前者是"本次触摸的投递目标"(事件链概念);后者是"键盘/焦点体系"(becomeFirstResponder)。点击 UITextField 让它成为 firstResponder,是 touch 触发后的结果,两者不是一回事。

六、调试页是怎么打开的:复用项目路由

这个调试页没有硬编码入口,而是接入了项目的组合式路由(Navigator 白名单 + Target-Action),加一条白名单即可通过 URL 打开:

[Navigator openURLString:@"multimoduledemo://Debug/touchProbe"];

对应的 Target_Debug 里用 #if DEBUG 包裹,保证只在调试构建下可用,不污染线上包:

- (id)Action_touchProbe:(NSDictionary<NSString *, id> *)params {
#if DEBUG
    dispatch_async(dispatch_get_main_queue(), ^{
        UIViewController *top = [self probe_topViewController];
        TouchProbeDebugViewController *vc = [[TouchProbeDebugViewController alloc] init];
        UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
        nav.modalPresentationStyle = UIModalPresentationFullScreen;
        [top presentViewController:nav animated:YES completion:nil];
    });
    return @{ @"status" : @"presenting" };
#else
    return @{ @"error" : @"debug_only" };
#endif
}

架构小贴士:调试能力也应该"可插拔"。用 #if DEBUG + 独立 Target,验证完可以整块删除或长期保留,都不影响业务模块。

七、总结

把整条链压缩成一张图:

sequenceDiagram
    participant HW as 硬件
    participant App as UIApplication
    participant Win as keyWindow
    participant View as 视图树
    participant Resp as 响应者链
    HW->>App: IOHIDEvent
    App->>Win: sendEvent:
    Win->>View: hitTest: 递归向下
    View->>View: pointInside: 逐层判定
    View-->>Win: 返回 hit-test view
    Win->>Resp: touchesBegan
    Resp->>Resp: nextResponder 逐级向上
    Resp->>Resp: touchesMoved / Ended / Cancelled

核心结论:

  1. 入口 sendEvent:,定位 hitTest:,投递 touches*。
  2. hitTest 父先子后、子逆序、命中即返回。
  3. pointInside 用父坐标系;命中边界由它决定,不是 clipsToBounds。
  4. hidden / alpha<0.01 / userInteractionEnabled=NO 会让整个子树被跳过。
  5. touches 方向与 hitTest 相反,沿 nextResponder 上溯,且必须 super 调用。

与其记住这些结论,不如把探针跑一遍——亲眼看到日志顺序,比背十条规则都管用。


如果你也在做事件分发相关的疑难排查,欢迎在评论区交流。项目里的 TouchProbe 模块可以直接搬走,#if DEBUG 包裹,零业务污染。


simulator_screenshot_D6FAA638-6123-499A-AA33-9B3A0A5996E5.png github link