iOS 组件化、模块化与方案探索

880 阅读16分钟

image.png

组件与模块

“组件”强调的是复用,它被各个模块或组件直接依赖,是基础设施,它一般不包含业务或者包含弱业务,属于纵向分层(比如网络请求组件、图片下载组件)。

“模块”强调的是封装,它更多的是指功能独立的业务模块,属于横向分层(比如购物车模块、个人中心模块)。

模块化需要提供多个库之间的服务调用并保持库与库之间的独立、非强依赖。

总的来说,模块化的重点还是如何去除多个模块之间的耦合,让每个模块在不强依赖的情况下可以调用其他模块的服务。现在在开源的方案中有以下三种方案被广泛使用。

  • 利用URL—Scheme注册
  • 利用Protocol-Class注册
  • 利用 Runtime 实现的Target-Action方法

image.png ** 缺点:**

  • Targe-Action: 硬编码
  • URL—Scheme:App直接跳转转化成页面跳转,还有参数传递的问题
  • Protocol-Class:在前面两者的优化
2. 利用Protocol-Class注册
@interface LYRouter : NSObject

- (void)registerProtocol:(Protocol *)proto forClass:(Class)cls;

- (Class)classForProtocol:(Protocol *)proto;

- (id)instanceForProtocol:(Protocol *)proto;

@end

@interface LYRouter ()
@property (nonatomic,strong) NSMutableDictionary *protocolCache;
@property (nonatomic,strong) dispatch_semaphore_t semaphore;
@end

@implementation LYRouter
static LYRouter *instance;

- (void)registerProtocol:(Protocol *)proto forClass:(Class)cls {
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    [self.protocolCache setObject:cls forKey:NSStringFromProtocol(proto)];
    dispatch_semaphore_signal(_semaphore);
}

- (Class)classForProtocol:(Protocol *)proto {
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    Class cls = self.protocolCache[NSStringFromProtocol(proto)];
    dispatch_semaphore_signal(_semaphore);
    
    return cls;
}

- (id)instanceForProtocol:(Protocol *)proto {
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    Class cls = self.protocolCache[NSStringFromProtocol(proto)];
    dispatch_semaphore_signal(_semaphore);
    return [[cls alloc] init];
}

#pragma mark -

+ (instancetype)allocWithZone:(struct _NSZone *)zone{
    if (!instance) {
        instance = [super allocWithZone:zone];
        instance.semaphore = dispatch_semaphore_create(1);
        
        dispatch_semaphore_wait(instance.semaphore, DISPATCH_TIME_FOREVER);
        if (!instance.protocolCache) {
            instance.protocolCache = [NSMutableDictionary new];
        }
        dispatch_semaphore_signal(instance.semaphore);
    }
    
    return instance;
}
- (id)copyWithZone:(NSZone *)zone{
    return instance;
}

- (id)copy{
    return instance;
}
@end
登录模块-使用示例:
@interface Target_LYLogin : NSObject

@end
@implementation Target_LYLogin

+(void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [[LYRouter new] registerProtocol:@protocol(LYLoginViewControllerProtocol) forClass:[LYLoginViewController class]];
        [[LYRouter new] registerProtocol:@protocol(LYLoginUserPrivacyProtocol) forClass:[LYLoginProtocolController class]];
    });
    
}

@end

Router的作用
  • 路由缺失时的情况
  • 寻找模块
  • 声明依赖和接口
  • Builder和依赖注入

Router的作用

首先,我们需要梳理清楚,为什么我们需要Router,Router能带来什么好处,解决什么问题?我们需要一个什么样的Router?

路由缺失时的情况

没有路由时,界面跳转的代码就很容易产生模块间耦合。 iOS中执行界面跳转时,用的是UIViewController上提供的跳转方法:

[sourceViewController.navigationController pushViewController:destinationViewController animated:YES];

[sourceViewController presentViewController:destinationViewController animated:YES completion:nil];

