基于响应者链条的事件传递方式

组件化

随着业务越来越负责,APP中的页面也变得越来越负责,层级变的越来越深,不论项目是MVC还是MVVM架构,我们都倾向于将事件的处理放到控制器中。随着层级越来越复杂,事件处理方法越来越多,为此我们的解决方案一般都是将部分处理事件放到viewmodel中或者给控制器添加分类。但这些方法都不能避免事件一层层的传递。那么有没有更好的方法呢?

随着业务越来越负责,APP中的页面也变得越来越负责,层级变的越来越深,不论项目是MVC还是MVVM架构,我们都倾向于将事件的处理放到控制器中。随着层级越来越复杂,事件处理方法越来越多,为此我们的解决方案一般都是将部分处理事件放到viewmodel中或者给控制器添加分类。但这些方法都不能避免事件一层层的传递。那么有没有更好的方法呢?

我们都知道屏幕上的某个按钮被点击之后,系统会先寻找最佳响应者然后在将事件交给最佳响应者去处理。这里我们可以看到其实每个事件的处理,系统也是在通过某些方法去找到一个最佳的响应者,那么我们是否可以利用这个过程呢?

响应者链条

响应者链的链路是:

subview -> view –> (viewController) –> parentView –> … –> UIWindow –> UIApplication

有没有感觉 这个其实跟我们的事件传递是一样的,我们在处理事件的时候也是想将子视图中的某些响应事件一层一层的传到控制器中,最终在控制器中处理这个事件。

现有的传递方式和弊端

目前我们事件传递的方式主要有:

  • 代理
  • block
  • 通知
  • KVO
  • 直接property传值

很明显,除了通知其他几种一对一的传递方式,在层次比较深的场景下我们都需要做大量的事件传递,这就导致我们创建了大量的作用相似的block或者代理。

那么到底有没有更好的方法呢?

首先我们再次明确下,我们的目的: 优化复杂场景下事件传递方式,简化事件传递时的代码。

下面 我们通过下面的例子来具体分析下:

示例

下面是上图展示视图的层级结构:

层次结构

上面的示例中我们有两个按钮,这两个按钮的点击事件我们都需要像外部传递。

下面先来看下传统的方式我们是如何向外部传递的(以delegate为例):

声明协议

1
2
3
4
5
6
7
8
@protocol LWCyanViewDelegate <NSObject>

/// 按钮的点击事件
/// @param cyanView cyanView description
- (void)cyanViewButtonDidClick:(LWCyanView *_Nullable)cyanView;

@end

代理调用

1
2
3
4
5
6

- (void)buttonDidClick {
if (self.delegate && [self.delegate respondsToSelector:@selector(cyanViewButtonDidClick:)]) {
[self.delegate cyanViewButtonDidClick:self];
}
}

设置代理

1
@property (nonatomic, weak) id <LWCyanViewDelegate> delegate;

按照上面的步骤 我们要在示例中的
[purpleView] -> [LWGreenView] -> [LWBlueView] -> [subController.view] -> [ViewController.view] 中每层都要实现上面的三个步骤。

这种实现过于复杂,而我们想要做的只是响应按钮的点击事件。

导致的问题:

由于这种方式需要层层传递,所以有些偷懒的同事就直接将事件的处理放到了view视图里,这样我们就不用传递事件了。但是这将导致更加严重的问题:视图复用。如果这个视图我们需要在其他位置复用,但是点击的响应事件我们不在是之前的那个,这样我们应该如何修改?

还有些同事,不希望层层的实现和调用方法,因此选择将最外层的控制器作为代理 一层层的向内传递。但是这种做法同样存在一些问题,比如控制器遵守了某些层次较深的子视图的协议,但是这些视图实际上并不是直接加载当前控制器视图上的,而且当我们重用这个控制器的某些子视图时 我们不知道要处理那些事件,相反我们必须传递一个代理,但是起初我们是并不知道这个代理需要实现那些方法的。

