在使用手机的过程中,会产生很多交互事件,如触摸屏幕、摇晃、按下按键、使用耳机操控设备等。这些事件都需要系统去响应并作出处理。这篇文章将介绍系统是如何响应、处理这些事件的。
1. UIEvent
UIEvent描述用户与 app 的单次交互。
应用可以接收不同类型的事件(event),包括触摸事件(touch events)、运动事件(motion events)、远程控制事件(remote-control events)和按下物理按键事件(press events)。触摸事件是最常见的事件,发送给触摸的视图。运动事件由 UIKit 触发,并与 Core Motion framework 运动事件进行区分。远程控制事件允许 responder 对象接收外部配件的指令(如耳机),以便管理音视频播放。Press events 代表与 game controller、AppleTV remote 或其他有物理按键设备的交互。可以使用type和subtype属性判断事件类型。
Touch event 对象包含与事件相关的 touch。Touch event 对象包含一个或多个 touch,每个 touch 都由UITouch对象表示。当发生触摸事件时,系统将事件路由至合适的响应者,并调用touchesBegan(_:with:)方法,responder 提取 touch 中的数据,并作出适当响应。
在多点触摸序列中,向 app 传递更新的触摸数据时,UIKit 会复用UIEvent对象。因此,不要持有UIEvent对象及其中的数据。如果需要在响应者方法之外使用UIEvent、UITouch数据,应在响应者方法中处理数据,并复制到自定义的数据结构中。
2. UITouch
UITouch对象表示屏幕上 touch 的位置、大小、移动和压力。
通过传递给 responder 的UIEvent来获取UITouch对象,UITouch提供以下信息:
- touch 发生的 view 或 window。
- touch 位于 view 或 window 的位置。
- touch 大致半径。
- touch 压力大小(支持 3D Touch 或 Apple Pencil 的设备)。
此外,touch 对象使用timestamp属性表示 touch 发生的时间,整数类型的tapCount属性表示点击屏幕的次数,UITouch.Phase属性表示处于began、moved、ended、canceled等阶段,
Touch 对象会在整个多点触控序列中存在。当处理多点触控序列时,可以引用 touch 对象,直到触控结束才释放。如果需要在多点触控序列外使用,需复制 touch 中的数据到自定义数据结构。
gestureRecognizers属性包含了当前处理 touch 的手势。
3. UIResponder
UIResponder类抽象了响应和处理事件的接口。
响应者(responder)对象(即UIResponder的实例)构成了事件处理的骨干。很多对象都继承自UIResponder,如UIApplication、UIViewController、UIView(UIWindow继承自UIView,进而也继承自UIResponder)。事件发生时,UIKit调度事件给 responder 处理。
想要处理特定类型事件,responder 必须重写对应方法。例如,想要处理 touch event,responder 需重写以下方法:
- touchesBegan(_:with:)
- touchesMoved(_:with:)
- touchesEnded(_:with:)
- touchesCancelled(_:with:)
在触摸事件中,responder 使用参数中的 event 信息跟踪 touch 变化,并更新 UI。
除了处理事件,responder 还可以转发未处理的事件。如果指定 responder 未处理事件,它会将事件转发给响应链(responder chain)中的 next responder。UIKit 根据规则,动态管理响应链。
Responder 对象除了处理UIEvent,还可以通过inputView接收自定义输入,系统的键盘就是一种 input view。用户点击屏幕上的UITextField或UITextView时,它成为 first responder 并显示 input view,默认展示系统键盘。可以创建自定义 input view 赋值给inputView属性,当其成为第一响应者时展示。
4. 事件传递
当 iPhone 接收到一个事件时,处理过程如下:
通过动作触发事件,唤醒处于睡眠状态的app。
-
使用 IOKit.framework 将事件封装为 IOHIDEvent 对象。
IOKit.framework 是一个系统框架的集合,用来驱动一些系统事件。IOHIDEvent 中的 HID 代表 Human Interface Device。
系统通过 mach port 将 IOHIDEvent 对象转发给 SpringBoard.app。
SpringBoard.app 是 iOS 系统桌面 app,只接收按键、触摸、加速、接近传感器等几种 event。SpringBoard.app 找到可以响应这个事件的 app,并通过 mach port 将 IOHIDEvent 对象转发给 app 。
app 主线程 RunLoop 接收到 SpringBoard.app 转发的消息,触发对应 mach port 的 source1 回调 __IOHIDEventSystemClientQueueCallback()。
Source1 回调内部触发 Source0 回调 __UIApplicationHandleEventQueue()。
Source0 回调内部,将 IOHIDEvent 对象转化为
UIEvent。Source0 回调内部调用 UIApplication 的
sendEvent(_:)方法,将UIEvent发给UIWindow。
UIWindow接收到事件后,开始传递事件。
5. 第一响应者 First Responder
下图显示了包含UILabel、UITextField、UIButton、UIView、UIViewController、UIWindow等视图的事件传递。