如果是直接导入destinationViewController的头文件进行引用,就会导致和destinationViewController模块产生耦合。类似的,一个模块引用另一个模块时也会产生这样的耦合。 因此我们需要一个方式来获取destinationViewController,但又不能对其产生直接引用。 这时候就需要路由提供的"寻找模块"的功能。以某种动态的方式获取目的模块。 那么路由是怎么解决模块耦合的呢?在上一篇VIPER讲解里,路由有这几个主要职责:

  • 寻找指定模块,执行具体的路由操作
  • 声明模块的依赖
  • 声明模块的对外接口
  • 对模块内各部分进行依赖注入

通过这几个功能,就能实现模块间的完全解耦。

寻找模块

路由最重要的功能就是给出一种寻找某个指定模块的方案。这个方案是松耦合的,获取到的模块在另一端可以随时被另一个相同功能的模块替换,从而实现两个模块之间的解耦。 寻找模块的实现方式其实只有有限的几种:

用一个字符串identifier来标识某个对应的界面(URL Router、UIStoryboardSegue) 利用Objective-C的runtime特性,直接调用目的模块的方法(CTMediator) 用一个protocol来和某个界面进行匹配(蘑菇街的第二种路由和阿里的BeeHive),这样就可以更安全的对目的模块进行传参

这几种方案的优劣将在之后逐一细说。

声明依赖和接口

一个模块A有时候需要使用其他模块的功能,例如最通用的log功能,不同的app有不同的log模块,如果模块A对通用性要求很高,log方法就不能在模块A里写死,而是应该通过外部调用。这时这个模块A就依赖于一个log模块了。App在使用模块A的时候,需要知道它的依赖,从而在使用模块A之前,对其注入依赖。

当通过cocoapods这样的包管理工具来配置不同模块间的依赖时,一般模块之间是强耦合的,模块是一一对应的,当需要替换一个模块时会很麻烦,容易牵一发而动全身。如果是一个单一功能模块,的确需要依赖其他特定的各种库时,那这样做没有问题。但是如果是一个业务模块中引用了另一个业务模块,就应该尽量避免互相耦合。因为不同的业务模块一般是由不同的人负责,应该避免出现一个业务模块的简单修改(例如调整了方法或者属性的名字)导致引用了它的业务模块也必须修改的情况。 这时候,业务模块就需要在代码里声明自己需要依赖的模块,让app在使用时提供这些模块,从而充分解耦。

示例代码:

@protocol ZIKLoginServiceInput <NSObject>
- (void)loginWithAccount:(NSString *)account
                password:(NSString *)password
                 success:(void(^_Nullable)(void))successHandler
                   error:(void(^_Nullable)(void))errorHandler;
@end
复制代码@interface ZIKNoteListViewController ()
//笔记界面需要登录后才能查看,因此在头文件中声明,让外部在使用的时候设置此属性
@property (nonatomic, strong) id<ZIKLoginServiceInput> loginService;
@end

这个声明依赖的工作其实是模块的Builder的职责。一个界面模块大部分情况下都不止有一个UIViewController,也有其他一些Manager或者Service,而这些角色都是有各自的依赖的,都统一由模块的Builder声明,再在Builder内部设置依赖。不过在上一篇文章的VIPER讲解里,我们把Builder的职责也放到了Router里,让每个模块单独提供一个自己的Router。因此在这里,Router是一个离散的设计,而不是一个单例Router掌管所有的路由。这样的好处就是每个模块可以充分定制和控制自己的路由过程。 可以声明依赖,也就可以同时声明模块的对外接口。这两者很相似,所以不再重复说明。 Builder和依赖注入

执行路由的同时用Builder进行模块构建,构建的时候就对模块内各个角色进行依赖注入。当你调用某个模块的时候,需要的不是某个简单的具体类,而是一个构建完毕的模块中的某个具体类。在使用这个模块前,模块需要做一些初始化的操作,比如VIPER里设置各个角色之间的依赖关系,就是一个初始化操作。因此使用路由去获取某个模块中的类,必定需要通过模块的Builder进行。很多路由工具都缺失了这部分功能。

