写在前面
最近在做 Feed 流全链路埋点治理项目时,我们遇到了一个非常微妙的线上数据问题:**同一个按钮点击事件在某些情况下,Feed 流卡片上的子按钮明明点了之后,卡片的卡片整体跳转也会也触发了。从业务的跳转页面跳转页面的也触发了。这个 bug 的。排查过程中涉及了 UIKit 触摸事件全链路中几个容易被忽略的细节问题,就顺手把这些东西系统梳理了一遍,整理成一篇总结,供有需要的同学参考一下。如有不准确的地方,欢迎斧正。
一、问题复现与初步猜测
我们简化复现路径:
- 自定义一个一个 Cell 内嵌一个删除按钮
- 删除按钮有两个两个独立的事件处理回调(独立事件处理:删除 = 卡片内按钮 = 跳转详情页)
- 快速点击删除按钮,偶现**两个回调都会触发
初步猜测是两个独立的手势识别器之间的手势识别器与普通触摸事件链路上有竞争,导致两个事件都触发了。经过调试,我们发现,先回到 UIKit 的两个核心步骤的触摸事件的两个独立的两个核心概念,它们分别是:命中测试(Hit Testing) 与 响应链 Responder Chain。下面我们先把这两者的原理先理清楚。
二、命中测试(Hit Testing):谁该谁是谁「找到最适合处理处理 touch 应该由谁来处理
2.1 基本流程
命中测试阶段发生在整个 App 进程在 拿到 IOHIDEvent 事件系统从底层硬件层 事件后的第一阶段的处理整个流程如下。整个流程步骤如下:

