移动客户端

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

2021-04-11  本文已影响0人  沉江小鱼

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

1. 组件化介绍

1.1 什么是组件化

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

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

1.2 组件化产生的原因

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

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

对应的问题主要体现在:

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

1.3 实施组件化的前提

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

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

1.4 组件化方案的几条指标

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

前 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

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

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

但是这样就会另外的问题:

所以需要将它移植到Mediator中间层后:

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

优化后就成了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,没有其他作用,这样做有三个问题:

第二点没法解决,第一点和第三点可以跟前面那个方案一样,在 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
//ComponentProtocol.h
@protocol BookDetailComponentProtocol <NSObject>
- (UIViewController *)bookDetailController:(NSString *)bookId;
- (UIImage *)coverImageWithBookId:(NSString *)bookId;
@end

@protocol ReviewComponentProtocol <NSObject>
- (UIViewController *)ReviewController:(NSString *)bookId;
@end
//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 {
 ….
}
//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];
}

我们可以看到,这种方案相当于将组件和协议对应存储起来,每个组件都实现了相应的协议,这些个协议就是组件对外提供的接口,在业务方都是直接可见的,当业务方需要使用某个组件的时候,会通过中间层根据协议获取对应的组件,然后调用该组件的方法,简而言之就是:

这个方案跟Target-Action最大的不同是,它不是直接通过Mediator调用组件方法,而是通过Mediator拿到对应的组件对象,再自行去调用组件方法。

结果就是组件方法的调用是分散在各地的,没有统一的入口,也没法做组件不存在时的处理。

4. 总结

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

上一篇下一篇

猜你喜欢

热点阅读