你可以把依赖注入简单地看成对目的模块传参。在进行界面跳转和使用某个模块时,经常需要设置目的模块的一些参数,例如设置delegate回调。这时候就必须调用一些目的模块的方法,或者传递一些对象。由于每个模块需要的参数都不一样,目前大部分Router都是使用字典包裹参数进行传递。但其实还有更好、更安全的方案,下面将会进行详解。 你也可以把Router、Builder和Dependency Injector分开,不过如果Router是一个离散型的设计,那么都交给各自的Router去做也很合理,同时能够减少代码量,也能够提供细粒度的AOP。

现有的Router
  • URL Router

      - 优点
      极高的动态性
      统一多端路由规则
      适配URL scheme
    
    
      - 缺点
      不适合通用模块
      安全性差
      维护困难
    
  • Protocol Router

      优点
      安全性好,维护简单
      适用于所有模块
      优雅地声明依赖
    
    
      缺点
      动态性有限
      需要额外适配URL Scheme
      **Protocol是否会导致耦合?**
      业务设计的互相关联
      Required Interface 和 Provided Interface
    
  • Target-Action

      优点
      缺点
    
  • UIStoryboardSegue

      优点
      缺点
    
  • ZIKRouter的特性

现有的Router

梳理完了路由的职责,现在来比较一下现有的各种Router方案。关于各个方案的具体实现细节我就不再展开看,可以参考这篇详解的文章:iOS 组件化 —— 路由设计思路分析。

URL Router

目前绝大多数的Router都是用一串URL来表示需要打开的某个界面,代码上看来大概是这样:

//注册某个URL,和路由处理进行匹配保存
[URLRouter registerURL:@"settings" handler:^(NSDictionary *userInfo) {
	UIViewController *sourceViewController = userInfo[@"sourceViewController"];
	//获取其他参数
	id param = userInfo[@"param"];
	//获取需要的界面
	UIViewController *settingViewController = [[SettingViewController alloc] init];
	[sourceViewController.navigationController pushViewController: settingViewController animated:YES];
}];

//调用路由
[URLRouter openURL:@"myapp://noteList/settings?debug=true" userInfo:params completion:^(NSDictionary *info) {

}];

传递一串URL就能打开noteList界面的settings界面,用字典包裹需要传递的参数,有时候还会把UIKit的push、present等方法进行简单封装,提供给调用者。 这种方式的优点和缺点都很突出。

优点

极高的动态性

这是动态性最高的方案,甚至可以在运行时随时修改路由规则,指向不同的界面。也可以很轻松地支持多级页面的跳转。 如果你的app是电商类app,需要经常做活动,app内的跳转规则经常变动,那么就很适合使用URL的方案。

统一多端路由规则

URL的方案是最容易跨平台实现的,iOS、Andorid、web、PC都按照URL来进行路由时,也就可以统一管理多端的路由规则,降低多端各自维护和修改的成本,让不懂技术的运营人员也可以简单快速地修改路由。 和上一条一样,这也是一个和业务强相关的优点。如果你有统一多端的业务需求,使用URL也很合适。

适配URL scheme

iOS中的URL scheme可以跨进程通信,从app外打开app内的某个指定页面。当app内的页面都能使用URL打开时,也就直接兼容了URL scheme,无需再做额外的工作。

缺点

不适合通用模块

URL Router的设计只适合UI模块,不适合其他功能性模块的组件。功能性模块的调用并不需要如此强的动态特性,除非是有模块热更新的需求,否则一个模块的调用在一个版本里应该总是稳定不变的,即便要进行模块间解耦,也不应该用这种方式。

安全性差

字符串匹配的方式无法进行编译时检查,当页面配置出错时,只能在运行时才能发现。如果某个开发人员不小心在字符串里加了一个空格,编译时也无法发现。你可以用宏定义来减少这种出错的几率。

维护困难

没有高效地声明接口的方式,只能从文档里查找,编写时必须仔细对照字符串及其参数类型。 传参通过字典来进行,参数类型无法保证,而且也无法准确地知道所调用的接口需要哪些参数。当目的模块进行了接口升级,修改了参数类型和数量,那所有用到的地方都要一一修改,并且没有编译器的帮助,你无法知道是否遗漏了某些地方。这将会给维护和重构带来极大的成本。

针对这个问题,蘑菇街的选择是用另一个Router,用protocol来获取目的模块,再进行调用,增加安全性。

Protocol Router

这个方案也很容易理解。把之前的字符串匹配改成了protocol匹配,就能获取到一个实现了某个protocol的对象。

开源方案里只看到了BeeHive实现了这样的方式:

id<ZIKLoginServiceInput> loginService = [[BeeHive shareInstance] createService:@protocol(ZIKLoginServiceInput)];

优点

安全性好,维护简单

再对这个对象调用protocol中的方法,就十分安全了。在重构和修改时,有了编译器的类型检查,效率更高。

适用于所有模块 Protocol更加符合OC和Swift原生的设计思想,任何模块都可以使用,而不局限于UI模块。

优雅地声明依赖

模块A需要用到登录模块,但是它要怎么才能声明这种依赖关系呢?如果使用Protocol Router,那就只需要在头文件里定义一个属性:

@property (nonatomic, string) id<ZIKLoginServiceInput> *loginService;

如果这个依赖是必需依赖,而不是一个可选依赖,那就添加到初始化参数里:

@interface ModuleA ()
- (instancetype)initWithLoginService:(id<ZIKLoginServiceInput>)loginService;
@end

问题是,如果这样的依赖很多,那么初始化方法就会变得很长。因此更好的做法是由Builder进行固定的依赖注入,再提供给外部。目前BeeHive并没有提供依赖注入的功能。

缺点

动态性有限

你可以维护一份protocol和模块的对照表,使用动态的protocol来尝试动态地更改路由规则,也可以在Protocol Router之上封装一层URL Router专门用于动态性的需求。 需要额外适配URL Scheme 使用了Protocol Router就需要再额外处理URL Scheme了。不过这样也是正常的,解析URL Scheme本来就应该放到另一个单独的模块里。

Protocol是否会导致耦合?

很多谈到这种方案的文章都会指出,和URL Router相比,Protocol Router会导致调用者引用目的模块的protocol,因此会产生"耦合"。我认为这是对"解耦"的错误理解。

要想避免耦合,首先要弄清楚,我们需要什么程度的解耦。我的定义是:模块A调用了模块B,模块B的接口或者实现在做出简单的修改时,或者模块B被替换为相同功能的模块C时,模块A不需要进行任何修改。这时候就可以认为模块A和模块B是解耦的。 业务设计的互相关联

有些时候,表达出两个模块之间的关联是有意义的。

当一个界面A需要展示一个登录界面时,它可能需要向登录界面传递一个"提示语"参数,用于在登录界面显示一串提示。这时候,界面A在调用登录界面时,是要求登录界面能够显示这个自定义提示语的,在业务设计中就存在两个模块间的强关联性。这时候,URL Router和Protocol Router没有任何区别,包括下面将要提到的Target-Action路由方式,都存在耦合,但是Protocol Router通过简单地改善,是可以把这部分耦合去除的。

URL Router:

[URLRouter openURL:@"login" userInfo:@{@"message":@"请登录查看笔记详情"}];
复制代码Protocol Router:
@protocol LoginViewInput <NSObject>
@property (nonatomic, copy) NSString *message;
@end

