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

一、 基础概念:响应者对象 (UIResponder)
学习 UIKit 触摸分发首先要理解 UIResponder。参与 responder chain、接收 touchesBegan: 等 UIResponder 回调的对象需要继承 UIResponder。但手势识别器等组件也会观察触摸序列,所以不要把“所有事件处理能力”都等同于 UIResponder 子类。
常见子类包括:
-
UIView -
UIViewController -
UIApplication -
UIWindow(继承自 UIView)
二、 事件的产生与底层源头(RunLoop 视角)

这是面试中区分初级和资深开发的一个点:事件是如何从硬件传到 App 的?
-
系统输入链路: 触摸从硬件和系统输入服务进入 UIKit,跨进程部分会涉及 Mach 消息,并唤醒应用主线程事件循环。
IOHIDEvent、SpringBoard/相关系统进程和端口分发细节属于私有实现,系统版本可能变化。 -
UIKit 转换与分发: UIKit 把底层输入转换为
UIEvent/UITouch并进入窗口系统。不能把这条私有管线固定描述成“Source1 必然转换成 Source0”。Source0 和 Source1 是 RunLoop Source 的机制分类,不是触摸事件的公开两阶段协议。 -
事件顺序: 系统会维护输入事件的时序和触摸序列状态,但 UIKit 没有公开一个可让业务依赖其内部结构的
UIApplicationFIFO 队列。- 同一触摸序列中的 began、moved、ended/cancelled 需要保持因果顺序;多来源事件还会受 RunLoop Mode、事件合并和系统调度影响,不能只用一个简单 FIFO 解释全部行为。
三、 事件的传递(Hit-Testing)—— 寻找最合适的 View
这是事件处理的第一步:自上而下(UIApplication -> Window -> View -> Subview)寻找“靶心”。
3.1 传递流程
-
UIKit 将事件路由到对应
UIWindow。多 Scene 应用可能同时存在多个 window,keyWindow也不再适合作为所有场景的全局唯一入口。 -
keyWindow判断自己能否响应,并判断点是否在自己身上。 -
倒序遍历子控件(从后往前,即
subviews.lastObject开始),重复上述步骤。- 原因:后添加的 View 在视觉上覆盖在先添加的 View 之上,优先响应最上面的 View 符合视觉逻辑,同时能减少循环次数。
-
一旦找到符合条件的子 View,就将其作为
fitView返回,停止遍历。 -
如果没有符合条件的子控件,但自己满足条件,则自己就是最合适的 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: 可以实现特殊需求:
-
扩大点击区域:重写
pointInside,判断点在 bounds 向外延伸的范围内即返回 YES。 -
穿透点击:让下层的 View 响应事件。在顶层 View 的
hitTest中返回nil,事件就会自动传递给被遮挡的 View(前提是父 View 继续寻找)。 -
子视图超出父视图范围响应:默认情况下,子视图超出父视图部分无效(因为父视图
pointInside返回 NO,根本不会遍历子视图)。解决方案是重写父视图的hitTest或pointInside。
四、 事件的响应(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)传递的。
-
Initial View 处理事件(调用 touches 方法)。
-
如果 View 没有重写 touches 方法,或者在 touches 方法中调用了
[super touchesBegan/Moved/Ended...],事件会传递给nextResponder。 -
Next Responder 查找规则:
- UIView: 若是 VC 的 Root View,下一个是 VC;否则是
superview。
- UIView: 若是 VC 的 Root View,下一个是 VC;否则是
-
UIViewController:
nextResponder关系会结合 view hierarchy、parent/presentation/container controller 结构决定,不宜固定成“它的 view.superview”。-
UIWindow: 下一个是
UIApplication。 -
UIApplication: 下一个是
AppDelegate(如果它是 UIResponder)。
-
- 如果传递到最后都没人处理,事件被丢弃。!
五、 高阶难点:事件响应的冲突与特殊处理
这部分内容是资深工程师必须掌握的细节。
5.1 手势识别器 (Gesture Recognizer) vs 触摸事件 (Touches)
这是最容易混淆的地方。UIGestureRecognizer 也是通过 Hit-Testing 绑定到 View 上的,但它的优先级通常高于 View 自身的 touches 方法。
-
默认行为:
-
当触摸发生,系统同时将事件发送给 View (touchesBegan) 和 绑定在 View (及父视图) 上的 GestureRecognizer。
-
如果 GestureRecognizer 识别失败,View 继续接收 touchesEnded。
-
如果 GestureRecognizer 识别成功:
-
它会独占该事件。
-
系统会向 View 发送
touchesCancelled,终止 View 的事件处理。 -
View 不会再收到 touchesEnded。
-
-
-
关键属性:
-
cancelsTouchesInView(默认 YES):识别成功后取消 View 的触摸。设为 NO 则两者共存(View 能收到 touchesEnded)。 -
delaysTouchesBegan(默认 NO):只有手势识别失败后,才把 touchesBegan 发送给 View。用于解决点击态闪烁问题。
-
5.2 UIControl (Target-Action) 的特殊性
UIButton、UISlider 等继承自 UIControl。
-
常见现象:点击 Button 时,父 View 自己覆写的
touchesBegan往往不会像普通未处理触摸那样收到转发,但结果还受控件实现、手势识别器和自定义响应链代码影响。 -
原因:
UIControl内部重写了 touches 方法来处理 Target-Action 逻辑。它默认阻断了响应链的向上传递(没有调用 super)。 -
区别:
-
UILabel/UIView:默认不处理,透传给父控件。 -
UIButton:处理并吞掉事件,父控件收不到。
-
5.3 不要把它理解成固定优先级排序
触摸会同时交给 hit-test view、相关 responder 与附着在视图层级上的 gesture recognizer 进行竞争和状态判断。识别器之间还受 failure requirement、delegate、cancelsTouchesInView、delaysTouchesBegan/Ended 等配置影响。UIControl 的 target-action 则建立在自身触摸跟踪之上。它们不是一个永远按 1、2、3 顺序执行的优先级队列。
六、 总结
iOS 的事件机制是一个精密设计的流程:
-
系统输入层:底层输入通过系统服务和事件循环进入 UIKit;Source0/Source1 细节只作实现理解,不作为稳定业务协议。
-
寻找层 (Hit-Test):自上而下,倒序遍历,利用
pointInside和坐标转换找到最合适的fitView。 -
响应层 (Responder Chain):自下而上,
nextResponder传递。 -
干扰项:注意手势识别器对标准响应链的“拦截”和“取消”机制。
掌握这套逻辑,不仅能应对面试中的“如何扩大按钮点击范围”、“父视图如何拦截子视图事件”、“手势冲突解决”等问题,也能在实际开发中处理复杂的交互场景。