0%

探究 iOS 手势处理实现

最近遇到一个非常奇怪的bug点击Cell上的某个View有一定概率不响应手势,透传到tableview上了。因为这一块完全是黑盒,只能通过“header dump + 调用栈分析 + 汇编代码”逆向摸清楚到底是什么原因导致了手势不响应了。虽然我查了三天也没有查出问题代码到底在哪里……但是仔细研究了一下手势,故此在这里做一次总结。

正文

就点击事件而言,把用户操作可以简单分为以下两个阶段

  1. 按下
  2. 抬起

两个阶段中间隔了好几次 runloop

因为响应者链是动态确定的,手势更新过程中如果 hitTest view 从视图树中移除后又添加回来会发生什么怎?

我在 Demo 中起了个 timer 不停执行 remove/add view 的操作,保证1、2两个阶段中间执行了前面说的诡异操作移除view并且重新添加回原来位置的行为。

不停点击 B 区域,发现 B 永远得不到响应,反而A区域的手势得到响应。

本文相关 Demo

本文的目的:苹果用 Event Handling, Responders, and the Responder Chain 的抽象和封装实现了开发人员对手势等交互行为快速的开发,但是问题也就随之而来,作为几乎是最古老的 API,很多开发人员只会简单得使用而已(说的就是我)。

终极目标是从设计层面还原整套手势系统内部是怎么实现的,如果自己要实现手势系统应该怎么做

深入理解RunLoop 中有相关这部分逻辑的简单介绍

事件响应

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。这个过程的详细情况可以参考这里。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的App进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

但是我调试的过程中,感觉还是有些出入的,比方说:

  1. 包装 event 的阶段其实是 Source0。
  2. 没看到 _UIGestureRecognizerUpdateObserver,倒是在 UIGestureEnvironment中看到了一个 Observer _gestureEnvironmentUpdateObserver,不过没有观察到被调用。
  3. 手势的回调也是在 Source0 中做的。

所以强调一下,本文是基于 iOS 11 SDK 的测试结果。

本文就从 runloop 进行划分,看看每一轮 runloop 都发生了什么

第一次 runloop 之前

先说一下UIGestureEnvironment (以下简称 environment)

当调用 [UIView addGestureRecognizer:] 的时候会把 gesture 加入 environment 中

这是一个私有的类,但是为了整理整个手势识别的过程,我们还是需要看一下这个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@interface UIApplication : UIResponder  {
UIGestureEnvironment * __gestureEnvironment;
}
@end

@interface UIGestureRecognizer : NSObject {
UIGestureEnvironment * _gestureEnvironment;
}
@end

@interface UIGestureEnvironment : NSObject {

CFRunLoopObserverRef _gestureEnvironmentUpdateObserver;
NSMutableSet* _gestureRecognizersNeedingUpdate;
NSMutableSet* _gestureRecognizersNeedingReset;
NSMutableSet* _gestureRecognizersNeedingRemoval;
NSMutableArray* _dirtyGestureRecognizers;
NSMutableArray* _delayedTouches;
NSMutableArray* _delayedTouchesToSend;
NSMutableArray* _delayedPresses;
NSMutableArray* _delayedPressesToSend;
NSMutableArray* _preUpdateActions;
bool _dirtyGestureRecognizersUnsorted;
bool _updateExclusivity;
UIGestureGraph* _dependencyGraph;
NSMapTable* _nodesByGestureRecognizer;

}

-(void)addGestureRecognizer:(id)arg1 ;
-(void)removeGestureRecognizer:(id)arg1 ;
-(void)_cancelGestureRecognizers:(id)arg1 ;
-(void)_updateGesturesForEvent:(id)arg1 window:(id)arg2 ;
(省略了很多 API)
-(void)_cancelTouches:(id)arg1 event:(id)arg2 ;
-(void)_cancelPresses:(id)arg1 event:(id)arg2 ;
@end

通过断点调试,发现 UIApplication 和 UIGestureRecognizer 的 gestureEnvironment 是同一个对象,结合 深入理解RunLoop 中的说法,这应该是为了管理所有手势的上下文(因为手势之间经常会有冲突,或者需要同时响应或者 cancel 或者 delay)。

第一次 runloop——准备开始

首先系统在 CFRunLoopDoSource0 中处理手势相关事件,这一阶段发生在这个时候

如果你看系统调用会是大概这样一个顺序

  1. kCFRunLoopBeforeSources
  2. View hittest
  3. gestureRecognizer:shouldReceiveTouch:
  4. Application/Window sendEvent:
  5. gestureRecognizer:shouldRequireFailureOfGestureRecognizer:
  6. UIResponder touchesBegan:withEvent:
  7. kCFRunLoopBeforeTimers

