## 基于接口的消息通讯解耦
文/彭飞
>代码耦合与解耦是一个永恒的话题。耦合无论好坏,只有当耦合在特定的业务场景中限制了业务扩展、代码复用及维护,才需要采取一定的手段降低耦合度。面向对象的程序开发领域,尤其是 Java 领域,有着很成熟的解耦理念和框架,比如 IOC 以及 Spring Service。但在 Objective-C(以下简称 OC)领域,未有一个权威的框架或工具来处理代码耦合。
本文将尝试从当前 OC 领域的代码解耦实践及 58 App 实际业务场景,来建立一套基于接口的消息通讯解耦框架,希望能抛砖引玉,能对同行有所启示和帮助。
### 问题背景
在提出具体需要解决的问题之前,先来简单看看 58 App 当前的框架现状,如图1所示。
图1 58 App 框架图
58 App 从上到下总共分为四层:业务层、容器层、公共服务层以及公用库层。上层对下层产生单向依赖,且可以跨层依赖。比如,业务层既依赖公共服务层,又依赖公用库层。由于公用库层在公用服务层的下一层,那么业务层对公用库层的依赖属于跳跃依赖。
业务层与公用服务层或公用库层的交互大多通过容器层中的总线来进行,但由于代码的历史原因,还存在很多绕开总线直接调用的情况。业务层与公用服务层或公用库层的直接调用,有合理的,也有不合理的。不合理的直接调用构成了代码的高度耦合,业务扩展与代码维护均需要较高的成本。
另外,58 App 现有的开发团队是基于业务并行开发来展开的,即无线技术部负责公共服务层和公共库层的开发和维护,各业务线(房产/招聘/黄页/二手车)App 开发团队并行研发,专注于业务功能的研发。所以业务层与公用服务层,或公用库层的高度耦合在一定程度上也给业务并行研发带来了诸多不便及效率问题。
现有的 58 App 框架中的消息总线已经很好地集成了跳转路由功能,但是在基于接口的消息通讯方面一直是个缺口,本文即是基于这样的背景来提出和解决问题的。
### 消息通讯的几种方式
消息通讯的本质就是方法的调用。在同构系统中的方法调用比较容易理解,调用方利用获取到的类的实例调用对应的方法即可。但放到异构系统上,方法的调用就变得复杂了,比如用
JavaScript 代码调用 OC 代码中的某一个方法,服务器端的 Java 代码调用客户端的 OC 代码,不能直接获取异构系统代码上的类来操作方法,需要借助一定的中间手段。
本文主要聚焦于同构系统中的消息通讯,主要有以下三种实现手段:
- 方法直接调用;
- 接口隔离,通过 Protocol 调用;
- 通过 URL 路由实现。
先说 URL 路由。在同一个 App 内部模块之间代码的相互调用,为了达到解耦的效果,我们也会通过协议(预定义的一套规则)进行通讯。这种基于数据协议的通讯也就是 URL 路由的实现,URL 路由的设计很好地解决了模块间方法调用的耦合,但是有两个不足:
- 不能实现编译期检查。基于数据协议的消息通讯必须基于 Runtime 的 API 来实现,协议配得对不对,是否符合对应的协议规范,不能在编译期得到及时反馈和提示。这加大了开发成本和运行时出错的概率。
- 使用过重,只适合粗粒度的模块调用。每将一个新的入口及对应入口类的方法纳入到路由,必须编写相应的代码进行实现。这种方法只适合大的模块间的入口方法间的调用,而且入口类的方法基本差异不大。比如常见的 App 内的 UI 模块的跳转,主要是调用
UINavigationController 的 pushViewController:animated 方法。
所以,针对模块间的消息通讯,除了上述的基于数据协议的通讯,还有一种基于接口的消息通讯。这里的接口在 iOS 系统中就是 Protocol,Android 中是 Interface,只是在不同开发语言中称呼及具体语法实现不同。
基于接口的消息通讯支持在编译期检查接口的语法,而且粗细粒度可以灵活把握。相对而言,是一种轻量的消息通讯解耦方式,这也是本文要研究的内容。
### 耦合及解耦手段
#### 高耦合与低耦合
耦合一词源于英文中的 Coupling,英文解释为:a connection (like a clamp or vise) between two things so they move together。这段英文解释翻译为中文为:耦合是两个事物之间的连接,这种连接使得事物一起运动(相互影响)。
在软件开发领域,耦合是无处不在的,正是耦合的存在才使得软件各功能可以相互影响,一起实现具体的业务。耦合是客观存在的,是无法消除的。但耦合的程度有高低之分,可以通过具体的手段来调整耦合度的高低。低耦合可以给软件开发带来可读性、复用性、可维护性和易变更性。
《浮现式设计》一书中将代码耦合分为四种类型:标示耦合、表示耦合、子类耦合、继承耦合。本文中涉及的耦合为标示耦合与表示耦合,即对实体及方法的依赖。比如在业务类 A 中需要调用微信登录类 B 中的登录方法:
```
#import "SampleBusiness.h"
#import "WeChatLogin.h"
@implementation SampleBusiness
-(void) doBusLogin{
WeChatLogin *wechatLogin = [[WeChatLogin alloc]init];
[wechatLogin doLogin];
}
@end
```
代码1 业务代码调用样例
上述业务类 SampleBusiness 中存在两个级别的耦合:对微信登录类 WeChat Login 及登录方法 doLogin 的耦合。WeChat Login 类的删除及 doLogin 方法的变动(方法名修改、参数变动等)会影响 SampleBusiness 类的编译。在上述情况的耦合下,对以下假设不能得到满足:
- 如果需要下掉微信登录功能,则需要 SampleBusiness 中修改代码,并且需要重新提交测试;
- 如果微信登录 API 有变动,需要修改 SampleBusiness 代码,并且需要重新提交测试;
- 如果要把微信登录换成 QQ 登录,则需要 SampleBusiness 中修改代码,并且需要重新提交测试。
上述的编译影响不能满足的假设,在特定场景下成为一种高耦合,对业务的扩展及代码的维护带来了一定程度的影响。需要说明的是这里举的是一个业务简化的例子,如果业务复杂度高,代码量大,上述假设带来的代码变动就会明显。
那么,如何降低上述的耦合度呢?在寻找具体的解决方法前得明确一下最终问题解决所要达成的目标,最终目标应包含以下几个方面:
- 在 SampleBusiness 头文件中不需要引入具体的登录实现类,不论是微信登录还是 QQ 登录。这涉及到类耦合的处理。
- 在 SampleBusiness 业务实现中,不要直接调用登录实现类的 API,这涉及到方法依赖的处理。
明确了最终目标后,就有寻找具体解决方法的标准了。
#### 降低耦合度的两种手段
要达成上述目标,通常有两种手段:引入 Runtime 和 Protocol,将上述依赖转移到
Runtime 依赖和 Protocol 依赖上来。下面对这两种手段进行一一介绍:
##### Runtime 依赖
先来看基于 Runtime 是如何解决上述 SampleBusiness 中的耦合的:
```
#import "SampleBusiness.h"
@implementation SampleBusiness
-(void) doBusLogin{
Class loginClass = NSClassFromString(@"WeChatLogin");
id loginInstance = [loginClass new];
[loginInstance performSelector:@selector(doLogin) withObject:nil];
}
@end
```
代码2 基于 Runtime 解决耦合
可以看出上述代码的解决方案中,没有引入具体的登录实现类(WeChat Login)。也没有直接调用登录实现类中的 API,满足前文定的问题解决的目标。
Runtime 是 OC 语言特有的,用他来解决代码耦合,耦合度非常低。但此种方式在代码可读性和可维护性上较差,而且调试成本较高。
##### Protocol 依赖
先来看看 Protocol 依赖的代码实现:
```
@protocol WeChatLoginProtocol
-(void) doLogin;
@end
@implementation SampleBusiness
-(void) doBusLogin{
Class loginClass = NSClassFromString(@"WeChatLogin");
id loginInstance = [loginClass new];
[loginInstance doLogin];
}
@end
```
代码3 基于 Protocol 代码实现
首先定义了一个登录协议,在 WeChat Login 中实现了这个协议。然后在业务实现的过程中执行 Protocol 的调用,而不是直接业务实现类的方法调用。此处的 Protocol 依赖解决了总目标中的方法依赖,但是对类依赖的解决仍旧是通过 Runtime 处理的。如果有一个 Manager 类来集中管理利用 Runtime 生成对应类的实例,那么业务方代码中将可以不含有任何 Runtime 操作的逻辑,只需要关注如何利用 Protocol 执行 API 的调用。这个 Manager 类的功能也是后文将要详细叙述的。
相比 Runtime 依赖,Protocol 依赖可以支持编译检查。借助编译检查,可以在编码阶段消除很多错误,接口方法的任何变动,业务调用方都能直接感知。这对业务代码的调试有很大的帮助,而不需要在运行时逐步调试来发现接口变动带来的错误。除此之外,代码可读性也比较好,方法的调用一目了然。
Protocol 依赖一个重要的问题是如何解决类依赖,在业务代码中不需要写任何 Runtime 相关的代码。这看似一个简单的问题,实际上涉及很多问题,这些问题的解决也是本文要叙述的主要内容。
### 基于接口的消息通讯框架的设计与实现
前文中提到了要解决类依赖,需要有一个统一的 Manager 类进行管理,使得业务使用方不需要关注如何 Runtime 处理逻辑,通过 Manager 提供的 API,很容易得到对于的
Protocol 实现类的 instance。这个 Manager 类我用了一个高大上的名字:基于接口的消息通讯框架。之所以用上框架这个词,是为了与 Java 端 Spring@Service 框架相比较。本文的消息通讯框架的设计与实现充分借鉴了 Spring@Service 的实现以及
iOS 端的两个开源库:BeeHive 与 Typhoon。
#### 建立 Protocol 实现类的映射
要获取 Protocol 实现类的实例,必须得建立一个唯一标示符与 Protocol 实现类的映射关系。对外暴露的 API 设计下所示:
```
/**
注册指定的Class,通常此Class是某一个protocol的实现类
@param aClass 注册的Class
@param aKey 在注册module时指定标示module的唯一标识,key为可选值,
当key为nil时,仅使用Class Name字符串
*/
-(void) registerClass:(Class) aClass
withKey:(NSString*) aKey;
```
代码4 注册 protocol 实现类的 API
这里的 key 值采用的是字符串类型,而非 BeeHive 框架中的 Protocol。因为如果使用 Protocol 作为 key 值,只满足 Protocol 与实现类一对一的情况,而对一对多的情况将无法满足。比如定义了一个登录协议,微信/QQ/微博都对此协议进行了实现,此时无法利用 Protocol 作为 key,建立与其实现类之间的映射关系。此处的 key 在使用时比较灵活,使用方可以根据自定义的规则设置 key,也可以传 nil,使用框架默认的
Class Name 作为 key。
针对实现类的注册,本框架封装了一个宏:
```
//组件注册方法的宏封装
//(1)key为service类的唯一标示,
//(2)如果key为空,则系统默认取service类名字符串
#define REGISTER_WBIOC_SERVICE(key) \
+ (void)load {\
[[WBIOCServiceFactory serviceFactoryInstance]registerClass:self\
withKey:key];\
}
```
代码5 组件注册方法宏
有了此宏,只需在 Protocol 实现类中进行铺设,使用非常简单。既达到了封装的效果,又使得注册代码可灵活扩展。这种手法在 React Native 框架中比较常见,有兴趣的同学可以比较一下。且在 Java 框架中,类似于 Spring 中的基于注解的配置。
在注册方式上还有一种方式是基于配置文件的注册。就是根据模块或者系统创建若干配置文件,配置文件中配置 key 与 Class 的映射信息。然后在框架加载的时候,会主动去加载这些配置文件,从而将映射关系读取到内存中以供使用。
基于注解的注册更加轻量,不用额外维护配置文件,尤其是多人协作开发时经常出现配置文件编写冲突。但是当实现类不是自己编写的类,基于注解的配置无法实现,比如 Lib 库中只提供了头文件的 Protocol 实现类。但这种情况可以通过对 Lib 库中的类进行一层封装来解决。所以,综合考虑,本框架最终使用的是基于注解的配置。
#### 根据 key 获取 Protocol 实现类的实例
建立了 Protocol 实现类的映射,相应的也需设计如何根据 key 获取 Protocol 实现类的实例。一般情况下的 API 设计如下所示:
```
/**
使用默认初始化方法构造Class对应的实例instance
@param key 在注册module时指定标示module的唯一标识
@return id 注册module Class对应的实例instance,采用默认初始化方法构造instance
*/
-(id) moduleForKey:(NSString*) key;
```
代码6 根据 key 获取 protocol 实现类
这里的一般情况是指可以通过默认初始化方法来构造 instance,而且 instance 的生命周期由调用方进行管理,框架不持有 instance。
在这里还有一种情况是如果 instance 是一个单例属性,则框架根据下面的协议进行判断:如果目标 Class 中实现了 wbIOC SharedInstance 协议(如代码7所示),则先调用此协议生成 instance 并返回;如果没有实现,则调用默认的初始化方法来构造
instance 并返回。
```
@protocol WBIOC Protocol
//如果有自己的单例实例,只需要实现此协议,moduleForKey的时候会自动返回业务类里面的实例。
+(id) wbIOC SharedInstance;
@end
```
代码7 单例的实现协议
#### 控制 Protocol 实现类实例的生命周期
可能有同学会提出,如果只提供上述的 API 接口,并且框架不持有创建的 instance,那么如果在一个容器 controller 中要多次使用创建的 instance,只能有两种选择:
- 调用一次上述 API,并将返回的 instance 作为容器 controller 内的全局属性,这样在容器内需要再次使用时,直接调用此全局属性;
- 多次调用上述 API 以满足容器内多处业务使用。
第一种处理方式需要在容器内额外增加一个属性,第二种处理方式多次调用 API 重复创建 instance 带来额外的开销。那么有没有一种更加简洁高效的方式供调用方使用?
针对此情况,本框架在输入参数上增加了一个 lifeClass 参数,用以控制创建的
instance 的生命周期,API 如代码8所示:
```
/**
使用默认初始化方法构造Class对应的实例instance
初始化完的instance在框架中进行存储,直到lifeClass被释放时触发销毁
@param key 在注册module时指定标示module的唯一标识
@param lifeClass instance的生命周期由lifeClass控制,lifeClass被销毁时自动销毁instance。如果lifeClass为nil,则直接返回nil。
@return id 注册module Class对应的实例instance,采用默认初始化方法构造instance。如果已经调用过一次,则返回的instance是第一次创建的。
*/
-(id) moduleForKey:(NSString*) key
withLifeClass:(id) lifeClass;
```
代码8 基于 key 和 lifeClass 获取实例
通过此方法生成的 instance,生命周期和 lifeClass 绑定起来了。一般情况下,这里的 lifeClass 是一个容器类 controller。那么在整个 controller 生命周期内,多次调用 API 生成 instance 都为同一个对象。
Instance 与 lifeClass 的绑定逻辑思路是:
- 先创建一个 NSObject 类别 objectBinding;
- 在 objectBinding 中,利用 `objc_setAssociatedObject` 关联 instance 与
lifeClass。`objc_AssociationPolicy` 设置为` OBJC_ASSOCIATION_RETAIN_NONATOMIC`。这样绑定后,lifeClass 会持有
instance,直至 lifeClass 销毁时才会触发 instance的dealloc。
在实际操作中,还要考虑一个 lifeClass 与多个 instance 的绑定情况。另外,需要注意的是 instance 不能持有 lifeClass,不然就造成循环引用了。
#### 支持自定义的初始化方法
在前面的叙述中,instance 的创建都是默认的初始化方法的。如果要支持自定义的初始化方法,则需要做额外处理。本框架中提供的支持初始化方法创建实例 instance 的 API 如代码9所示:
```
/**
使用指定的初始化方法及对应的参数构造Class对应的实例instance
@param key 在注册module时指定标示module的唯一标识
@param selector 初始化方法对应的selector
params selector中对应的参数
@return id 注册module Class对应的实例instance,根据传入的selector及参数构造instance
@warning: selector在本方法只支持实例方法,不支持类方法!
*/
-(id) moduleForKey:(NSString*) key
initSelector:(SEL) selector
params:param0,...NS_REQUIRES_NIL_TERMINATION;
```
代码9 根据指定初始化方法获取实例
调用方只需要把自定义初始化方法的 selector 及参数传入,框架就能根据传入的参数来生成实例 instance。这里参数采用了可变参数,方便了业务调用。instance 的生成过程,是通过调用 NSInvocation 的 invocationWithMethodSignature 来进行处理的。这里之所以选择操作较负责的 NSInvocation,而不直接用 NSObject 的
performSelector,是为了支持更多的参数(performSelector 最多只支持两个参数)。
针对自定义初始化方法,如果要控制创建的 instance 的生命周期,则调用如代码10所示的 API:
```
/**
使用指定的初始化方法及对应的参数构造Class对应的实例instance。
@param key 在注册module时指定标示module的唯一标识
@param lifeClass instance的生命周期由lifeClass控制,lifeClass被销毁时自动销毁instance.如果lifeClass == nil,则返回nil。
@param selector 初始化方法对应的selector
params selector中对应的参数
@return id 注册module Class对应的实例instance,根据传入的selector及参数构造instance
@warning: 由于instance的生命周期受liefeClass销毁时控制,所以lifeClass一定不能持有instance。如果lifeClass必须持有instance,则返回nil。
@warning: selector在本方法只支持实例方法,不支持类方法!
*/
-(id) moduleForKey:(NSString*) key
withLifeClass:(id) lifeClass
initSelector:(SEL) selector
initParams:param0,...NS_REQUIRES_NIL_TERMINATION;
```
代码10 根据 lifeClass 和指定初始化方法获取实例
#### 线程安全的处理
在整个框架中有两个字典来存储数据:
- serviceMappingDict:用来存储(参考1)中所属的 key 与 protocol 实现类之间的映射关系。
- instanceDict:用来存储生命周期需要框架来控制的 instance,也就是上文叙述的绑定了 lifeClass 的 instance。
这两个字典在进行增删查改的时候,需要考虑线程安全的问题。通用的解决的方案是利用
Synchronized 和 NSLock 对公共资源进行加锁。但这种加锁方式性能较低,特别是在并发读的情况下,事实上是不需要对公共资源加锁。针对这种情况下,调研了一些开源框架,比如 AFNetworking,采用的就是性能较高的并行队列方案。以
serviceMappingDict 为例,本框架的处理方式如下:
先定义一个并发队列`dispatch_queue_t`,名称为 service MappingConQueue。
数据存储的时候的处理,如代码11所示:
```
dispatch_barrier_async(_serviceMappingConQueue, ^{
//往map字典中存储映射关系
[self.serviceMappingDict setObject:aClass forKey:implClassMapKey];
});
```
代码11 数据存储的时候加锁
这里巧妙地利用了 dispatch_barrier 的特性。在解释具体代码之前先明确两个概念:
- 当前线程:指执行上述数据存储逻辑的线程;
- 并发队列的线程:serviceMappingConQueue 中的线程,具体线程数依赖队列的设置,任务数较少时一个任务可分配到一个线程。
再来看上述逻辑的处理:
- `dispatch_barrier_async`中的 async 指当前线程可以与
serviceMappingConQueue 中的任务异步并行,互不影响;
- barrier 则限制了 serviceMappingConQueue 中的任务的执行,必须等当前任务执行完了才可执行队列里面的其他任务。
这样达到的效果是:在数据添加时,不会阻塞当前线程,是一个异步过程。而且 barrier
会阻塞队列中其他任务(添加/删除/读取)的执行,相当于对公共资源
serviceMappingDict 的加锁。
数据读取的时候的处理,如代码12所示:
```
dispatch_sync(_serviceMappingConQueue, ^{
serviceClass = [self.serviceMappingDict objectForKey:key];
});
```
代码12 数据获取时候的代码处理
可以看出,数据读取的时候并没有使用 barrier 加锁,对于同是读取的任务可以并发执行。
数据删除时的处理,如代码13所示:
```
dispatch_barrier_sync(_serviceMappingConQueue, ^{
Class aClass = [self.serviceMappingDict objectForKey:key];
if (aClass != nil) {
[self.serviceMappingDict removeObjectForKey:key];
removed = YES;
}
});
```
代码13 数据删除时加锁
数据删除与数据添加的唯一差别在于这里使用了sync,是为了避免当前线程拿即将要删除的数据执行其他操作,所以这里将当前线程也阻塞了。
### 业务使用示例
这里列举一个 58 App 在 IM 模块使用上述消息通讯解耦的例子,来更好理解前文所叙内容。
如问题背景一章中所述,IM 模块在整个 58 App 架构中属于服务层,最上层的是各业务线组成的业务层。按照架构设计,上层只对下层有依赖,而且业务层与公共服务层的交互要松散耦合,原则上只能通过总线中提供的相关机制来进行消息通讯,所以 IM 模块对上层业务提供的 API 一定要本着低耦合的原则,以满足后期各业务线不断增加的业务需求及业务线代码的复用。
在本例中,具体需求是业务层需要使用到 IM 层的相关数据,比如联系人列表。于是,具体的处理措施如下:
- 先定义交互接口,如代码14所示:
```
@protocol WBIMConversationProtocol
@required
-(void)obtainConversationListWithWithCount:(NSUInteger)countLimit completion:(void(^)(NSError *error,NSArray *conversationList))completion;
@end
```
代码14 交互接口代码
IM 模块中的 protocol 实现类,并注册,如代码15所示:
```
#import "WBIOCServiceFactory.h"
@interface WBIMPublicDataModel()
@end
@implementation WBIMPublicDataModel
//注册
REGISTER_WBIOC_SERVICE(@"WBIMConversationProtocol")
-(void)obtainConversationListWithWithCount:(NSUInteger)countLimit completion:(void(^)(NSError *error,NSArray *conversationList))completion{
//省略具体业务实现
}
```
代码15 protocol 实现类代码实现
- 业务方的调用,如代码16所示:
```
#import "WBIOCServiceFactory.h"
@implementation HSGetIMListModule
RCT_EXPORT_METHOD(getIMList:(NSInteger)count completion:(RCTResponseSenderBlock)completion){
//获取接口实现类
id imProtocol = [IOC_SERVICEFACTORY_INSTANCE moduleForKey:@"WBIMConversationProtocol"];
//基于protocol调用具体方法:
[imProtocol obtainConversationListWithWithCount:count completion:^(NSError *error, NSArray *conversationList) {
if (!error&&completion) {
completion(@[[conversationList JSONString]]);
}
}];
}
```
代码16 业务方调用代码实现
从上面的实例可以看出,在业务使用的过程中,始终不用关心是哪个类实现的 Protocol。这样在后期接口实现类的代码调整过程中,能最大限度地降低对业务方的影响。
另外,也需要指出的是,一定根据具体的业务场景来评估是否需要解耦,避免过度使用。
### 总结与讨论
本框架与同类型的框架相比,具有以下特性:
- 更加轻量,剔除诸如 Typhoon 框架中对通过类方法的解耦,仅支持面向接口的消息通讯;
- 支持基于注解的注册,使用更加轻便和灵活;
- 支持接口及实现类的一对多关系,更加全面的支持各种设计模式;
- 支持带参数的初始化方法及解耦;
- 支持多种状态的生命周期控制(方法体内/容器内/App 应用内)。
同时也需要重申和强调的是:基于接口的消息通讯解耦是有特定的应用场景的,一定要根据具体的业务场景进行评估,提出尽可能会出现的假设,看看是否需要使用。不恰当的使用效果往往会适得其反。
#### 参考资料
《耦合的本质》
iOS代码耦合的处理
路由跳转的思考
BeeHive
Typhoon