//获取登录界面进行设置
UIViewController<LoginViewInput> *loginViewController = [ProtocolRouter destinationForProtocol:@protocol(LoginViewInput)];
loginViewController.message = @"请登录查看笔记详情";

复制代码由于字典传参的原因,URL Router只不过是把这种接口上的关联隐藏到了字典key里,它在参数字典里使用@"message"时,就是在隐式地使用LoginViewInput的接口。 这种业务设计上导致的模块之间互相关联是不可避免的,也是不需要去隐藏的。隐藏了反而会引来麻烦。如果登录界面的属性名字变了,从NSString *message改成了NSString *notifyString,那么URL Router在register的时候也必须修改传参时的代码。如果register是由登录界面自己执行和处理的,而不是由App Context来处理的,那么此时参数key是固定为@"notifyString"的,那就会要求所有调用者的传参key也修改为notifyString,这种修改如果缺少编译器的帮助会很危险,目前是用宏来减少这种修改导致的工作量。而Protocol Router在修改时就能充分利用编译器进行检查,能够保证100%安全。

因此,URL Router并不能做到解耦,只是隐藏了接口关联而已。一旦遇到了需要修改或者重构的情况,麻烦就出现了,在替换宏的时候,你还必须仔细检查有没有哪里有直接使用字符串的key。只是简单地修改名字还是可控的,如果是需要增加参数呢?这时候就根本无法检查哪里遗漏了参数传递了。这 就是字典传参的坏处。

关于这部分的讨论,也可以参考Peak大佬的文章:iOS组件化方案。 Protocol Router在这种情况下也需要作出修改,但是它能帮助你安全高效地进行重构。而且只要稍加改进,也可以完全无需修改。解决方法就是把Protocol分离为Required Interface和Provided Interface。

Required Interface 和 Provided Interface

模块的接口其实是有Required Interface和Provided Interface的区别的。Required Interface就是调用者需要用到的接口,Provided Interface就是实际的被调用者提供的接口。 在UML的组件图中,就很明确地表现出了这两者的概念。下图中的半圆就是Required Interface,框外的圆圈就是Provided Interface:

那么如何实施Required Interface和Provided Interface?上一篇文章里已经讨论过,应该由App Context在一个adapter里进行接口适配,从而使得调用者可以继续在内部使用Required Interface,adapter负责把Required Interface和修改后的Provided Interface进行适配。 示例代码:

@protocol ModuleARequiredLoginViewInput <NSObject>
@property (nonatomic, copy) NSString *message;
@end

//Module A中的调用代码
UIViewController<ModuleARequiredLoginViewInput> *loginViewController = [ZIKViewRouterToView(LoginViewInput) makeDestination];
loginViewController.message = @"请登录查看笔记详情";
复制代码//Login Module Provided Interface
@protocol ProvidedLoginViewInput <NSObject>
@property (nonatomic, copy) NSString *notifyString;
@end
复制代码//App Context 中的 Adapter,用Objective-C的category或者Swift的extension进行接口适配
@interface LoginViewController (ModuleAAdapte) <ModuleARequiredLoginViewInput>
@property (nonatomic, copy) NSString *message;
@end
@implementation LoginViewController (ModuleAAdapte)
- (void)setMessage:(NSString *)message {
	self.notifyString = message;
}
- (NSString *)message {
	return self.notifyString;
}
@end

复制代码用category、extension、NSProxy等技术兼容新旧接口,工作全部由模块的使用和装配者App Context完成。如果LoginViewController已经有了自己的message属性,这时候就说明新的登录模块是不可兼容的,必须有某一方做出修改。当然,接口适配能做的事情是有限的,例如一个接口从同步变成了异步,那么这时候两个模块也是不能兼容的。 因此,如果模块需要进行解耦,那么它的接口在设计的时候就应该十分仔细,尽量不要在参数中引入太多其他的模块依赖。 只有存在Required Interface和Provided Interface概念的设计,才能做到彻底的解耦。目前的路由方案都缺失了这一部分。