这一阶段解决的重要的问题:把消息发送给谁,这个谁包括了 View 和 Gesture

hit-test 找到 hitTest view

在这个阶段标志就是不断发送 hit-test,直到到找hitTest view
有了 hitTest view 才能有响应者链。

借用苹果文档说明

image

Figure 1 shows the default responder chains in an app whose interface contains a label, a text field, a button, and two background views. If the text field does not handle an event, UIKit sends the event to the text field’s parent UIView object, followed by the root view of the window. From the root view, the responder chain diverts to the owning view controller before directing the event to the window. If the window does not handle the event, UIKit delivers the event to the UIApplication object, and possibly to the app delegate if that object is an instance of UIResponder and not already part of the responder chain.

注意:图中的箭头,这描述了响应者链中的顺序,而不是 hit-test 的调用顺序

hit-test 默认实现会以 Window 为根节点,开始反向前序深度优先搜索,并且不止调用一次(意图不明)。

Hit-Testing in iOS

命中测试采用反向预定深度优先遍历(先访问根结点,然后遍历其子树由高到低的指标)。这种遍历可以减少遍历迭代次数,并在搜索到第一个包含touch-point的最深的子视图中停止搜索过程。这可能是因为一个视图总是在它的父视图之前渲染,而兄弟视图总是比在subviews具有较低索引的兄弟视图先渲染。这样,多个重叠的视图都包含一个touch-point时,在右子树的最深的视图是第一个被渲染的。

摘抄一段别人写的 hit-test 的默认实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event {
//判断是否合格
if (!self.hidden && self.alpha > 0.01 && self.isUserInteractionEnabled) {
//判断点击位置是否在自己区域内部
if ([self pointInside: point withEvent:event]) {
UIView *attachedView;
for (int i = self.subviews.count - 1; i >= 0; i--) {
UIView *view = self.subviews[i];
//对子view进行hitTest
attachedView = [view hitTest:point withEvent:event];
if (attachedView)
break;
}
if (attachedView) {
return attachedView;
} else {
return self;
}
}
}
return nil;
}

到了这一步可以看出,如果复写了 hit-test 但是不递归调用子view的相关方法,遍历顺序就会变。
这里能做一些有趣的事情

  1. 选择性让子节点接受或不接受事件
  2. 扩大点击区域
  3. 让自己不接受事件

包装 UIEvent

系统走完 hit-test 之后会调用 gesture 的 shouldReceiveTouch 并且是从响应者链底下往上找。

调用栈如下
img

—————- 这里插一个我没搞懂的问题 ———————

顺序怎么实现自下而上而上的

可以看出 UITouchesEvent 似乎遍历了一个view数组,这个数组不知道哪里来的,难道是 hit-test 的时候记录下来的?

不过 hit-test 和这一阶段在同一个 runloop 循环中,也不用担心被打断就是了。

不清楚系统是怎么优雅实现这一调用的,因为我们知道 hitTest view 是响应者链的起点,事件一层层向下传递,因为响应者链的构建是通过调用 nextResponder 动态构建的,可以把响应者链理解为单向链表的话(具体响应者链会在下面说明),这就相当于从链表末尾向上遍历。

UITouchesEvent 内部也没有维护这样一条链
img

—————- 题外话结束 —————

总之系统会从响应者链的根视图开始往响应者链的顶部遍历,不断视图的 gesture 的 delegateshouldReceiveTouch

梳理一下 Gesture、Event、Touch、Responder 知识

you should never retain an event object or any object returned from an event object. If you need to retain data outside of the responder method you use to process that data, copy that data from the UITouch or UIEvent object to your local data structures.

系统维护了 Event 和 Touch 的生命周期,如果手动保留,可能会导致不可预知的错误。

1
2
3
4
5
6
7
8
9
10
11
12
@interface UIEvent : NSObject
(省略代码)

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly, nullable) NSSet <UITouch *> *allTouches;
#else
- (nullable NSSet <UITouch *> *)allTouches;
#endif
- (nullable NSSet <UITouch *> *)touchesForWindow:(UIWindow *)window;
- (nullable NSSet <UITouch *> *)touchesForView:(UIView *)view;
- (nullable NSSet <UITouch *> *)touchesForGestureRecognizer:(UIGestureRecognizer *)gesture NS_AVAILABLE_IOS(3_2);
@end

我们透过这些 API 研究一下 Gesture、Event、Touch、Responder 之间的关系

结合文档和 UITouchesEvent 的内存结构

hit-test 的目的其实是为了包装 UIEvent,系统用这个抽象了用户的交互行为。起点是__updateTouchesWithDigitizerEventAndDetermineIfShouldSend

UIEvent: An object that describes a single user interaction with your app.

为什么这么说?

UIEvent所有属性都是只读,传递的确是它的子类UITouchesEvent,防止被修改。

SendEnent阶段信息变多了

如果你 hook 了 UIView 的 hitTest ,会发现系统在这一阶段调用时 event 里面只有一个时间戳信息

1
2
<UITouchesEvent: 0x60800010d5c0> timestamp: 30537.4 touches: {(
)}

但是到了SendEvent阶段(后面会提到) 和这个 enevt 就多了好多信息

1
2
3
<UITouchesEvent: 0x60800010d5c0> timestamp: 30537.5 touches: {(
<UITouch: 0x7fc0c8403e30> phase: Cancelled tap count: 1 force: 0.000 window: <UIWindow: 0x7fc0c8504dd0; frame = (0 0; 414 736); autoresize = W+H; gestureRecognizers = <NSArray: 0x604000254910>; layer = <UIWindowLayer: 0x60400003f320>> view: <TestView: 0x7fc0cb002580; frame = (0 0; 414 736); autoresize = W+H; tag = 3; gestureRecognizers = <NSArray: 0x604000256320>; layer = <CALayer: 0x604000220d40>> location in window: {327.66665649414062, 201} previous location in window: {328.33332824707031, 201} location in view: {327.66665649414062, 201} previous location in view: {328.33332824707031, 201}
)}

流程大概总结如下

  • 创建了一个空白 UITouchesEvent
  • 找到 hitTest view,并且记录下来
  • 创建 UITouch,并且关联 view,把 UITouch 保存在 UITouchesEvent

UIKit 会创建了 UITouch ,并且把它与 UIView 建立映射关系。将来用户交互行为如果改变直接修改 UITouch 里面的值(和View的映射关系不变)

这一步可以从苹果的文档中可以看到一些细节

UIKit permanently assigns each touch to the view that contains it. UIKit creates each UITouch object when the touch first occurs, and it releases that touch object only after the touch ends. As the touch location or other parameters change, UIKit updates the UITouch object with the new information. The only property that does not change is the containing view. Even when the touch location moves outside the original view, the value in the touch’s view property does not change.

到此为止做了两件事情

  1. 通过 hit-test 确定了 hitTest view
  2. 包装好了 UIEvent

投递 UIEvent

幸幸苦苦包装了一个礼物🎁——UIEvent,终于可以开始送货了~

调用栈如下,

Ps:为了更方便去确定调用顺序,所以为 swizzle 了原始实现,看下图的时候请自行把 jft_sendEvent 替换为 sendEvent

img

送给谁?

img
B 是 A 的子 View,C 和 A 都贴在 VC 上。所有的 View 都加了点击手势,都有一个 target-action。A、B、C 手势的 delegate 为自身。VC.view 手势的 delegate 为 VC

点击红色区域,下面哪些情况会发生?

1:A、B、C 都响应
2:A、B、VC 都响应
3:A、VC 都响应
4:B、VC 都响应
5:只有 VC

答案是 2,3,4,5

A、B、VC都响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@interface TestView : UIView <UIGestureRecognizerDelegate>
@property (nonatomic, strong) UITapGestureRecognizer *tap;
@end
@implementation TestView
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
if (self = [super initWithCoder:aDecoder]) {
_tap = [UITapGestureRecognizer new];
_tap.delegate = self;
[_tap addTarget:self action:@selector(didTap:)];
[self addGestureRecognizer:_tap];
}
return self;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}

- (void)didTap:(UITapGestureRecognizer *)sender {
NSLog(@"[%@], %@",NSStringFromSelector(_cmd), self);
}

@end

@interface ViewController ()<UIGestureRecognizerDelegate>
@property (weak, nonatomic) IBOutlet UIView *aView;
@property (weak, nonatomic) IBOutlet UIView *bView;
@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];

self.aView.tag = 1;
self.bView.tag = 2;

UITapGestureRecognizer *viewTap = [UITapGestureRecognizer new];
viewTap.delegate = self;
[viewTap addTarget:self action:@selector(didTapView:)];
[self.view addGestureRecognizer:viewTap];
self.view.tag = 3;
}

- (void)didTapView:(UITapGestureRecognizer *)sender {
NSLog(@"[%@], %@",NSStringFromSelector(_cmd), self);
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
NSLog(@"[%@], %@",NSStringFromSelector(_cmd), self);
return YES;
}
@end

让 A 不响应,B 和 VC 都响应

1
2
3
4
5
6
7
8
9
10
@implementation TestView
(省略一部分代码)
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
NSLog(@"[%@], %@",NSStringFromSelector(_cmd), self);
if (gestureRecognizer.view.tag == 1) {
return NO;
}
return YES;
}
@end

只让VC响应

1
2
3
4
5
6
7
@implementation ViewController
(省略一部分代码)
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
NSLog(@"[%@], %@",NSStringFromSelector(_cmd), self);
return YES;
}
@end

要理解上面的原理就需要理解响应者链。

响应者链

怎么确定响应者链?

从 hitTest view 开始递归调用 nextResponder 得到的一条链。

If the first responder cannot handle an event or action message, it forwards it to the “next responder” in a linked series called the responder chain. The responder chain allows responder objects to transfer responsibility for handling an event or action message to other objects in the app. If an object in the responder chain cannot handle the event or action, it passes the message to the next responder in the chain. The message travels up the chain, toward higher-level objects, until it is handled. If it isn’t handled, the app discards it.

image

看 nextResponder 文档的解释

Returns the next responder in the responder chain, or nil if there is no next responder.
he UIResponder class does not store or set the next responder automatically, so this method returns nil by default. Subclasses must override this method and return an appropriate next responder. For example, UIView implements this method and returns the UIViewController object that manages it (if it has one) or its superview (if it doesn’t). UIViewController similarly implements the method and returns its view’s superview. UIWindow returns the application object. The shared UIApplication object normally returns nil, but it returns its app delegate if that object is a subclass of UIResponder and has not already been called to handle the event.

这个属性不是存储的值,而是动态根据视图树(结合控制器)返回的。所以如果有人无聊问这样的问题:响应者链是什么时候确定的。如果答 hit-test 的时候确定的肯定不对,因为这条链是动态得出来的。

响应者链上的对象都有机会处理,但是父节点会有更高的话语权。

UIWindow 把 event 发送给了前面提到的私有类 UIGestureEnvironment

1
-[UIGestureEnvironment _updateGesturesForEvent:window:]

这时候调用 _gestureRecognizersForWindow 去获取 Envieonment 中对应 window 的手势,

  1. 询问手势的 delegate 是否需要手势失效shouldRequireFailureOfGestureRecognizer
  2. 给不需要失效的 gesture 依次发送 _touchesBegan:withEvent:
  3. 给能够响应(这个定义有点不确定)的 view 依次发送 touchesBegan:withEvent:

注意⚠️:UIGestureRecognizer 有一个 private 的头文件,方便自定义手势,所以有类似 UIResponder 的 API,不过多了一个下划线

过程一二三的次序从现象上看都是从响应者链中自上而下

Envieonment 定义了如下属性

1
2
3
4
5
6
7
8
9
10
11
12
UIGestureGraph* _dependencyGraph;
@interface UIGestureGraph : NSObject {
NSMapTable * _edgesByLabel;
NSMapTable * _nodesByLabel;
}

@property (nonatomic, readonly) unsigned int edgeCount;
@property (nonatomic, readonly) NSSet *edgeLabels;
@property (nonatomic, readonly) unsigned int nodeCount;
@property (nonatomic, readonly) NSSet *nodeLabels;
(省略接口)
@end

从名字来看应该是定义了一个手势之间关系的图。

为什么 view 是树状结构,而这里却是一个图呢?

大概因为手势可以同时被添加到多个 view 中,所以手势之间不能直接用树进行描述的原因吧

这个图大概如下(肯定是不对的,凑合理解用)

img

总之应该会从这个图结构中找到对应的手势,挨个发送 event。

从表象来看就是响应者链自上而下至此所有应该响应的 UIResponder 和 UIGestureRecognizer 都准备好开始工作了

第二次 runloop——更新(中止)

系统调用顺序大致如下

  1. kCFRunLoopBeforeSources
  2. Application&Window:sendEvent
  3. gestureRecognizer:shouldBegin
  4. gesture action 调用
  5. touchesCancelled
  6. 其他 gesture action 调用

如果你仔细观察 4,5,6就会觉得很有意思,某个手势是优先调用 action,然后其对应 responder 调用了 touchesCancelled,其他的 gesture 才会得到调用的机会。

参考文献

Understanding Event Handling, Responders, and the Responder Chain

iOS 触摸事件处理详解