作为iOS面试中几乎必问
的题,响应链问题应该是绝大多数人都逃不开的。今天新看到了一道题,让我重新对这个问题燃起了兴趣:
- 响应链: 如果 Swizzle 了 父 View 的 touchesBegan 的方法, 会对子 View 造成什么影响?
这道题涉及了两个方面:
- 响应链及相关的touches方法
- Runtime下的method swizzle
既然如此,我们就代码搞起来!
第1步 demo搭起来
主要是三个类:页面:ViewController
,祖View:GView
,父View:PView
,子View:SView
。具体如下:
#import "ViewController.h"
#import "GView.h"
#import "PView.h"
#import "SView.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
GView *g = [[GView alloc] initWithFrame:CGRectMake(100, 150, 300, 300)];
g.backgroundColor = [UIColor grayColor];
[self.view addSubview:g];
PView *p = [[PView alloc] initWithFrame:CGRectMake(50, 50, 150, 150)];
p.backgroundColor = [UIColor redColor];
[g addSubview:p];
SView *s = [[SView alloc] initWithFrame:CGRectMake(30, 30, 100, 100)];
s.backgroundColor = [UIColor greenColor];
[p addSubview:s];
}
@end
#import "GView.h"
@implementation GView
@end
#import "PView.h"
@implementation PView
@end
#import "SView.h"
@implementation SView
@end
复制代码
页面显示如下:
第2步 添加touches相关方法
为ViewController
和祖父子三级ViewGView
,PView
和SView
添加touches相关方法
@implementation ViewController
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"VC touchBegan");
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"VC touchesEnded");
}
@end
#import "GView.h"
@implementation GView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"GView touchBegan");
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"GView touchesEnded");
}
@end
#import "PView.h"
@implementation PView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"PView touchBegan");
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"PView touchesEnded");
}
@end
#import "SView.h"
@implementation SView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"SView touchBegan");
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"SView touchesEnded");
}
@end
复制代码
运行demo,点击SView的绿色区域,得到log如下:
根据官方文档,当一个事件在发生后,先是被逐级上报以确定响应的App(如果不能确定,事件就会被丢弃),然后被逐级分发,通过hitTest:withEvent:
和pointInside:withEvent:
两个方法以确定最终响应事件的对象,即第一响应者,如果不能确定,事件会被逐级上抛,如果到AppDelegate仍未确定第一响应者来响应事件,则事件会被丢弃,这就是事件响应链规则
。
根据事件响应链规则
,当点击绿色的SView
区域时,touchesBegan
事件和touchesEnded
事件将根据点击动作先后被SView
上报,走完整个响应链条后,由SView
响应,输出对应log信息。
由于红色的父View:PView
并未被确定为响应事件的对象,而只是传递事件的对象,因此在PView
上的touches
相关方法并未响应。
这也是大多数人能够理解的内容。
第3步 交换父view的 touchesBegan 方法
交换方法,是利用了Objective-C
的消息机制
和Runtime运行时机制
,通过交换方法实现,来达到相应的目的。这里我们先把这部分实现一下:
#import "PView.h"
#import <objc/runtime.h>
@implementation PView
+ (void)load {
// 当前类
Class class = [self class];
// 原方法名 和 替换方法名
SEL originalSelector = @selector(touchesBegan:withEvent:);
SEL swizzledSelector = @selector(newTouchesBegan:withEvent:);
// 原方法结构体 和 替换方法结构体
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
// 调用交换两个方法的实现
method_exchangeImplementations(originalMethod, swizzledMethod);
}
- (void)newTouchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"PView newTouchesBegan");
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"PView touchBegan");
}
// 其它的方法省略
@end
复制代码
为了查看交换效果,先注释SView
内的相关方法,然后运行demo,点击红色PView
区域,得到log如下:
确定方法已经交换成功。
此时放开SView
内的相关方法注释,重新运行项目,分别点击红色PView
和绿色SView
区域,得到结果:
点击红色PView
区域
点击绿色SView
区域
看起来没有问题,一切都和按规则推断的一样。
可是真的这样吗?
第4步 调试一下
如果注释绿色SView
的touchesBegan
方法,会发生什么?
按照响应链规则,事件分发后,由于绿色SView
的touchesBegan
方法被注释而没有实现,则交由父视图红色PView
的touchesBegan
方法响应;而红色PView
的touchesBegan
方法已经与newTouchesBegan
交换,因此会先响应红色PView
的newTouchesBegan
方法。
而绿色SView
的touchesEnded
方法是实现了的,因此也会响应。
综上,应该是先后响应红色PView
的newTouchesBegan
方法和绿色SView
的touchesEnded
方法。
而实际上的运行结果是:
在绿色SView
的touchesEnded
方法执行完毕后,红色PView
的touchesEnded
方法也会被执行。
断点再调试一下:
绿色SView
的touchesEnded
方法的调用堆栈:
红色PView
的touchesEnded
方法的调用堆栈:
两者相比较,除了红色PView
的touchesEnded
方法的调用堆栈中间多了一个-[UIResponder _completeForwardingTouches:phase:event:index:]
调用外,两者基本一致。
而对于多出来的-[UIResponder _completeForwardingTouches:phase:event:index:]
方法调用,目前网上也暂未搜索到有明确的解答。
既然如此,那就多调试几轮:
注释掉PView
和SView
的touchesBegan
方法,保持其它所有方法放开,点击SView
,得到的结果是:
注释掉GView
,PView
和SView
的touchesBegan
方法,保持其它所有方法放开,点击SView
,得到的结果是:
注释掉GView
和PView
的touchesBegan
方法,保持其它所有方法放开,点击SView
,得到的结果是:
注释掉全部的touchesBegan
方法,保持其它所有方法放开,点击SView
,得到的结果是:
注释掉GView
和SView
的touchesBegan
方法,保持其它所有方法放开,点击SView
,得到的结果是:
而在此期间我通过断点查看对应View
和ViewController
是否是第一响应者,得到的结果全部都是NO
假设AppDelegate
-> ViewController
-> View
的关系是树木的根 -> 枝 -> 叶
的关系的话,根据上面调试的结果,我有了初步的总结:
- 由于点击操作未能确定第一响应者,才会发生
touchesEnded
方法从叶往根的方向进行递归上抛调用; touchesEnded
的递归上抛调用截止到实现了touchesBegan
方法的响应者。
根据UIResponder
中的touches
相关接口方法的说明,我似乎找到了一种可以信服的说法:
根据接口说明,可以提炼出以下要点:
- 在自定义点击响应时,应该重写全部四个touches方法;
- 对于一个响应者来说,如果一个点击操作接收到了
touchesBegan:withEvent:
事件,那么也会收到同一个点击操作的touchesEnded:withEvent:
或touchesCancelled:withEvent:
事件; - 必须正确的处理点击取消操作,错误的处理可能会导致错误的行为或崩溃。
根据我们实际调试的结果,以及接口说明所提炼的要点,我们可以总结得出以下结论:
- 对于一个点击操作,如果响应链无法确定一个明确的第一响应者,那么会发生
touchesEnded:withEvent:
方法的递归上抛; - 如果响应者链条中有响应者实现了
touchesBegan:withEvent:
方法,并且被响应,那么touchesEnded:withEvent:
方法的递归上抛截止到该响应者为止; - 如果响应者链条中没有响应者实现了
touchesBegan:withEvent:
方法,那么touchesEnded:withEvent:
方法的递归上抛将一直上抛到AppDelegate,然后结束。
同理,如果放开绿色SView
的touchesBegan
方法,而注释touchesEnded
方法,则点击操作响应如下:
可以发现红色PView
的任何touches
方法都不会执行了。分析如下:
由于绿色SView
的touchesBegan
方法有实现,而touchesEnded
没有重写,因此会执行默认操作,即什么都不做。因此虽然响应链条没有第一响应者,但是touchesEnded
的递归上抛也是截止到SView
为止。
总结
回顾最开始的面试题,我们可以发现,如果 Swizzle 了 父 View 的 touchesBegan
的方法,对子View没有任何影响。
相比于Swizzle方法,父子视图间响应链问题则更加值得关注。前面总结搬运如下:
- 对于一个点击操作,如果响应链无法确定一个明确的第一响应者,那么会发生
touchesEnded:withEvent:
方法的递归上抛;- 如果响应者链条中有响应者实现了
touchesBegan:withEvent:
方法,并且被响应,那么touchesEnded:withEvent:
方法的递归上抛截止到该响应者为止;- 如果响应者链条中没有响应者实现了
touchesBegan:withEvent:
方法,那么touchesEnded:withEvent:
方法的递归上抛将一直上抛到AppDelegate,然后结束。
参考接口说明,
presses
相关方法有与touches
方法同样需要注意的问题。
参考文档: