前言:之前对于组件化的认知,仅停留于
模块相互独立
、分层
的概念,另一方面,由于公司产品线较少,对于业务模块抽离
以及模块间通信
的方案没有明确的认知,这次就是需要全面学习了解一下。
1. 组件化介绍
1.1 什么是组件化
组件化就是将模块单独抽离
、分层,并制定模块间通信
的方式,从而实现解耦
,主要适用于大型团队
开发项目。
这里的模块包含基础模块、功能模块、业务模块。
1.2 组件化产生的原因
有人说从来没用过组件化,也不影响项目开发。确实项目组件化不是项目开发的必要条件,但是项目实施组件化之后可以大大提高项目的开发效率,当项目越来越大的时候,维护的人员也不只是一两个人了,各个模块之间如果直接互相引用
,就会产生许多耦合
,当某个模块需要修改时,那么就需要修改依赖于这个模块的所有模块,想想这是不是一件很恐怖的事。
实施组件化,主要有 4 个原因:
- 模块间解耦
- 模块重用
- 提高团队协作开发效率
- 单元测试
对应的问题主要体现在:
- 修改某个模块的功能时,需要修改其他引用该模块的代码,这样会导致开发成本增加
- 模块对外接口不明确,外部甚至会调用不应暴露的私有接口,修改时耗费大量时间
- 修改代码时,涉及到其他的模块,容易影响其他成员的开发,产生代码冲突
- 当某个模块需要在其他产品线复用时,会发现耦合严重导致无法单独抽离
- 模块间的耦合导致接口和依赖混乱,难以编写单元测试
所以需要减少模块之间的耦合,用更规范
的方式进行模块间交互
。这就是组件化,也可以叫做模块化。
1.3 实施组件化的前提
上面有提到组件化并不是项目开发的必要条件,实施组件化是需要成本的,需要花费时间设计接口,分离代码,像以下这些情况就不需要组件化了,当然也需要结合实际情况进行考虑:
- 项目比较小,由于需求原因,模块间交互简单,耦合少
- 模块没有被多个外部模块引用,只是一个单独的小模块
- 模块不需要重用,代码几乎不会修改了
- 项目只有一两个人维护的时候
- 不需要编写单元测试
当有以下几个现象时,就需要考虑组件化了:
- 模块逻辑复杂,模块间耦合严重
- 项目规模变大,修改一个代码需要设计好几个地方
- 团队人数变多,经常代码冲突
- 项目编译耗时较大
- 模块的单元测试经常由于其他模块的修改而失败
1.4 组件化方案的几条指标
当我们需要组件化的时候,也需要设定一个目标,来标明组件化之后会带来什么样的效果,比如:
- 模块间没有直接耦合,一个模块内部的修改不会影响到另一个模块
- 模块可以单独被编译
- 模块间能够清晰的进行数据传递
- 模块可以被重用或者被另一个提供了相同功能的模块替换
- 模块的对外接口容易查找和维护
- 当模块的接口改变时,使用此模块的外部代码能够被高效的重构
- 尽量使用最少的修改和代码,让现有的项目实现模块化
- 支持 OC 和 Swift,以及混编
前 4 条用于衡量一个模块是否被真正解耦,后面 4 条用于衡量在项目实践中的易用程度。
2. 组件划分
一般项目会分为基础组件
、通用组件
、业务组件
三种,相应也划分成了不同的层级,当然,这里只是给个建议,具体的划分需要结合项目进行分析,如下图所示:
同时,需要注意的是:
- 只能上层对下层进行依赖
- 如果同一层组件之间有依赖,则将依赖部分提取出来,抽离为下一层的组件(
依赖下沉
)
3. 组件间通信
对于通用组件和基础组件,这两层很少会产生横向依赖,我们可以使用cocoapods
把相应的代码封装成私有库
,具体可见Cocoapods私有库的创建,这里就不做赘述了。
比较麻烦的是业务组件
,或者称为业务模块
,因为产品很多天马星空的想法,就让不同业务组件产生了相互依赖,这是不可避免的,没有耦合、没有依赖就无法形成一个项目,所以如何处理业务组件之间的依赖
成为了组件化实施的重点。
有的项目中模块之间的关系如下图所示(图是随便画的,就是为了描述模块之间相互依赖的乱七八糟的关系):
截屏2021-04-11 下午3.34.50.png
从上图可以看到,每个模块都离不开其他模块,最终成了一坨,再改需求的时候,很容易形成连锁反应。
这样的一坨代码对于测试、编译、开发效率、后续扩展都有坏处,那怎么解决呢?
在程序员的自我修养这本书中,看到过这样一句话:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决
。这样理解的话,我们的问题瞬间逼格上升了,居然涉及到计算机系统软件体系结构了。
那我们就增加一个中间层,负责转发业务组件之间的信息,如下图所示:
截屏2021-04-11 下午3.45.05.png
现在看起来顺眼多了,中间层就是负责转发业务组件之间的信息,现在还会有几个问题:
- 中间层怎么去转发组件间调用?
- 一个模块只跟中间层通信,怎么知道另一个模块提供了什么接口?
- 上图中,模块和中间层之间相互依赖,怎么破除这个相互依赖?
3.1 Target-action
对于前两个问题,我们可以在中间层对外提供接口,实现时去调用对应模块的方法,如下:
// 中间层
#import "BookDetailComponent.h"
#import "ReviewComponent.h"
@implementation Mediator
+ (UIViewController *)BookDetailComponent_viewController:(NSString *)bookId {
return [BookDetailComponent detailViewController:bookId];
}
+ (UIViewController *)ReviewComponent_viewController:(NSString *)bookId reviewType:(NSInteger)type {
return [ReviewComponent reviewViewController:bookId type:type];
}
@end
//BookDetailComponent 组件
#import "Mediator.h"
#import "WRBookDetailViewController.h"
@implementation BookDetailComponent
+ (UIViewController *)detailViewController:(NSString *)bookId {
WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:bookId];
return detailVC;
}
@end
//ReviewComponent 组件
#import "Mediator.h"
#import "WRReviewViewController.h"
@implementation ReviewComponent
+ (UIViewController *)reviewViewController:(NSString *)bookId type:(NSInteger)type {
UIViewController *reviewVC = [[WRReviewViewController alloc] initWithBookId:bookId type:type];
return reviewVC;
}
@end
然后比如在阅读模块里这样使用:
//WRReadingViewController.m
#import "Mediator.h"
@implementation WRReadingViewController
- (void)gotoDetail:(NSString *)bookId {
UIViewController *detailVC = [Mediator BookDetailComponent_viewControllerForDetail:bookId];
[self.navigationController pushViewController:detailVC];
UIViewController *reviewVC = [Mediator ReviewComponent_viewController:bookId type:1];
[self.navigationController pushViewController:reviewVC];
}
@end
这就是上面那个架构图的实现,这样看来依赖关系没有解除,中间层(Mediator
)和模块之间仍然是相互依赖
的关系。
对于OC
来说有个办法可以解决这个问题,就是runtime
反射调用:
//Mediator.m
@implementation Mediator
+ (UIViewController *)BookDetailComponent_viewController:(NSString *)bookId {
Class cls = NSClassFromString(@"BookDetailComponent");
return [cls performSelector:NSSelectorFromString(@"detailViewController:") withObject:@{@"bookId":bookId}];
}
+ (UIViewController *)ReviewComponent_viewController:(NSString *)bookId type:(NSInteger)type {
Class cls = NSClassFromString(@"ReviewComponent");
return [cls performSelector:NSSelectorFromString(@"reviewViewController:") withObject:@{@"bookId":bookId, @"type": @(type)}];
}
@end
这下中间层(Mediator)
没有再对组件有依赖了,也不需要 #import
什么东西了,对应的架构图就变成:
只有调用其他组件接口时才需要依赖Mediator
,组件开发者不需要知道 Mediator
的存在,但是既然可以用runtime
就可以解耦取消依赖,那还用Mediator
干啥?组件间调用时直接用 runtime
接口调就行了,比如:
//WRReadingViewController.m
@implementation WRReadingViewController
- (void)gotoReview:(NSString *)bookId {
Class cls = NSClassFromString(@"ReviewComponent");
UIViewController *reviewVC = [cls performSelector:NSSelectorFromString(@"reviewViewController:") withObject:@{@"bookId":bookId, @"type": @(1)}];
[self.navigationController pushViewController:reviewVC];
}
@end
但是这样就会另外的问题:
- 写起来很恶心,代码提示都没有,每次调用写一坨
-
runtime
方法的参数个数和类型限制,导致只能每个接口都统一传一个NSDictionary
。这个NSDictionary
里的key value
是什么不明确,需要找个地方写文档说明和查看。 - 编译器层面不依赖其他组件,实际上还是依赖了,直接在这里调用,没有引入调用的组件时就挂了
所以需要将它移植到Mediator
中间层后:
- 调用者写起来不恶心,代码提示也有了
- 参数类型和个数无限制,由
Mediator
去转就行了,组件提供的还是一个NSDictionary
参数的接口,但在Mediator
里可以提供任意类型和个数的参数,像上面的例子显式要求参数NSString *bookId
和NSInteger type
-
Mediator
可以做统一处理,调用某个组件方法时如果某个组件不存在,可以做相应操作,让调用者与组件间没有耦合
到这里,基本上能解决我们的问题:各组件互不依赖,组件间调用只依赖中间件Mediator
,Mediator
不依赖其他组件。接下来就是优化这套写法,有两个优化点:
-
Mediator
每一个方法里都要写runtime
方法,格式是确定的,这是可以抽取出来的 - 每个组件对外方法都要在
Mediator
写一遍,组件一多Mediator
类的长度是恐怖的
优化后就成了casa 的方案CTMediator,target-action
对应第一点,target
就是class
,action
就是selector
,通过一些规则简化动态调用。Category
对应第二点,每个组件写一个 Mediator
的 Category
,让 Mediator
不至于太长。
总结起来就是,组件通过中间层通信
,中间层利用 OC
的 runtime
、category
特性动态获取模块,例如通过 NSClassFromString
获取类并创建实例,通过performSelector:
+NSInvocation
动态调用方法。
对于CTMediator
的具体分析可以查看组件化方案学习 - CTMediator这篇文章。
3.2 URL路由
这种方式是采用注册表的方式,用URL
来表示接口,在模块启动时注册模块提供的接口,可以看下面这个简化的实现:
//Mediator.m 中间件
@implementation Mediator
typedef void (^componentBlock) (id param);
@property (nonatomic, storng) NSMutableDictionary *cache
- (void)registerURLPattern:(NSString *)urlPattern toHandler:(componentBlock)blk {
[cache setObject:blk forKey:urlPattern];
}
- (void)openURL:(NSString *)url withParam:(id)param {
componentBlock blk = [cache objectForKey:url];
if (blk) blk(param);
}
@end
//BookDetailComponent 组件
#import "Mediator.h"
#import "WRBookDetailViewController.h"
+ (void)initComponent {
[[Mediator sharedInstance] registerURLPattern:@"weread://bookDetail" toHandler:^(NSDictionary *param) {
WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:param[@"bookId"]];
[[UIApplication sharedApplication].keyWindow.rootViewController.navigationController pushViewController:detailVC animated:YES];
}];
}
//WRReadingViewController.m 调用者
//ReadingViewController.m
#import "Mediator.h"
+ (void)gotoDetail:(NSString *)bookId {
[[Mediator sharedInstance] openURL:@"weread://bookDetail" withParam:@{@"bookId": bookId}];
}
这样也可以做到每个模块之间没有依赖,中间层也不会依赖其他组件,不过这里不同的是组件本身和调用者都依赖了 Mediator
,不过这不是重点,架构图还是和之前的一样。
各个组件初始化时向 Mediator
注册对外提供的接口,Mediator
通过保存在内存
的表去查找模块需要哪些接口,接口的形式是 URL->block
。
这里先不谈URL
的远程调用和本地调用混在一起导致的问题,先说一下本地调用的情况,对于本地调用,URL
只是一个表示组件的key
,没有其他作用,这样做有三个问题:
- 需要有个地方列出各个组件里有什么
URL
接口可供调用。蘑菇街做了个后台专门管理,相当于一个说明文档 - 每个组件都需要初始化,内存里需要保存一份表,组件多了会有内存问题
- 参数的格式不明确,是个灵活的
dictionary
,也需要有个地方可以查参数格式
第二点没法解决,第一点和第三点可以跟前面那个方案一样,在 Mediator
每个组件暴露方法的转接口,然后使用起来就跟前面那种方式一样了。
抛开URL
不说,这种方案跟Target+Action
的共同思路就是:Mediator
不能直接去调用组件的方法,因为这样会产生依赖,那我就要通过其他方法去调用,也就是通过 字符串->方法
的映射去调用。runtime
接口的className + selectorName -> IMP
是一种,注册表的 key -> block
是一种,而前一种是 OC
自带的特性,后一种需要内存维持一份注册表,这是不必要的。
现在说回URL
,组件化是不应该跟URL
扯上关系的,因为组件对外提供的接口主要是模块间代码层面
上的调用,我们先称为本地调用
,而URL
主要用于APP
间通信,姑且称为远程调用
。按常规思路者应该是对于远程调用
,再加个中间层
转发到本地调用
,让这两者分开。那这里这两者混在一起有什么问题呢?
如果是URL
的形式,那组件对外提供接口时就要同时考虑本地调用和远程调用两种情况,而远程调用有个限制,传递的参数类型有限制
,只能传能被字符串化的数据,或者说只能传能被转成json
的数据,像 UIImage
这类对象是不行的,所以如果组件接口要考虑远程调用,这里的参数就不能是这类非常规对象,接口的定义就受限了。
3.3 protocol-class
这种方案其实是用于本地调用,就是通过 protocol-class
注册表的方式实现的:
- 首先由一个中间件
//ProtocolMediator.m 新中间件
@implementation ProtocolMediator
@property (nonatomic, storng) NSMutableDictionary *protocolCache
- (void)registerProtocol:(Protocol *)proto forClass:(Class)cls {
NSMutableDictionary *protocolCache;
[protocolCache setObject:cls forKey:NSStringFromProtocol(proto)];
}
- (Class)classForProtocol:(Protocol *)proto {
return protocolCache[NSStringFromProtocol(proto)];
}
@end
- 然后有一个公共
Protocol
文件,定义了每一个组件对外提供的接口:
//ComponentProtocol.h
@protocol BookDetailComponentProtocol <NSObject>
- (UIViewController *)bookDetailController:(NSString *)bookId;
- (UIImage *)coverImageWithBookId:(NSString *)bookId;
@end
@protocol ReviewComponentProtocol <NSObject>
- (UIViewController *)ReviewController:(NSString *)bookId;
@end
- 再在模块里实现这些接口,并在初始化时调用
registerProtocol
注册:
//BookDetailComponent 组件
#import "ProtocolMediator.h"
#import "ComponentProtocol.h"
#import "WRBookDetailViewController.h"
+ (void)initComponent
{
[[ProtocolMediator sharedInstance] registerProtocol:@protocol(BookDetailComponentProtocol) forClass:[self class];
}
- (UIViewController *)bookDetailController:(NSString *)bookId {
WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:param[@"bookId"]];
return detailVC;
}
- (UIImage *)coverImageWithBookId:(NSString *)bookId {
….
}
- 通过 protocol 从 ProtocolMediator 拿到提供这些方法的 Class,再进行调用:
//WRReadingViewController.m 调用者
//ReadingViewController.m
#import "ProtocolMediator.h"
#import "ComponentProtocol.h"
+ (void)gotoDetail:(NSString *)bookId {
Class cls = [[ProtocolMediator sharedInstance] classForProtocol:BookDetailComponentProtocol];
id bookDetailComponent = [[cls alloc] init];
UIViewController *vc = [bookDetailComponent bookDetailController:bookId];
[[UIApplication sharedApplication].keyWindow.rootViewController.navigationController pushViewController:vc animated:YES];
}
我们可以看到,这种方案相当于将组件和协议对应存储起来,每个组件都实现了相应的协议,这些个协议就是组件对外提供的接口,在业务方都是直接可见的,当业务方需要使用某个组件的时候,会通过中间层根据协议获取对应的组件,然后调用该组件的方法,简而言之就是:
- 将
protocol
和对应的类进行字典匹配 - 通过用
protocol
获取class
,再动态创建实例,调用方法
这个方案跟Target-Action
最大的不同是,它不是直接通过Mediator
调用组件方法,而是通过Mediator
拿到对应的组件对象,再自行去调用组件方法。
结果就是组件方法的调用是分散在各地的,没有统一的入口,也没法做组件不存在时的处理。
4. 总结
每个方案都有优劣,各个公司实施组件化的方案都是上面的一种或者多种的组合,这个就需要根据自己的项目制定出合适的方案,毕竟组件化也是需要一些成本的。
网友评论