前言
最近App做的越来越大,规模也越来越大,客户端团队也根据业务的需要分为不同的小组,分别负责不同的业务模块,虽然人员变动容易,但是代码变动就很难了,为此必须将代码工程实现模块化,不同业务的人只负责自己模块的代码,重要的是不同模块之间要做到无关联,降低耦合度,做到直接删除某一模块对工程的运行无影响。目标:将业务完全解耦,将通用功能下沉,每个业务做成独立的Git仓库,生成Pod库,最后再集成到一起。
第一步 代码拆分
实现模块化的第一步就是模块划分,将整个App划分为多个模块,根据模块将工程代码归类到对应的模块当中。但是问了来了,模块粒度应该如何划分? 我就直接按戴铭老师的答案来了:
- 单一功能原则:对象功能要单一,不要在一个对象里添加很多功能。
- 开闭原则:扩展是开放的,修改是封闭的。
- 里氏替换原则:子类对象是可以替代基类对象的。
- 接口隔离原则:接口的用途要单一,不要在一个接口上根据不同入参实现多个功能。
- 依赖反转原则:方法应该依赖抽象,不要依赖实例。iOS 开发就是高层业务方法依赖于协议。
第二步 业务解耦
代码拆分完成以后,我们就已经可以从代码内来分析了,如何接触业务之间的耦合性。举一个简单的例子:我们目前在业务模块一的页面中,页面中某个按钮的点击是跳转到业务模块二的页面当中,你会怎么做? 引入头文件,初始化一个业务二模块的控制器然后切换展示? 这显示无法达到我们的要求,移除业务模块二以后,业务模块一就会立马报错。所以在实践中,一般分为协议式和中间者两种框架设计方案。
协议式框架设计方案
戴铭老师说:
协议式架构设计主要采用的是协议式编程的思路:在编译层面使用协议定义规范,实现可在不同地方,从而达到分布管理和维护组件的目的。这种方式也遵循了依赖反转原则,是一种很好的面向对象编程的实践。
但是,这个方案的缺点也很明显,主要体现在以下两个方面:
1、由于协议式编程缺少统一调度层,导致难于集中管理,特别是项目规模变大、团队变多的情况下,架构管控就会显得越来越重要。
2、协议式编程接口定义模式过于规范,从而使得架构的灵活性不够高。当需要引入一个新的设计模式来开发时,我们就会发现很难融入到当前架构中,缺乏架构的统一性。
我们的团队没有采用这种方法,所以没有深入研究,感兴趣的可以自己查阅一下,我们主要讲下面的中间者框架
中间者框架设计方案
中间者框架如下图所示:(戴铭老师的图)
我们可以清楚的看到所有拆分的组件都会依赖中间者,这样组件间的相互依赖关系就不存在了。只要涉及到组件与组件间的交互都会通过中间者统一调度,不仅组件间的通信更容易管理,二期也可以在中间者上轻松的添加新的设计模式,便于扩展。戴铭老师认为这种框架设计模式是非常值得推荐的,并且已有开源的库来支持,让我们的模块化实现起来更加简单。
casatwy 以前设计了一个 CTMediator 就是按照中间者架构思路设计的。你可以在GitHub上看到它的内容。
CTMediator 使用的是运行时解耦,接下来我就通过开源的 CTMediator 代码,和你分享下如何使用运行时技术来解耦。
GTMediatir源码讲解
(一)基础概念
(1)Method的定义
typedef struct objc_method *Method
struct objc_method{
SEL method_name OBJC2_UNAVAILABLE; // 方法名
char *method_types OBJC2_UNAVAILABLE; // 函数的返回值和参数
IMP method_imp OBJC2_UNAVAILABLE; // 方法的具体实现
}
我们可以看到该结构体中包含一个SEL和IMP,实际上相当于在SEL和IMP之间作了一个映射,将SEL和IMP进行了关联,通过SEL我们便可以找到对应的IMP,从而调用方法的实现代码。SEL由结构体可知,就是指方法名,我们通过方法名找到指向该方法的指针IMP,从而就可以执行方法体。如何获取方法名呢?这里我们简单介绍一下selector。
- 方法编号,对方法名hash化的字符串
- 无论什么类里,只要方法名相同,SEL就相同。项目里的所有SEL都保存在一个NSSet集合里(NSSet集合里的元素不能重复),所以查找对应方法,只要找到对应的SEL就可以了。
既然SEL是方法的唯一标识,那不同的类调用名字相同的方法怎么办呢?
每个方法名有对应的唯一seletor,其SEL相同,但对应的IMP函数指针不同。
获取SEL的方法有两种:
//获取方法名
SEL s1 = @selector(test); //方法1
SEL s2 = NSSelectorFromString(@"test"); //方法2
获取方法名就有两种,调用的方法就有三种。下面可以简单看一下方法调用的转换过程:
[object test]; // 1、调用方法
// @selector(test) 是一个C的字符串
[object performSelector:@selector(test)]]; //2、转换成 对象 执行方法
// 转换成如下实现方式
objc_msgSend(object,@selector(test)) // 3、 c的方法
总之,我们可以用过NSSelectorFromString将字符串转化成想要执行的方法名SEL,同样的道理我们可以用NSClassFromString 通过字符串的名称来获取一个类,通过 [object performSelector:@selector(test)]]这种形式我们目前可以实现方法的调用了。
(二)进阶讲解
简单介绍一下performSelector , respondsToSelector 两个方法干了什么。
respondsToSelector 就是判断对象是否可以响应此方法,一般和performSelector一起使用,防止产生crash。
(BOOL)respondsToSelector:(SEL)aSelector; //返回值是BOOL
if ([self respondsToSelector:@selector(test)]) { //如果self可以响应test方法
[self performSelector:@selector(test)]; //self就向test发送消息,或者说执行此方法
}
在CTMetidor主要使用到的就是RunTime中的让对象发送消息,上面我们也简单看过,performSelector 本质上就是会转化成 objc_msgSend 来进行实现,其内部实现步骤:
1、通过对象(obj)的isa指针找到它所属类(class) ;
2、在该类(class)的方法列表(method list)中找test方法方法 ;
3、如果该类(class)中没到test方法,继续往它的父类(superclass)中找 ;
4、一旦找到test这个方法,就去执行它的实现IMP(方法实现指针)
我们用CTMetidor 的一段源代码来分析:
[target performSelector:action withObject:params];
//target 对象实例 obj
//action 方法名SEL
//params 参数
简答说就是target对象执行action方法,这应该可以看懂吧。
(三)源码解析
首先我们先看一下.h文件中暴露的API接口:远程APP调用入口、本地组件调用入口、释放某个target缓存
//单例
+ (instancetype _Nonnull)sharedInstance;
// 远程App调用入口
- (id _Nullable)performActionWithUrl:(NSURL * _Nullable)url completion:(void(^_Nullable)(NSDictionary * _Nullable info))completion;
// 本地组件调用入口
- (id _Nullable )performTarget:(NSString * _Nullable)targetName action:(NSString * _Nullable)actionName params:(NSDictionary * _Nullable)params shouldCacheTarget:(BOOL)shouldCacheTarget;
//释放某个target缓存
- (void)releaseCachedTargetWithFullTargetName:(NSString * _Nullable)fullTargetName;
我们最常用的也就是本地组件调用入口,一看四个参数
targetName : 类/对象名, NSClassFromString 可以解决
action :方法名, NSSelectorFromString 可以搞定
params : 字典类型,我们把需要的参数保存到这个字典中
shouldCacheTarget :是否需要缓存,用NO就行。
- (id _Nullable )performTarget:(NSString * _Nullable)targetName action:(NSString * _Nullable)actionName params:(NSDictionary * _Nullable)params shouldCacheTarget:(BOOL)shouldCacheTarget;
通过CTMediator的单例方法生成一个实例,然后代入参数执行方法就可以。
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{
// 从 params 字典中 获取 swiftModuleName,swift语言专用
NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
// 根据targetName获取类名
NSString *targetClassString = nil;
if (swiftModuleName.length > 0) {
targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
} else {
targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
//为什么要加上Target_这个前缀呢,是希望与现有的方法做区分,加上前缀可以明确知道这个方法是本地组件调用时用的
}
// 根据 targetClassString 从 cachedTarget (缓存的Target)获取 target
NSObject *target = self.cachedTarget[targetClassString];
if (target == nil) {
// 未获取到 则通过NSClassFromString将字符串转为应的类
Class targetClass = NSClassFromString(targetClassString);
target = [[targetClass alloc] init];
}
// 生成方法名
NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
SEL action = NSSelectorFromString(actionString);
if (target == nil) {
// 这里是处理无响应请求的地方之一,这个demo做得比较简单,如果没有可以响应的target,就直接return了。实际开发过程中是可以事先给一个固定的target专门用于在这个时候顶上,然后处理这种请求的
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
return nil;
}
// 是否需要对 Target 进行缓存
if (shouldCacheTarget) {
// 将 Target 进行缓存
self.cachedTarget[targetClassString] = target;
}
// 判断target对象是否响应action,避免crash
if ([target respondsToSelector:action]) {
// 这里是处理有响应请求的地方
return [self safePerformAction:action target:target params:params];
} else {
// 这里是处理无响应请求的地方,如果无响应,则尝试调用对应target的notFound方法统一处理
SEL action = NSSelectorFromString(@"notFound:");
if ([target respondsToSelector:action]) {
return [self safePerformAction:action target:target params:params];
} else {
// 这里也是处理无响应请求的地方,在notFound都没有的时候,这个demo是直接return了。实际开发过程中,可以用前面提到的固定的target顶上的。
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
// 删除缓存的无用 Target
[self.cachedTarget removeObjectForKey:targetClassString];
return nil;
}
}
}
这响应请求的处理,CTMediator主要就这两个函数,都看看看吧
- (id)safePerformAction:(SEL)action target:(NSObject *)target params:(NSDictionary *)params
{
NSMethodSignature* methodSig = [target methodSignatureForSelector:action];
if(methodSig == nil) {
return nil;
}
const char* retType = [methodSig methodReturnType];
if (strcmp(retType, @encode(void)) == 0) {
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
[invocation setArgument:¶ms atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
return nil;
}
if (strcmp(retType, @encode(NSInteger)) == 0) {
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
[invocation setArgument:¶ms atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
NSInteger result = 0;
[invocation getReturnValue:&result];
return @(result);
}
if (strcmp(retType, @encode(BOOL)) == 0) {
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
[invocation setArgument:¶ms atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
BOOL result = 0;
[invocation getReturnValue:&result];
return @(result);
}
if (strcmp(retType, @encode(CGFloat)) == 0) {
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
[invocation setArgument:¶ms atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
CGFloat result = 0;
[invocation getReturnValue:&result];
return @(result);
}
if (strcmp(retType, @encode(NSUInteger)) == 0) {
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
[invocation setArgument:¶ms atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
NSUInteger result = 0;
[invocation getReturnValue:&result];
return @(result);
}
return [target performSelector:action withObject:params];
看到这里,大部分人就又晕了,invocation是啥呀。invocation和performSelector实现的功能相似,都是消息转发,只不过invocation是高级的消息转发,我们用看代码简单分析一下。
[invocation setArgument:¶ms atIndex:2]; //params 是参数,放在index = 2的位置,参数从index=2开始放置,多个参数就往后面加
[invocation setSelector:action]; //设置方法, 方法默认放在index = 1的位置
[invocation setTarget:target]; //设置对象,对象默认放在index = 0 的位置
[invocation invoke]; //对象,方法,参数都设置好了,开始执行
[invocation getReturnValue:&result]; //获取方法的返回值
看完这个是不是大体理解一些,具体可以参考一下链接NSInvocation详解
(四)基础应用
首先模拟一个简单的场景,就是不同模块之间的调用。比如我们把viewController当做一个模块,自己新建一个Target_testOne的模块,实现一个Action_calculate的方法。
#import "Target_testOne.h"
@implementation Target_testOne
-(void)Action_calculate:(NSDictionary *)param{
NSLog(@"Action_calculate被执行");
}
@end
然后我们一般在viewController中调用方式大概会是这样
- (void)viewDidLoad {
[super viewDidLoad];
NSMutableDictionary *param = [[NSMutableDictionary alloc] init];
Target_testOne *obj = [[Target_testOne alloc]init];
[obj Action_calculate:param];
}
那么现在我们通过CTMediator的方式调用
- (void)viewDidLoad {
[super viewDidLoad];
NSMutableDictionary *param = [[NSMutableDictionary alloc] init];
[[CTMediator sharedInstance]performTarget:@"testOne" action:@"calculate" params:param shouldCacheTarget:NO];
//注意对象名和方法名的前缀是CTMediator内部实现的,我们写的时候要注意格式
}
那么小伙伴要问了,这样有什么好处呢?
那我就直说了,项目大了以后好处才能逐渐体现出来。模块划分以后,通过这种方式可以实现模块间的彻底解耦,互不依赖。简单说就是,比如现在我把Target_testOne对应的模块删除了,我们常用的方式就会在代码中大量报错,而第二种方式不会保存,因为找不到对应的对象和方法名就不会响应它。
那么就不存在缺点吗?缺点其实也是很明显的,主要表现在两个方法(戴铭):
1、直接硬编码的调用方式,参数是以 string 的方法保存在内存里,虽然和将参数保存在 Text 字段里占用的内存差不多,同时还可以避免.h 文件的耦合,但是其对代码编写效率的降低也比较明显。
2、由于是在运行时才确定的调用方法,调用方式由 [obj method] 变成 [obj performSelector:@""]。这样的话,在调用时就缺少类型检查,是个很大的缺憾。因为,如果方法和参数比较多的时候,代码编写效率就会比较低。
CTMediator作者针对这两个问题做了一下答复:
CTMediator 本质就是一个方法,用来接收 target、action、params。由于 target、action 都是字符串,params 是字典,对于调用者来说十分不友好,因为调用者要写字符串,而且调用的时候若是不看文档,他也不知道这个字典里该塞什么东西。
所以实际情况中,调用者是不会直接调用 CTMediator 的方法的。那调用者怎么发起调用呢?通过响应者给 CTMediator 做的 category 或者 extension 发起调用。
category 或 extension 以函数声明的方式,解决了参数的问题。调用者看这个函数长什么样子,就知道给哪些参数。在 category 或 extension 的方法实现中,把参数字典化,顺便把 target、action 这俩字符串写死在调用里。
于是,对于调用者来说,他就不必查文档去看参数怎么给,也不必担心 target、action 字符串是什么了。这个 category 是一个独立的 Pod,由响应者业务的开发给到。
所以,当一个工程师开发一个业务的时候,他会开发两个 Pod,一个是 category Pod,一个是自己本身的业务 Pod。这样就完美解决了 CTMediator 它自身的缺点。
对于调用者来说,他不会直接依赖 CTMediator 去发起调用,而是直接依赖 category Pod 去发起调用的。这么一来,CTMediator 方案就完美了。
然后还有一点可能需要强调:基于 CTMediator 方案的工程,每一个组件无所谓是 OC 还是 Swift,Pod 也无所谓是 category 还是 extension。也就是说,假设一个工程由 100 个组件组成,那可以是 50 个 OC、50 个 Swift。因为 CTMediator 抹去了不同语言的组件之间的隔阂,所以大家老的 OC 工程可以先应用 CTMediator,把组件拆出来。然后新的业务来了,用 Swift 写,等有空的时候再把老的 OC 改成 Swift,或者不改,都是没问题的。
(五)进阶应用
看完上面的解释,您听懂了吗? 不妨咱们实际做一下,对原来的方式进行一下改造。首先我们需要给CTMediator添加category,实现如下:
@implementation CTMediator (TestOne)
NSString *const kTarget_GTCourse = @"testOne"; //写死落对象名
NSString *const kActionNativTo_calculate = @"calculate"; //写死方法名
-(void)calculate:(NSDictionary *)param{
[[CTMediator sharedInstance]performTarget:kTarget_GTCourse action:kActionNativTo_calculate params:param shouldCacheTarget:NO]; //调用CTMediator方法
//如果日后需要添加多个方法,添加新的方法名和实现即可,记得在.h文件中暴露一下方法
}
添加好以后,我们再次验证一下,首先看一下viewController中的调用
- (void)viewDidLoad {
[super viewDidLoad];
NSMutableDictionary *param = [[NSMutableDictionary alloc] init];
[[CTMediator sharedInstance] calculate:param]; //会直接调用分类中的方法
}
这样下一步会调用到category中的方法
-(void)calculate:(NSDictionary *)param{
[[CTMediator sharedInstance]performTarget:kTarget_GTCourse action:kActionNativTo_calculate params:param shouldCacheTarget:NO]; //调用CTMediator方法
然后会通过CTMediator中的的performTarget方法,最终调用到Target_testOne中的方法
-(void)Action_calculate:(NSDictionary *)param{
NSLog(@"Action_calculate被执行");
}
综上,以后在使用CTMediator的过程中,先根据对应的模块创建一个该模块的分类(CTMediator+TestOne),在分类中实现对其他模块暴露的方法,供其他模块调用。然后在对应模块中添加Target_testOne类,里面才是方法的具体实现。