同时,因为代理的方法如果是required的方法但是未实现时,还会导致方法调用的崩溃。

针对上面使用代理进行事件传递存在的问题,我们希望比较好的事件传递方式应该具备:

  • 省略层层的协议声明、代理方法调用、代理的设置
  • 在最外部的控制器中我们可以知道我们需要处理那些方法,以及如何处理
  • 如果外部方法不存在,内部调用时不会导致崩溃

如何利用响应者链条进行事件传递

查找最佳响应者

查找最佳响应者的过程如上图,而其中最主要依赖的方法是

1
2
3
1:- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;

2: - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;

通过上面方法的递归调用 我们就可以找到某个事件的最佳响应者。

事件处理

Application 已经找到了第一响应者对象,接下来 UIWindow 会将封装好的 UIEvent 对象,直接交给第一响应者,让其处理

但是 如果第一响应者选择不处理这个事件,那么这个事件会根据响应者联调一次向上寻找下一个最佳响应者。

具体过程如上图的左边部分:

当第一响应者不能处理 UIEvent 的时候,事件会被转发给下一个响应者(next Responder),下一个响应者不能处理,则继续传递给 next Responder ,以此类推下去,便形成了响应者链。UIResponder 的属性 nextResponder 是建立响应者链的桥梁:

1
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;

既然当最佳响应者选择不处理某个事件时,系统会自动向上查找下一个最佳响应者。那么我们是否可以利用这个机制来帮我们实现自定义的方法的数据传递。

当A按钮被点击时,我们调用某个方法(方法参数可以确定是A按钮被点击),如果这个方法没有实现,我们就去调用下一个响应者的这个方法,如果仍未实现那么继续向下查找,直到找到这个方法为止,如果到了UIApplication仍未能处理这个方法,那么直接丢弃。

但是有一个明显的问题,我们的时间传递依赖UIResponder,那如果某些控件不继承自UIResponder呢?

1
2
3
4
5
6
Responder objects—that is, instances of 
UIResponder—constitute the event-handling
backbone of a UIKit app. Many key objects are also responders, including the
UIApplication object, UIViewController objects, and all UIView objects (which
includes UIWindow). As events occur, UIKit dispatches them to your app's
responder objects for handling.

从上面的介绍中,我们可以看出我们平时使用的类基本都是继承UIResponder,他是仅次于NSObject的基类存在。

既然不用考虑某些控件可能不继承自UIResponder的问题,那么该如何实现呢?

如何实现利用响应者链条实现事件传递

实现

因为上面我们说了要利用响应者链条,那么我们先给UIResponder做一个分类

1
2
3
UIResponder+LW.h
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo;

1
2
3
4
5
UIResponder+LW..m
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
[[self nextResponder] routerEventWithName:eventName userInfo:userInfo];
}

这样我们在按钮响应事件时调用分类中的方法

1
2
3
- (void)buttonDidClick {
[self routerEventWithName:@":LWButton DidClick" userInfo:@{@"test":@"test"}];
}

那么这个方法就会顺着相应者链条依次调用,那么到了控制器中我们该如何处理呢?

1
2
3
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo {
NSLog(@"event name %@ info%@",eventName,userInfo);
}

我们只需要实现这个方法,同时保证不再继续调用下一个响应者的这个方法。就表示这里已经将对应事件处理完成。不需要外部的响应者继续处理。

问题

对于复杂的页面来说,我们要处理的事件肯定会非常多,不同的事件需要处理的方式也不同。那么在最终控制器里的routerEventWithName方法中 我们可能会写大量的if/else

大量的if/else虽然逻辑比较清晰,但是维护起来非常复杂,那么该如何解决这个问题呢?

其实对于了解设计模式的人来说,有一种设计模式叫做策略模式,这种模式是专门用来解决类似问题的。下面我们来具体看下改怎么实现。

