【iOS面试题】4.由一道面试题引出的响应链问题

作为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
复制代码

页面显示如下:

image.png

第2步 添加touches相关方法

ViewController和祖父子三级ViewGViewPViewSView添加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如下: image.png

根据官方文档,当一个事件在发生后,先是被逐级上报以确定响应的App(如果不能确定,事件就会被丢弃),然后被逐级分发,通过hitTest:withEvent:pointInside:withEvent:两个方法以确定最终响应事件的对象,即第一响应者,如果不能确定,事件会被逐级上抛,如果到AppDelegate仍未确定第一响应者来响应事件,则事件会被丢弃,这就是事件响应链规则

image.png

根据事件响应链规则,当点击绿色的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如下:

image.png

确定方法已经交换成功。

此时放开SView内的相关方法注释,重新运行项目,分别点击红色PView和绿色SView区域,得到结果:

点击红色PView区域

image.png

点击绿色SView区域

image.png

看起来没有问题,一切都和按规则推断的一样。

可是真的这样吗?

第4步 调试一下

如果注释绿色SViewtouchesBegan方法,会发生什么?

按照响应链规则,事件分发后,由于绿色SViewtouchesBegan方法被注释而没有实现,则交由父视图红色PViewtouchesBegan方法响应;而红色PViewtouchesBegan方法已经与newTouchesBegan交换,因此会先响应红色PViewnewTouchesBegan方法。

而绿色SViewtouchesEnded方法是实现了的,因此也会响应。

综上,应该是先后响应红色PViewnewTouchesBegan方法和绿色SViewtouchesEnded方法。

而实际上的运行结果是:

image.png

在绿色SViewtouchesEnded方法执行完毕后,红色PViewtouchesEnded方法也会被执行。

断点再调试一下:

绿色SViewtouchesEnded方法的调用堆栈:

iShot2022-03-01 23.40.55.png

红色PViewtouchesEnded方法的调用堆栈:

iShot2022-03-01 23.41.29.png

两者相比较,除了红色PViewtouchesEnded方法的调用堆栈中间多了一个-[UIResponder _completeForwardingTouches:phase:event:index:]调用外,两者基本一致。

而对于多出来的-[UIResponder _completeForwardingTouches:phase:event:index:]方法调用,目前网上也暂未搜索到有明确的解答。

既然如此,那就多调试几轮:

注释掉PViewSViewtouchesBegan方法,保持其它所有方法放开,点击SView,得到的结果是:

image.png

注释掉GViewPViewSViewtouchesBegan方法,保持其它所有方法放开,点击SView,得到的结果是:

image.png

注释掉GViewPViewtouchesBegan方法,保持其它所有方法放开,点击SView,得到的结果是:

image.png

注释掉全部的touchesBegan方法,保持其它所有方法放开,点击SView,得到的结果是:

image.png

注释掉GViewSViewtouchesBegan方法,保持其它所有方法放开,点击SView,得到的结果是:

image.png

而在此期间我通过断点查看对应ViewViewController是否是第一响应者,得到的结果全部都是NO

image.png

假设AppDelegate -> ViewController -> View的关系是树木的根 -> 枝 -> 叶 的关系的话,根据上面调试的结果,我有了初步的总结:

  1. 由于点击操作未能确定第一响应者,才会发生touchesEnded方法从叶往根的方向进行递归上抛调用;
  2. touchesEnded的递归上抛调用截止到实现了touchesBegan方法的响应者。

根据UIResponder中的touches相关接口方法的说明,我似乎找到了一种可以信服的说法:

image.png

根据接口说明,可以提炼出以下要点:

  • 在自定义点击响应时,应该重写全部四个touches方法;
  • 对于一个响应者来说,如果一个点击操作接收到了touchesBegan:withEvent:事件,那么也会收到同一个点击操作的touchesEnded:withEvent:touchesCancelled:withEvent:事件;
  • 必须正确的处理点击取消操作,错误的处理可能会导致错误的行为或崩溃。

根据我们实际调试的结果,以及接口说明所提炼的要点,我们可以总结得出以下结论:

  1. 对于一个点击操作,如果响应链无法确定一个明确的第一响应者,那么会发生touchesEnded:withEvent:方法的递归上抛;
  2. 如果响应者链条中有响应者实现了touchesBegan:withEvent:方法,并且被响应,那么touchesEnded:withEvent:方法的递归上抛截止到该响应者为止;
  3. 如果响应者链条中没有响应者实现了touchesBegan:withEvent:方法,那么touchesEnded:withEvent:方法的递归上抛将一直上抛到AppDelegate,然后结束。

同理,如果放开绿色SViewtouchesBegan方法,而注释touchesEnded方法,则点击操作响应如下:

image.png

可以发现红色PView的任何touches方法都不会执行了。分析如下:

由于绿色SViewtouchesBegan方法有实现,而touchesEnded没有重写,因此会执行默认操作,即什么都不做。因此虽然响应链条没有第一响应者,但是touchesEnded的递归上抛也是截止到SView为止。

总结

回顾最开始的面试题,我们可以发现,如果 Swizzle 了 父 View 的 touchesBegan 的方法,对子View没有任何影响。

相比于Swizzle方法,父子视图间响应链问题则更加值得关注。前面总结搬运如下:

  1. 对于一个点击操作,如果响应链无法确定一个明确的第一响应者,那么会发生touchesEnded:withEvent:方法的递归上抛;
  2. 如果响应者链条中有响应者实现了touchesBegan:withEvent:方法,并且被响应,那么touchesEnded:withEvent:方法的递归上抛截止到该响应者为止;
  3. 如果响应者链条中没有响应者实现了touchesBegan:withEvent:方法,那么touchesEnded:withEvent:方法的递归上抛将一直上抛到AppDelegate,然后结束。

参考接口说明,presses相关方法有与touches方法同样需要注意的问题。

参考文档:

调试iOS用户交互事件响应流程

Using Responders and the Responder Chain to Handle Events

猜你喜欢

转载自juejin.im/post/7070193144716853262