如果文本框没有处理 event,UIKit 转发事件给文本框的父视图UIView,随后是控制器的根视图、视图控制器、window。如果 window 也没有处理 event,UIKit 转发 event 至 UIApplication。如果 app delegate 是UIResponder子类,且未处于 responder chain,则也可能转发给 app delegate。
UIKit 根据事件类型指定第一响应者,事件类型如下:
| 事件类型 | 第一响应者 |
|---|---|
| 触摸事件 | 触摸的视图 |
| 按压事件 | 焦点对象 |
| 晃动事件 | 开发者或UIKit指定的对象 |
| 远程控制事件 | 开发者或UIKit指定的对象 |
| 编辑按钮信息 | 开发者或UIKit指定的对象 |
与加速计、陀螺仪、磁力仪相关的运动事件,不遵守 responder chain,Core Motion 直接将事件发送给指定对象。
触摸事件传递大致分为以下三个阶段:
- 寻找触摸对象 hit-testing。
- 响应手势 recognize gesture。
- 触摸事件传递 response chain。
5.1 Hit-testing
Hit-testing 是查找 touch point 是否位于指定视图上的过程。iOS 使用 hit-testing 查找触摸事件最前的视图(即视图数组中 index 最大的视图),hit-testing 使用逆序深度优先遍历算法(reverse pre-order depth-first traversal algorithm)查找视图。
在介绍 hit-testing 如何工作前,先看下从手指触摸屏幕到抬起的单次触摸流程:

如上图所示,每次触摸屏幕的时候都会调用 hit-testing,并且是在视图、gesture recognizer 收到UIEvent之前。
Hit-testing 结束后,触摸位置下最前端视图被选为 first responder,它被关联到UITouch对象,并且 touch event 的所有阶段都会关联此视图。除了 hit test 视图,添加到 hit test 视图、父视图上的手势,都会关联到UITouch对象。最后,hit test 视图开始接受触摸系列事件。
即使手指已经滑动到 hit test view 边界之外,到了另一个视图上,hit test view 也会继续接收
UITouch,直到这次 touch event 序列结束。
像前面说到的,hit-testing 使用逆序深度优先遍历算法(先查找到根视图,然后从高 index 开始遍历子视图)。子视图永远渲染在父视图之上;子视图数组中,兄弟视图(sibling view)中高 index 对象更大概率渲染在低 index 对象之上,多个视图覆盖在一起时,数组中高 index 对象更大概率是最前端视图。因此,逆序深度优先遍历算法可以减少遍历次数。
子视图会覆盖部分或全部父视图。父视图使用数组存储子视图,在数组中顺序决定子视图的可见性。如果两个子视图覆盖在一起,后添加子视图覆盖在先添加的子视图之上。
下图显示了视图和对应视图层级树,视图树从左至右反映了视图添加到父视图的顺序。

如上图所示,View A 和 View B,及其子视图 View A.2 和 View B.1覆盖在一起,但由于 View B 的 index 大于 View A,View B 和它的子视图渲染到了 View A 和它的子视图上面。因此,当触摸重叠的 View B.1 区域时,hit-testing 返回 View B.1 视图。
逆序深度优先遍历算法查找过程如下:

UIWindow是视图层级结构的根视图,查找时先向UIWindow发送hitTest(_:with:)消息,该方法会返回包含触摸位置的视图。
下图显示了查找逻辑:

下面代码演示了hitTest(_:event:)方法可能的实现方式:
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 只有允许用户交互、没有隐藏、可见度大于 0.01 时,才允许接收手势。
guard isUserInteractionEnabled, !isHidden, alpha > 0.01 else { return nil }
// 点击point不在视图中时,直接返回 nil。
guard self.point(inside: point, with: event) else {
return nil
}
// 逆序遍历子视图
for subView in subviews.reversed() {
let convertedPoint = subView.convert(point, from: self)
let hitTestView = subView.hitTest(convertedPoint, with: event)
// 首个非空子视图即为 first responder
if let hitView = hitTestView {
return hitView
}
}
// 如果所有子视图都没有响应 hit-testing,则视图本身为 first responder。
return self
}
使用 runtime 的 method swizzling 交换系统的hitTest(_:event:)方法,每次调用时输出当前类,查找到 first responder 时输出 first responder。如下所示:
extension UIView{
public class func initializeMethod() {
// hit-testing
let originalHitSelector = #selector(UIView.hitTest(_:with:))
let swizzleHitSelector = #selector(UIView.pr_hitTest(_:with:))
guard let originalHitMethod = class_getInstanceMethod(self, originalHitSelector) else { return }
guard let swizzleHitMethod = class_getInstanceMethod(self, swizzleHitSelector) else { return }
let didAddHitMethod = class_addMethod(self, originalHitSelector, method_getImplementation(swizzleHitMethod), method_getTypeEncoding(swizzleHitMethod))
if didAddHitMethod {
class_replaceMethod(self, swizzleHitSelector, method_getImplementation(originalHitMethod), method_getTypeEncoding(swizzleHitMethod))
} else {
method_exchangeImplementations(originalHitMethod, swizzleHitMethod)
}
// pointInside
let originalInsideSelector = #selector(UIView.point(inside:with:))
let swizzleInsideSelector = #selector(UIView.pr_point(inside:with:))
guard let originalInsideMethod = class_getInstanceMethod(self, originalInsideSelector) else { return }
guard let swizzleInsideMethod = class_getInstanceMethod(self, swizzleInsideSelector) else { return }
let didAddInsideMethod = class_addMethod(self, originalInsideSelector, method_getImplementation(swizzleInsideMethod), method_getTypeEncoding(swizzleInsideMethod))
if didAddInsideMethod {
class_replaceMethod(self, swizzleInsideSelector, method_getImplementation(originalInsideMethod), method_getTypeEncoding(swizzleInsideMethod))
} else {
method_exchangeImplementations(originalInsideMethod, swizzleInsideMethod)
}
}
// 交换系统的hitTest(_:event:)方法,hit-Testing 时可以查看查找顺序。
@objc public func pr_hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
print(NSStringFromClass(type(of: self)) + " " + #function)
let result = pr_hitTest(point, with: event)
if result != nil {
print((NSStringFromClass(type(of: self))) + " hitTesting return:" + NSStringFromClass(type(of: result!)))
}
return result
}
@objc public func pr_point(inside point: CGPoint, with event: UIEvent?) -> Bool {
print(NSStringFromClass(type(of: self)) + " --- pointInside")
let result = pr_point(inside: point, with: event)
print(NSStringFromClass(type(of: self)) + " pointInside +++ return: \(result)")
return result
}
}
如果你对 runtime 还不了解,可以查看我的文章:Runtime从入门到进阶一和Runtime从入门到进阶二。
点击 ViewB1:

输出如下:
UITextEffectsWindow pr_hitTest(_:with:)
UITextEffectsWindow --- pointInside
UITextEffectsWindow pointInside +++ return: true
UIInputSetContainerView pr_hitTest(_:with:)
UIEditingOverlayGestureView --- pointInside
UIEditingOverlayGestureView pointInside +++ return: true
UIEditingOverlayGestureView pr_hitTest(_:with:)
UIEditingOverlayGestureView --- pointInside
UIEditingOverlayGestureView pointInside +++ return: true
UIEditingOverlayGestureView hitTesting return:UIEditingOverlayGestureView
UIInputSetHostView pr_hitTest(_:with:)
UIInputSetContainerView hitTesting return:UIInputSetContainerView
UITextEffectsWindow hitTesting return:UITextEffectsWindow
// hit-testing 时会调用point inside。
UIWindow pr_hitTest(_:with:)
UIWindow --- pointInside
UIWindow pointInside +++ return: true
UITransitionView pr_hitTest(_:with:)
UITransitionView --- pointInside
UITransitionView pointInside +++ return: true
UIDropShadowView pr_hitTest(_:with:)
UIDropShadowView --- pointInside
UIDropShadowView pointInside +++ return: true
UILayoutContainerView pr_hitTest(_:with:)
UILayoutContainerView --- pointInside
UILayoutContainerView pointInside +++ return: true
UINavigationBar pr_hitTest(_:with:)
UINavigationBar --- pointInside
UINavigationBar pointInside +++ return: false
UINavigationTransitionView pr_hitTest(_:with:)
UINavigationTransitionView --- pointInside
UINavigationTransitionView pointInside +++ return: true
UIViewControllerWrapperView pr_hitTest(_:with:)
UIViewControllerWrapperView --- pointInside
UIViewControllerWrapperView pointInside +++ return: true
// UIView、ViewC、ViewB、ViewB2、ViewB1依次调用 hitTest。
UIView pr_hitTest(_:with:)
UIView --- pointInside
UIView pointInside +++ return: true
ResponderChain.ViewC pr_hitTest(_:with:)
ResponderChain.ViewC --- pointInside
ResponderChain.ViewC pointInside +++ return: false
ResponderChain.ViewB pr_hitTest(_:with:)
ResponderChain.ViewB --- pointInside
ResponderChain.ViewB pointInside +++ return: true
ResponderChain.ViewB2 pr_hitTest(_:with:)
ResponderChain.ViewB2 --- pointInside
ResponderChain.ViewB2 pointInside +++ return: false
ResponderChain.ViewB1 pr_hitTest(_:with:)
ResponderChain.ViewB1 --- pointInside
ResponderChain.ViewB1 pointInside +++ return: true
ResponderChain.ViewB1 hitTesting return:ResponderChain.ViewB1
ResponderChain.ViewB hitTesting return:ResponderChain.ViewB1
UIView hitTesting return:ResponderChain.ViewB1
UIViewControllerWrapperView hitTesting return:ResponderChain.ViewB1
UINavigationTransitionView hitTesting return:ResponderChain.ViewB1
UILayoutContainerView hitTesting return:ResponderChain.ViewB1
UIDropShadowView hitTesting return:ResponderChain.ViewB1
UITransitionView hitTesting return:ResponderChain.ViewB1
// 再次执行 hit-Testing
UIWindow hitTesting return:ResponderChain.ViewB1
UIWindow pr_hitTest(_:with:)
UIWindow --- pointInside
UIWindow pointInside +++ return: true
UITransitionView pr_hitTest(_:with:)
UITransitionView --- pointInside
UITransitionView pointInside +++ return: true
UIDropShadowView pr_hitTest(_:with:)
UIDropShadowView --- pointInside
UIDropShadowView pointInside +++ return: true
UILayoutContainerView pr_hitTest(_:with:)
UILayoutContainerView --- pointInside
UILayoutContainerView pointInside +++ return: true
UINavigationBar pr_hitTest(_:with:)
UINavigationBar --- pointInside
UINavigationBar pointInside +++ return: false
UINavigationTransitionView pr_hitTest(_:with:)
UINavigationTransitionView --- pointInside
UINavigationTransitionView pointInside +++ return: true
UIViewControllerWrapperView pr_hitTest(_:with:)
UIViewControllerWrapperView --- pointInside
UIViewControllerWrapperView pointInside +++ return: true
UIView pr_hitTest(_:with:)
UIView --- pointInside
UIView pointInside +++ return: true
ResponderChain.ViewC pr_hitTest(_:with:)
...
从日志中可以看到,首先是UIWindow开始调用 hitTest,然后是导航控制器视图、根视图,之后是ViewC,ViewC返回 false后,开始遍历ViewB,ViewB返回 ture 后,先遍历 ViewB2,ViewB2 返回 false 后才遍历 ViewB1,最终返回 ViewB1。
可以看到,一次点击会调用两次
hitTest(_:event:),这是因为系统在调用 hit test 时,可能微调点击位置,hitTest(_:event:)只是单纯的函数,没有其它副作用。
6. 用途
通过重写hitTest(_:event:)、pointInside(point:event:)方法,可以将事件转发给其它视图处理。
由于先执行
hitTest(_:event:)、后发送 event,重写hitTest(_:event:)、pointInside(point:event:)方法后,转发的事件是完整事件,即包含UITouch.Phase.began、UITouch.Phase.moved、UITouch.Phase.ended等所有阶段的事件。
6.1 扩大可点击区域
一个常见用途就是扩大可点击区域,使它大于视图bounds。下图的按钮大小为 20*20,不太方便点击,可以使用pointInside(point:event:)扩大其可点击区域。