首先,对于策略模式我们要确定的是策略。我们可以根据类型进行区分,这个类型最好可以一对多,这样策略模式作用才能最大的提现。

例如我们可以根据视图作为策略进行区分。

上图父视图中有两个子视图,每个子视图都有4个事件需要处理。
这里我们有两种方式指定策略

1、以视图作为区分策略

1
2
3
4
if (A || B || C || D)
return Strategy1
else if (F || G || H || I )
return Strategy2

2、根据预定规范的方法名

1
2
3
4
if ([eventName hasPrefix:A])
return Strategy1
else if ([eventName hasPrefix:B])
return Strategy2

实际应用
普通视图方法解耦 (一对一)
假设我们有6个事件需要处理,那么我们首先需要维护6个事件名

1
2
kULAViewEventA,kULAViewEventB,kULAViewEventC,kULAViewEventD
kULBViewEventF,kULBViewEventG,kULBViewEventH,kULBViewEventI

其次我们需要维护一个映射表或者条件转换方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (NSDictionary <NSString *, NSInvocation *> *)eventStrategy
{
if (_eventStrategy == nil) {
_eventStrategy = @{
kULAViewEventA:[self createInvocationWithSelector:@selector(aViewEventA:)],
kULAViewEventB:[self createInvocationWithSelector:@selector(aViewEventB:)],
kULAViewEventC:[self createInvocationWithSelector:@selector(aViewEventC:)],
kULAViewEventD:[self createInvocationWithSelector:@selector(aViewEventD:)],
kULBViewEventF:[self createInvocationWithSelector:@selector(bViewEventA:)],
kULBViewEventG:[self createInvocationWithSelector:@selector(bViewEventB:)],
kULBViewEventH:[self createInvocationWithSelector:@selector(bViewEventC:)],
kULBViewEventI:[self createInvocationWithSelector:@selector(bViewEventD:)]
};
}
return _eventStrategy;
}

上面这个方法,我们可以在调用的时候通过下面的方法完成转换 然后进行方法的执行

1
2
3
4
5
6
7
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{

NSInvocation *invocation = self.eventStrategy[eventName];
[invocation setArgument:&userInfo atIndex:2];
[invocation invoke];
}

这样我们我们就可以将大量的if/else转换为上面的几句代码实现,但是不可否认的是,这个过程中我们需要维护一个映射表,所以我们每次添加方法的时候都需要添加一个对应的事件名。这里实际上是有可以继续优化的,如果大家有好的方法可以给我留言。

schema处理(一对多)

每个APP可能都维护了大量的scheme跳转路径,比如 a://b/c?id=1这个scheme,我们通过这个scheme就知道我们应该跳转到b业务c页面参数为id=1。但是我们在处理scheme的位置可能会有大量的判断。而且这些判断的位置都集中在一起,也给我们组件化造成了一定的困扰。

下面我们就尝试使用策略模式进行优化 我们先看下之前的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (BOOL)handleOpenScheme:(NSString *)scheme
if([scheme hasPrefix:@"http"]) {
// 跳转到网页
} else if ([scheme hasPrefix:@"projectName"]) {
// 项目自身的scheme
if([scheme isEqualToString:@"APage"]) {
// 跳转到A页面
} else if([scheme isEqualToString:@"BPage"]) {
// 跳转到B页面
} else if([scheme isEqualToString:@"CPage"]) {
// 跳转到C页面
}
.....
} else {
// 暂不支持的方法
}

从上面的代码我们可以看出 基本上APP有多少个页面这里就会有多少个if/else。随着我们项目组件化的进程我们也需要将这些跳转方法分散到各个模块当中。

假设我们的项目中目前有四大模块:

  • 小说模块
  • 直播间模块
  • 广播剧模块
  • 个人主页模块

那么我们先声明一个协议,协议中是处理跳转的方法

1
2
3
4
5
6
@protocol ULHandleSchemeDelegate <NSObject>

- (BOOL)handleScheme:(NSString *)scheme;

