[iOS] 组件化方案学习 - 缘起

前言:之前对于组件化的认知,仅停留于模块相互独立分层的概念,另一方面,由于公司产品线较少,对于业务模块抽离以及模块间通信的方案没有明确的认知,这次就是需要全面学习了解一下。

1. 组件化介绍

1.1 什么是组件化

组件化就是将模块单独抽离、分层,并制定模块间通信的方式,从而实现解耦,主要适用于大型团队开发项目。

这里的模块包含基础模块、功能模块、业务模块。

1.2 组件化产生的原因

有人说从来没用过组件化,也不影响项目开发。确实项目组件化不是项目开发的必要条件,但是项目实施组件化之后可以大大提高项目的开发效率,当项目越来越大的时候,维护的人员也不只是一两个人了,各个模块之间如果直接互相引用,就会产生许多耦合,当某个模块需要修改时,那么就需要修改依赖于这个模块的所有模块,想想这是不是一件很恐怖的事。

实施组件化,主要有 4 个原因:

  • 模块间解耦
  • 模块重用
  • 提高团队协作开发效率
  • 单元测试

对应的问题主要体现在:

  • 修改某个模块的功能时,需要修改其他引用该模块的代码,这样会导致开发成本增加
  • 模块对外接口不明确,外部甚至会调用不应暴露的私有接口,修改时耗费大量时间
  • 修改代码时,涉及到其他的模块,容易影响其他成员的开发,产生代码冲突
  • 当某个模块需要在其他产品线复用时,会发现耦合严重导致无法单独抽离
  • 模块间的耦合导致接口和依赖混乱,难以编写单元测试

所以需要减少模块之间的耦合,用更规范的方式进行模块间交互。这就是组件化,也可以叫做模块化。

1.3 实施组件化的前提

上面有提到组件化并不是项目开发的必要条件,实施组件化是需要成本的,需要花费时间设计接口,分离代码,像以下这些情况就不需要组件化了,当然也需要结合实际情况进行考虑:

  • 项目比较小,由于需求原因,模块间交互简单,耦合少
  • 模块没有被多个外部模块引用,只是一个单独的小模块
  • 模块不需要重用,代码几乎不会修改了
  • 项目只有一两个人维护的时候
  • 不需要编写单元测试

当有以下几个现象时,就需要考虑组件化了:

  • 模块逻辑复杂,模块间耦合严重
  • 项目规模变大,修改一个代码需要设计好几个地方
  • 团队人数变多,经常代码冲突
  • 项目编译耗时较大
  • 模块的单元测试经常由于其他模块的修改而失败
1.4 组件化方案的几条指标

当我们需要组件化的时候,也需要设定一个目标,来标明组件化之后会带来什么样的效果,比如:

  • 模块间没有直接耦合,一个模块内部的修改不会影响到另一个模块
  • 模块可以单独被编译
  • 模块间能够清晰的进行数据传递
  • 模块可以被重用或者被另一个提供了相同功能的模块替换
  • 模块的对外接口容易查找和维护
  • 当模块的接口改变时,使用此模块的外部代码能够被高效的重构
  • 尽量使用最少的修改和代码,让现有的项目实现模块化
  • 支持 OC 和 Swift,以及混编

前 4 条用于衡量一个模块是否被真正解耦,后面 4 条用于衡量在项目实践中的易用程度。

2. 组件划分

一般项目会分为基础组件通用组件业务组件三种,相应也划分成了不同的层级,当然,这里只是给个建议,具体的划分需要结合项目进行分析,如下图所示:

截屏2021-04-11 下午3.15.39.png

同时,需要注意的是:

  • 只能上层对下层进行依赖
  • 如果同一层组件之间有依赖,则将依赖部分提取出来,抽离为下一层的组件(依赖下沉

3. 组件间通信

对于通用组件和基础组件,这两层很少会产生横向依赖,我们可以使用cocoapods把相应的代码封装成私有库,具体可见Cocoapods私有库的创建,这里就不做赘述了。

比较麻烦的是业务组件,或者称为业务模块,因为产品很多天马星空的想法,就让不同业务组件产生了相互依赖,这是不可避免的,没有耦合、没有依赖就无法形成一个项目,所以如何处理业务组件之间的依赖成为了组件化实施的重点。

有的项目中模块之间的关系如下图所示(图是随便画的,就是为了描述模块之间相互依赖的乱七八糟的关系):


截屏2021-04-11 下午3.34.50.png

从上图可以看到,每个模块都离不开其他模块,最终成了一坨,再改需求的时候,很容易形成连锁反应。

这样的一坨代码对于测试、编译、开发效率、后续扩展都有坏处,那怎么解决呢?
在程序员的自我修养这本书中,看到过这样一句话:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。这样理解的话,我们的问题瞬间逼格上升了,居然涉及到计算机系统软件体系结构了。

那我们就增加一个中间层,负责转发业务组件之间的信息,如下图所示:


截屏2021-04-11 下午3.45.05.png

现在看起来顺眼多了,中间层就是负责转发业务组件之间的信息,现在还会有几个问题:

    1. 中间层怎么去转发组件间调用?
    1. 一个模块只跟中间层通信,怎么知道另一个模块提供了什么接口?
    1. 上图中,模块和中间层之间相互依赖,怎么破除这个相互依赖?
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什么东西了,对应的架构图就变成:

截屏2021-04-11 下午3.59.08.png

只有调用其他组件接口时才需要依赖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 *bookIdNSInteger type
  • Mediator可以做统一处理,调用某个组件方法时如果某个组件不存在,可以做相应操作,让调用者与组件间没有耦合

到这里,基本上能解决我们的问题:各组件互不依赖,组件间调用只依赖中间件MediatorMediator不依赖其他组件。接下来就是优化这套写法,有两个优化点:

  • Mediator 每一个方法里都要写 runtime 方法,格式是确定的,这是可以抽取出来的
  • 每个组件对外方法都要在 Mediator 写一遍,组件一多 Mediator 类的长度是恐怖的

优化后就成了casa 的方案CTMediatortarget-action 对应第一点,target就是classaction就是selector,通过一些规则简化动态调用。Category 对应第二点,每个组件写一个 MediatorCategory,让 Mediator 不至于太长。

总结起来就是,组件通过中间层通信,中间层利用 OCruntimecategory 特性动态获取模块,例如通过 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. 总结

每个方案都有优劣,各个公司实施组件化的方案都是上面的一种或者多种的组合,这个就需要根据自己的项目制定出合适的方案,毕竟组件化也是需要一些成本的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容