为了使其更具有通用性,为UIButton增加分类,在分类方法中实现改变可点击区域的功能,这样后续想要使用时,只需调用分类方法即可。
下面使用runtime为分类添加属性的方式,实现扩大UIButton可点击区域的功能:
// 修改 UIButton 可点击区域
struct AssociateKeys {
static var topKey: UInt8 = 0
static var leftKey: UInt8 = 1
static var bottomKey: UInt8 = 2
static var rightKey: UInt8 = 3
}
protocol HitTestSlopProtocol {
func expand(edgeInset hitTestSlop: UIEdgeInsets)
}
extension UIButton: HitTestSlopProtocol {
func expand(edgeInset hitTestSlop: UIEdgeInsets) {
objc_setAssociatedObject(self, &AssociateKeys.topKey, hitTestSlop.top, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(self, &AssociateKeys.leftKey, hitTestSlop.left, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(self, &AssociateKeys.bottomKey, hitTestSlop.bottom, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(self, &AssociateKeys.rightKey, hitTestSlop.right, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
private func slop() -> UIEdgeInsets {
guard let topValue = objc_getAssociatedObject(self, &AssociateKeys.topKey) as? CGFloat,
let leftValue = objc_getAssociatedObject(self, &AssociateKeys.leftKey) as? CGFloat,
let bottomValue = objc_getAssociatedObject(self, &AssociateKeys.bottomKey) as? CGFloat,
let rightValue = objc_getAssociatedObject(self, &AssociateKeys.rightKey) as? CGFloat else { return .zero}
return UIEdgeInsets(top: topValue, left: leftValue, bottom: bottomValue, right: rightValue)
}
open override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let insets = slop()
if insets == .zero {
// Safer to use UIView's point(inside:with:) if we can.
return super.point(inside: point, with: event)
} else {
return bounds.inset(by: insets).contains(point)
}
}
}
后续想要改变可点击区域时,只需调用expand(edgeInset:)方法即可:
button.expand(edgeInset: UIEdgeInsets(top: -30, left: -30, bottom: -30, right: -30))
点击 button 之外,也可以触发 button 事件,如下所示:

通过重写
hitTest(_:event:),可以实现 tabbar 中部突出部分响应手势的需求。与上面实现有些类似,但不必写成分类。
6.2 事件集中处理
假设视图控制器中有一个 table view,cell 上有两个按钮 firstButton、secondButton,点击按钮、cell本身都会触发事件。以前我们一般直接处理事件,或使用delegate、closure等回调给视图控制器处理,现在我们可以使用 nextResponder 将所有响应都传递到控制器处理,这样代码逻辑会更清晰,业务逻辑也变得更简单。
extension UIResponder {
/// 将事件转发给下一 responder
/// - Parameters:
/// - event: 事件名称
/// - userInfo: 事件附带的额外信息
@objc func routerEvent(with event: String, userInfo: [String:String]) {
print(NSStringFromClass(type(of: self)) + " " + #function)
self.next?.routerEvent(with: event, userInfo: userInfo)
}
}
// Cell 按钮的点击事件
@objc private func firstButtonTapped(_: UIButton) {
print(NSStringFromClass(type(of: self)) + " " + #function)
// 路由给控制器处理
routerEvent(with: "firstButton", userInfo: [:])
}
@objc private func secondButtonTapped(_: UIButton) {
print(NSStringFromClass(type(of: self)) + " " + #function)
routerEvent(with: "secondButton", userInfo: [:])
}
extension RouterEventVC {
// 统一处理所有事件
override func routerEvent(with event: String, userInfo: [String : String]) {
if event == "firstButton" {
print("firstButton Clicked")
} else if event == "secondButton" {
print("secondButton Clicked")
} else {
print("Something else Clicked")
}
}
}
这样就可以将点击firstButton、secondButton的响应方法集中在 RouterEventVC 中处理。点击 firstButton 时,触发routerEvent(with: "firstButton", userInfo: [:])方法,此时将事件转发给UITableViewCell;由于 cell 没有处理事件,cell 将事件转发给UITableView处理;由于UITableView没有处理事件,table view 将事件转发给 RouterEventVC 的根视图UIView;由于UIView没有处理事件,它将事件转发给 RouterEventVC,RouterEventVC 已经处理了事件,不再进行转发。最后,也就由视图控制器统一处理。
总结
事件响应链和传递链完全相反。最有机会处理事件的就是通过事件传递找到的 first responder;如果 first responder 没有进行处理,就会沿着事件响应链传递给下一个响应者 next responder,一直追溯到最上层的 UIApplication。若都没有进行处理,就丢弃事件。
Demo名称:ResponderChain
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/ResponderChain
参考资料:
欢迎更多指正:https://github.com/pro648/tips
本文地址:https://github.com/pro648/tips/blob/master/sources/事件传递和响应链(Responder%20Chain).md