这里最关键的一点:命中测试阶段,返回的第一个非空非空返回值即第一响应者(hit-test view)。这个结果是这个是来决定这个 touch 这个 touch 交给处理的。 命中测试阶段,最关键的两个方法,系统默认实现流程可以抽象成如下伪代码:
objc
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// ① 4 种经典拦截情况
if (!self.userInteractionEnabled
|| self.hidden
|| self.alpha < 0.01f
|| ![self pointInside:point withEvent:event]) {
return nil;
}
// ② 倒序遍历子视图 (subviews 尾元素越越上层的先检查 后加的先查
for (NSInteger i = self.subviews.count - 1; i >= 0; i--) {
UIView *subview = self.subviews[i];
// ③ 坐标转换 + 子视图重新调用 hitTest
CGPoint subPoint = [self convertPoint:point toView:subview];
UIView *result = [subview hitTest:subPoint withEvent:event];
// ④ 短路返回:只要命中了子视图就直接返回,不用再继续向下查
if (result) return result;
}
// ⑤ 子视图都没命中,就命中自己
return self;
}
2.2 坑点 1:4 种返回 nil 的4 种情况 不参与响应链
上述伪代码的第一步的 4 种情况会直接导致 hitTest 返回 nil,在实际开发中很容易被忽略:
| 条件 | 说明 | 常见踩坑场景 |
|---|---|---|
userInteractionEnabled = NO |
用户交互关闭 | `UILabel/UIImageView 默认就是 YES,经常给给给 不交互 没有开 |
hidden = YES |
视图隐藏 | 隐藏的视图不参与命中 |
alpha < 0.01 |
透明度近乎透明 0.01 以下,系统认为不可见 | 做淡入淡出动画的时候容易触发 |
pointInside: 返回 NO |
判定点外 | 自定义扩大点击区域的拦截 |
2.3 扩大点击区域的工程实现
产品和 UIKit 官方 HIG 规范中明确建议可点击的热区不小于 44x44 pt。实际业务常常 24x24 的小图标按钮。这在小屏幕设备上用户体验非常差。这就需要扩大点击热区。在这个可通过**重写 pointInside:withEvent: 实现:
objc
@interface BigAreaButton : UIButton
/// 外扩内边距 负数=向外扩 正数=向内缩
@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets;
@end
@implementation BigAreaButton
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
// 默认四周外扩 20pt,小按钮可达 64x64
_hitTestEdgeInsets = UIEdgeInsetsMake(-20, -20, -20, -20);
}
return self;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGRect expandedRect = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets);
return CGRectContainsPoint(expandedRect, point);
}
@end
为什么推荐重写 pointInside: 而不是直接重写 hitTest:? 前者职责更关注「本视图的几何判定」,逻辑单一且不容易出错;后者需要处理子视图递归遍历的逻辑,写不好会影响子视图正常事件处理链条,容易引入新的坑。
三、响应链 Responder Chain:找到之后,谁来接棒处理
如果命中测试(Hit Testing)决定了「第一响应者」是谁之后,如果第一响应者没处理事件,就会进入响应链阶段,逐级往上冒泡寻找「谁能接棒处理这个事件。
3.1 响应链冒泡顺序
这里有个非常容易忽略的细节:UIViewController.view 的 nextResponder 不是 nil,也不是父视图,而是 UIViewController 本身。很多人容易漏掉这一层。
3.2 手势识别器对响应链的影响
手势识别器(Gesture Recognizer)的在实际开发中遇到的最频繁的事件冲突源头。因为它的处理优先级和系统触摸事件的处理优先级不同。
系统默认情况下的流程:
这正是我们文章开头提到的核心问题:父视图上加了 UITapGesture,按钮的 addTarget 会因为 touchesCancelled 而不触发。
3.3 三种主流解法的对比
| 方案 | 机制 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
A. requireGestureRecognizerToFail: |
建立手势间的状态依赖门闩 | 系统原生支持,互斥精确 | 只能用在手势之间 | ⭐️⭐️⭐️ 首选 Cell 子视图 vs 整层点击 |
B. cancelsTouchesInView = NO |
手势识别成功不发 touchesCancelled | 一行代码,View 事件不中断 | 两个 handler 都会触发,业务要做双消费互斥 | 需要同时响应的特殊场景 |
| C. 重写 pointInside / hitTest | 命中层拦截 | 彻底,拿不到系统手势句柄时兜底可用 | 调试成本高,视图层级复杂时容易写漏 | 系统手势拿不到句柄的特殊场景 |
3.4 方案 A 的状态机原理(重点推荐解法)
requireGestureRecognizerToFail: 本质不是简单优先级调度,而是状态机门闩。它强制 A 手势的 Ended 状态必须等待 B 手势 Failed 后才能继续流转:
举个具体例子:模拟 Cell 点击和子按钮点击互斥的正确写法是:
objc
// ✅ 正确:整层 Cell 点击 必须等 子按钮 手势 明确失败
[cellWholeTapGesture requireGestureRecognizerToFail:btnInsideGesture];
// ❌ 错误写反:子按钮点击 必须等 Cell 点击 失败 → 子按钮基本永远点不了
// [btnInsideGesture requireGestureRecognizerToFail:cellWholeTapGesture];
四、工程化封装:可复用在 Base 类中的一套通用方案
结合我们 Feed 流 100+ 种 Cell 的治理过程,最终沉淀到 BaseTableViewCell 中的两个 API:
objc
@interface BaseTableViewCell : UITableViewCell
/// 注册为交互热区:交互热区内点击不会触发 Cell 选中
- (void)registerInteractiveSubview:(UIView *)hotView;
- (void)registerInteractiveSubviews:(NSArray<UIView *> *)hotViews;
@end
@implementation BaseTableViewCell {
NSMutableArray<UIGestureRecognizer *> *_hoteRequireFailDependentGesturePool
}
- (void)registerInteractiveSubview:(UIView *)hotView {
if (!hotView) return;
// ① 热区加空手势作为一个占位手势
UITapGestureRecognizer *hotGuard = [[UITapGestureRecognizer alloc] initWithTarget:nil action:NULL];
hotGuard.cancelsTouchesInView = NO;
hotGuard.delegate = self;
[hotGuard addObserver
[hotView addGestureRecognizer:hotGuard];
// ② 遍历寻找系统内置的点击选中识别器
for (UIGestureRecognizer *ges in self.gestureRecognizers) {
BOOL isSelection = [NSStringFromClass(ges.class) containsString:@"Select"]
|| [NSStringFromClass(ges.class) containsString:@"Selection"];
if (isSelection) {
// ③ 核心:整层选中手势要等热区手势 Failed
[ges requireGestureRecognizerToFail:hotGuard];
}
}
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
CGPoint p = [touch locationInView:gestureRecognizer.view];
return CGRectContainsPoint(gestureRecognizer.view.bounds, p);
}
@end
这样业务同学在写自定义 Cell 时,只需要一行:
objc
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
_deleteBtn = [UIButton buttonWithType:UIButtonTypeCustom];
[self.contentView addSubview:_deleteBtn];
// ⭐️ 业务无感知:只要一行注册
[self registerInteractiveSubview:_deleteBtn];
}
return self;
}
不用关心命中测试/响应链/手势冲突,全部都由 Base 类统一兜底。
五、验收方案
我们治理前后的数据对比(同一批用户做 A/B 实验一周:
| 指标 | 治理前 | 治理后 | 改善幅度 |
|---|---|---|---|
| 卡片点击-删除按钮同时触发率 | 0.82% | 0.03% | ↓ 96.3% |
| 卡片埋点异常 PV | 4.2 / 千次卡片曝光 | 0.6 / 千次卡片曝光 | ↓ 85.7% |
| 按钮点击转化率 | 5.1% | 5.6% | ↑ 9.8% |
六、总结与后续规划
本次埋点数据异常的排查过程也让我们对 UIKit 触摸事件的完整链路有了更细节的理解。后续计划:
- 把这套 Base 方案进一步推广到
UICollectionViewCell基类 - 结合自动化测试环节增加
HitTest自动检测断言方案(通过注入自动化测试层自动检测按钮点击热区小于 44x44 的场景并自动报警 - 手势冲突自动化检测工具(运行时自动检测
requireGestureRecognizerToFail:顺序写反的情况),继续深化这一块的工具链建设。
参考资料
- Apple UIKit Documentation: Event Handling Guide for UIKit Apps
- Apple Documentation: UIGestureRecognizer
- GNUstep Base/UIKit 源码分析
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、评论区交流。有任何不对的地方欢迎评论区友好指出。希望帮到少踩点坑 :) ⛽️⛽️