@end

我们实现四个实体类:NovelSchemeHandlerLiveSchemeHandlerDramaSchemeHandlerUserProfileSchemeHandler

这四个类都遵守了ULHandleSchemeDelegate这个协议并实现了方法。

下面我们来重新实现下之前的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (BOOL)handleOpenScheme:(NSString *)scheme
NSURL *url = [NSURL URLWithString:scheme];
if([scheme hasPrefix:@"http"]) {
// 跳转到网页
} else {
if ([url isEqualToString:@"Live"]) {
// 直播模块处理 请忽略这里直接创建对应模块实例的方式
return [[LiveSchemeHandler new] handleScheme:scheme];
} else if ([url.host isEqualToString:@"Novel"]) {
// 小说模块 请忽略这里直接创建对应模块实例的方式
return [[NovelSchemeHandler new] handleScheme:scheme];
} else if ([url.host isEqualToString:@"Drama"]) {
// 话剧模块 请忽略这里直接创建对应模块实例的方式
return [[DramaSchemeHandler new] handleScheme:scheme];
} else if ([url.host isEqualToString:@"Novel"]) {
// 个人主页 请忽略这里直接创建对应模块实例的方式
return [[UserProfileSchemeHandler new] handleScheme:scheme];
} else {
// 暂不支持的方法
}
}
}

通过这中方式进行修改后,scheme的跳转变的更加清晰也更加容易维护了。当然我们还可以进行进一步的维护,添加一个映射方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (id <ULHandleSchemeDelegate> )schemeHandlerForScheme:(NSString *)scheme {
if ([url isEqualToString:@"Live"]) {
// 直播模块处理
return [LiveSchemeHandler new];
} else if ([url.host isEqualToString:@"Novel"]) {
// 小说模块
return [NovelSchemeHandler new];
} else if ([url.host isEqualToString:@"Drama"]) {
// 话剧模块
return [DramaSchemeHandler new];
} else if ([url.host isEqualToString:@"Novel"]) {
// 个人主页
return [UserProfileSchemeHandler new];
} else {
// 暂不支持的方法
return nil;
}
}

这样上面的handleOpenScheme方法可进一步简化为:

1
2
3
4
5
6
7
8
9
- (BOOL)handleOpenScheme:(NSString *)scheme
NSURL *url = [NSURL URLWithString:scheme];
if([scheme hasPrefix:@"http"]) {
// 跳转到网页
} else {
id <ULHandleSchemeDelegate> handler = [self schemeHandlerForScheme:scheme];
[handler handleScheme:scheme];
}
}

通过上面的方法我们可以将包含大量if/else语句的方法进行精简,而且根据自己的业务模块指定更好的策略,能达到更好的效果。

扩展

对于基于相应者链条的事件传递,其实还有其他的应用场景

控件id 我们埋点的时候有时候希望可以在埋点中携带控件的唯一id,而这个id我们通常是根据路径获取的。我们对分类中的方法稍加改造

1
2
3
4
5
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
NSString *newName = [NSString stringWithFormat:@"%@--%@",NSStringFromClass(self.class),eventName];
[[self nextResponder] routerEventWithName:newName userInfo:userInfo];
}

这样当我们处理点击事件的时候是否就可以获取到被点击这个控件在当前页面的id了呢?而这个过程当中是否有其他信息可以通过事件传递携带呢?大家可以根据自己的业务进行发挥!!!

总结

这篇文章我们主要介绍了一种新的基于响应者链条的事件传递机制,同时针对这种问题存在的弊端,我们使用策略模式进行简化,让这种方式更具有实用价值,当然对于视图的点击事件这种方法还存在下面两个问题

1、需要维护事件名称
2、需要维护不同事件名称与对应处理方法的映射表
不过在scheme处理的例子中,这种方式还是有很明显的效果的。

参考文档

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

iOS 响应者链与事件处理

一种基于ResponderChain的对象交互方式

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