前言
平时我们写业务,很少直接碰 hitTest、pointInside、nextResponder 这些 API。但一旦遇到"按钮点不动""子视图超出父视图点不中""蒙层挡住了事件"这类问题,如果对响应链没有完整认知,就只能靠猜和试。
与其背结论,不如动手验证 。这篇文章基于我在项目中落地的一个 TouchProbe 探针模块,用最少代码把整条事件链"打出来",边看日志边理解原理。
一、先建立宏观认知:一次点击到底经过了什么
一次触摸从手指按下到方法回调,完整链路如下:
记住一句话:入口是 sendEvent:,定位靠 hitTest:,投递靠 touches* 沿 nextResponder 上溯。
二、最小探针设计:三个切面,一个页面
为了把上面每一步都"看见",我设计了三个探针 + 一个调试页:
| 组件 | 挂钩点 | 验证目标 |
|---|---|---|
UIApplication+TouchProbe |
sendEvent: |
事件入口、目标窗口 |
TouchProbeView |
hitTest: / pointInside: / touchesBegan: |
命中顺序、坐标系、传递链 |
TouchProbeDebugViewController |
视图层级 | 对照实验开关 |
1. 入口探针:勾住 sendEvent:
objective-c
- (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:
objective-c
- (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 重写三个方法,进入/返回各打一行:
objective-c
- (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. 视图层级:故意设计成"会出问题"的结构
调试页搭了这样一棵树,每个设计都有目的:
- Overlap 后添加 :用来验证
hitTest的逆序遍历(后添加的先被测试)。 - Inner 超出 Middle 边界 :用来验证命中边界由
pointInside:决定。 - 页面提供 4 个开关:
hidden/alpha<0.01/userInteractionEnabled/clipsToBounds。
三、跑起来看日志:命中顺序全暴露
点击重叠区域,控制台输出顺序如下:
text
[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::
objective-c
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
// 扩大命中范围,允许子视图溢出区域也能响应
return CGRectContainsPoint(CGRectInset(self.bounds, -20, -20), point);
}
五、touches 阶段:方向与 hitTest 相反
两个方向必须分清:
- hitTest:从根视图逐层向下,找到最深的命中视图。
- touches :从命中视图沿
nextResponder逐级向上传递。
上溯典型顺序是:Inner → Middle → Root → ViewController.view → ViewController → UIWindow → UIApplication。
两个容易翻车的点:
- 探针必须调用
[super touchesBegan:...],否则事件不会继续上抛,日志看着像"链断了",其实是自己把链掐断了。 - 只有实现了
touches*的响应者才会打印,中间没实现方法的层级会被跳过,别误判为链断裂。
还有一个高频混淆点:
hit-test view ≠ firstResponder 前者是"本次触摸的投递目标"(事件链概念);后者是"键盘/焦点体系"(
becomeFirstResponder)。点击UITextField让它成为 firstResponder,是 touch 触发后的结果,两者不是一回事。
六、调试页是怎么打开的:复用项目路由
这个调试页没有硬编码入口,而是接入了项目的组合式路由(Navigator 白名单 + Target-Action),加一条白名单即可通过 URL 打开:
objective-c
[Navigator openURLString:@"multimoduledemo://Debug/touchProbe"];
对应的 Target_Debug 里用 #if DEBUG 包裹,保证只在调试构建下可用,不污染线上包:
objective-c
- (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,验证完可以整块删除或长期保留,都不影响业务模块。
七、总结
把整条链压缩成一张图:
核心结论:
- 入口
sendEvent:,定位hitTest:,投递touches*。 hitTest父先子后、子逆序、命中即返回。pointInside用父坐标系;命中边界由它决定,不是clipsToBounds。hidden/alpha<0.01/userInteractionEnabled=NO会让整个子树被跳过。touches方向与hitTest相反,沿nextResponder上溯,且必须super调用。
与其记住这些结论,不如把探针跑一遍------亲眼看到日志顺序,比背十条规则都管用。
如果你也在做事件分发相关的疑难排查,欢迎在评论区交流。项目里的 TouchProbe 模块可以直接搬走,#if DEBUG 包裹,零业务污染。