[关闭]
@qidiandasheng 2022-08-07T13:27:45.000000Z 字数 7599 阅读 1899

组件化 OR 模块化(😁)

架构


组件化和模块化介绍

什么是架构

首先,“系统是一群关联个体组成”,这些“个体”可以是“子系统”“模块”“组件”等;架构需要明确系统包含哪些“个体”。 其次系统中的个体需要“根据某种规则”运作,架构需要明确个体运作和协作的规则。

模块化和组件化的区别

类别 目的 特点 接口 成果 架构定位
组件 重用、解耦 高重用、松耦合 无统一接口 基础库、基础组件 纵向分层
模块 隔离、封装 高内聚、松耦合 统一接口 业务框架、业务模块 横向分块

从代码组织层面上来区分,组件化开发是纵向分层,模块化开发是横向分块,所以模块化并没有要求一定组件化。也就是说你可以只做模块化开发,而不做组件化开发。那这样的结果是什么样的呢?就是说你的代码完全不考虑代码重用,只是把相同业务的代码做内聚整合,不同模块之间还是存在大量的重复代码。

区别举例

解决什么问题

重用

组件化主要关注的是重用,关键结果就是提炼出一个个组件给不同的功能使用。是具体功能的依赖提炼出来的组件,组件本身之间可能也有依赖关系,但一般不多。所以我们总结组件化开发的原则就是高重用,低依赖。

高内聚

模块的定义,它是以关注点进行划分的,关注点说到底就是功能,或者说业务上的职责进行划分。如果未进行模块化开发的拆分,那么很多时候不同模块的同一类的代码都是直接写在一起的,比如系统启动的时候,我们会在启动方法里直接写多个模块的初始化代码。

而模块化开发就是为了解决这一问题,即提高内聚,将分属同一模块代码放到一起;降低耦合,将不同模块间的耦合程度弱化。

高内聚是目标,但是现状是有许多地方会用到多个模块,比如启动的时候会调用四个模块,首页会展示三个模块的界面。如果要高内聚,那么必然需要这些模块为不同的场景提供相同的方法,这就是说所有模块要实现同一套多个接口。这样主应用和模块之间的重耦合就变成了主应用和接口耦合,接口和模块耦合这样的松耦合。

但这样的简单模块只是轻模块,统一接口较少。而统一定义的接口越多,模块和统一接口的耦合就越高,也便是重模块。

分层

分层.png-86.3kB

解耦

我们一般讲的路由问题其实只是解决模块间耦合的问题,并不是模块化开发的必然需求,更多时候是基于产品上的动态化要求,只不过我们一般都会在这个时间考虑模块化这一事情而已,就像我们不会只做模块化开发同时不做组件化开发一样。

组件化框架基本都是通过服务中心控制反转/依赖注入来解耦创建强依赖。服务中心通过url来进行解析,解耦数据传递强依赖。

其实现在我们的组件化能力主要专注于松耦合,模块之间通过url来进行数据的交互,并不强依赖,并且统一通过服务中心来进行模块之间的调用。

比如系统A中,打车模块和住宿模块之间有相互的调用。如果我们的打车模块需要移植到系统B中,系统B中不需要住宿模块(不用依赖住宿模块即可),或者有自己的住宿模块(那只要实现相同的接口协议即可)。

解耦.png-124.3kB

路由的作用

现有的组件化框架主要解决的就是模块间解耦的问题(通过路由进行解决),路由最重要的功能就是给出一种寻找某个指定模块的方案。这个方案是松耦合的,获取到的模块在另一端可以随时被另一个相同功能的模块替换,从而实现两个模块之间的解耦。

寻找模块的实现方式其实只有有限的几种:

什么情况下不需要组件化

做任何事情都要讲求一个“度”,过度使用这条原则,非得给每个类都定义接口,接口满天飞,也会导致不必要的开发负担。至于什么时候,该为某个类定义接口,实现基于接口的编程,什么时候不需要定义接口,直接使用实现类编程,我们做权衡的根本依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。

这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。

从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。

Target-Action模式

未命名文件.png-52.8kB

Target_Action层

其实这种方式跟我们现在用的方式差不多,CTMediator这层主要就是运行时调用,通过字符串targetaction动态来调用模块A这一层,模块A的命名域并不会对外渗透。而模块A里的Target_Action则是对A实现层的一层调用封装。

Target_Action层:

  1. #import <UIKit/UIKit.h>
  2. @interface Target_A : NSObject
  3. - (UIViewController *)Action_viewController:(NSDictionary *)params;
  4. @end
  5. 实现文件:
  6. #import "Target_A.h"
  7. #import "AViewController.h"
  8. @implementation Target_A
  9. - (UIViewController *)Action_viewController:(NSDictionary *)params
  10. {
  11. AViewController *viewController = [[AViewController alloc] init];
  12. return viewController;
  13. }
  14. @end

动态调用层

实际的调用都是通过CTMediator层来进行动态调用的,通过运行时字符串targetaction的解析来调用。

业务方可以通过以下这样的方式直接调用:

  1. [self performTarget:@"A" action:@"viewController" params:params shouldCacheTarget:NO];
  1. //这里的类和方法子都是运行时动态生成的
  2. - (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
  3. {
  4. if (targetName == nil || actionName == nil) {
  5. return nil;
  6. }
  7. NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
  8. // generate target
  9. NSString *targetClassString = nil;
  10. if (swiftModuleName.length > 0) {
  11. targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
  12. } else {
  13. targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
  14. }
  15. NSObject *target = [self safeFetchCachedTarget:targetClassString];
  16. if (target == nil) {
  17. Class targetClass = NSClassFromString(targetClassString);
  18. target = [[targetClass alloc] init];
  19. }
  20. // generate action
  21. NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
  22. SEL action = NSSelectorFromString(actionString);
  23. .......
  24. }

但以上方式还是会存在这样两个问题:

  1. 直接硬编码的调用方式,参数是以 string 的方法保存在内存里,虽然和将参数保存在 Text 字段里占用的内存差不多,同时还可以避免.h 文件的耦合,但是其对代码编写效率的降低也比较明显。

  2. 由于是在运行时才确定的调用方法,调用方式由 [obj method] 变成 [obj performSelector:@""]。这样的话,在调用时就缺少类型检查,是个很大的缺憾。因为,如果方法和参数比较多的时候,代码编写效率就会比较低。

所以这里需要组件提供方来实现一个Categorycategoryextension 以函数声明的方式,解决了参数的问题。调用者看这个函数长什么样子,就知道给哪些参数。在 categoryextension 的方法实现中,把参数字典化,顺便把 targetaction 这俩字符串写死在调用里。

本质上就是把原来的字符串动态调用上面再封装一层,当然这个Category是独立的,只依赖于CTMediator,并不依赖于组件A。

  1. - (UIViewController *)A_aViewController{
  2. return [self performTarget:@"A" action:@"viewController" params:nil shouldCacheTarget:NO];
  3. }

思考

其实我觉得可以不用Category这一层,主要单独为Category来封装一个Pod成本过大,还有跨端调用时也并没有办法使用组件方提供的函数式调用。

优点:

缺点:

URL路由

目前 iOS 上绝大部分的路由工具都是基于 URL 匹配的,或者是根据命名约定,用 runtime 方法进行动态调用。

这些动态化的方案的优点是实现简单,缺点是需要维护字符串表,或者依赖于命名约定,无法在编译时暴露出所有问题,需要在运行时才能发现错误。

代码示例:

  1. // 注册某个URL
  2. [URLRouter registerURL:@"app://editor" handler:^(NSDictionary *userInfo) {
  3. UIViewController *editorViewController = [[EditorViewController alloc] initWithParam:userInfo];
  4. return editorViewController;
  5. }];
  1. // 调用路由
  2. [URLRouter openURL:@"app://editor/?debug=true" completion:^(NSDictionary *info) {
  3. }];

优点

缺点

字符串解耦的问题

URL 路由只能满足”支持模块单独编译”、”支持 OC 和 Swift”两条。它的解耦程度非常一般。

所有基于字符串的解耦方案其实都可以说是伪解耦,它们只是放弃了编译依赖,但是当代码变化之后,即便能够编译运行,逻辑仍然是错误的。

例如修改了模块定义时的 URL:

  1. // 注册某个URL
  2. [URLRouter registerURL:@"app://editorView" handler:^(NSDictionary *userInfo) {
  3. ...
  4. }];

那么调用者的 URL 也必须修改,代码仍然是有耦合的,只不过此时编译器无法检查而已。这会导致维护更加困难,一旦 URL 中的参数有了增减,或者决定替换为另一个模块,参数命名有了变化,几乎没有高效的方式来重构代码。可以使用宏定义来管理字符串,不过这要求所有模块都使用同一个头文件,并且也无法解决参数类型和数量变化的问题。

URL 路由适合用来做远程模块的网络协议交互,而在管理本地模块时,最大的甚至是唯一的优势,就是适合经常跨多端运营活动的 app,因为可以由运营人员统一管理多平台的路由规则。

改进:避免字符串管理

ZIKRouter

改进 URL 路由的方式,就是避免使用字符串,通过接口管理模块。

参数可以通过 protocol 直接传递,能够利用编译器检查参数类型,并且在 ZIKRouter 中,能通过路由声明和编译检查,保证所使用的模块一定存在。在为模块创建路由时,也无需修改模块的代码。

但是必须要承认的是,尽管 URL 路由缺点多多,但它在跨平台路由管理上的确是最适合的方案。因此 ZIKRouter 也对 URL 路由做出了支持,在用 protocol 管理的同时,可以通过字符串匹配 router,也能和其他 URL router 框架对接。

公司的方案

其实公司的方案本质上跟CTMediator是很像的,主要是没有了Category那一层提供一个函数式的接口。

然而在表现形式上趋向于URL路由的方式,URL由组件提供方来实现,组件提供方依赖于路由层,实现路由层的协议。通过实现协议来实现上面Target_Action类似的接口封装层,但表现上Target_Action可以通过URL来表现,也就是说URL通过解析能解析成Target_Action的形式。

Target_Action层

以下就是组件方通过遵守路由层的协议来提供Target_ActiontargetmyWalletActionopengetInfo。同时还可以给参数设置类型。

  1. + (void)moduleDescription:(SCCModuleDescription *)description {
  2. description
  3. .name(@"myWallet")
  4. .method(^(SCCModuleMethod * _Nonnull method) {
  5. method
  6. .name(@"open")
  7. .selector(@selector(open:))
  8. .parameters(^(SCCModuleParamEnumerator * _Nonnull enumerator) {
  9. enumerator.next.type(SCCParamTypeString).name(@"code");
  10. });
  11. })
  12. .method(^(SCCModuleMethod * _Nonnull method) {
  13. method
  14. .name(@"getInfo")
  15. .selector(@selector(getWalletType:))
  16. .parameters(^(SCCModuleParamEnumerator * _Nonnull enumerator) {
  17. enumerator.next.type(SCCParamTypeBlock);
  18. });
  19. });
  20. }

动态调用层

业务方调用的时候一种就是通过直接传URL,在路由层里面会对URL进行解析分解成Target_Action,然后runtime动态调用。一种就是直接调用TargetAction在路由层里面进行runtime动态调用。

  1. [[SCCModulor modulor] modulesURLString:@"ds://open/myWallet?code=111" performWithParams:nil callback:^(NSDictionary * _Nullable moduleInfo) {
  2. //回调
  3. }];
  4. [[SCCModulor modulor] moduleName:@"myWallet" performSelectorName:@"open" withParams:@{@"code":@"111"} callback:^(NSDictionary * _Nullable moduleInfo) {
  5. //回调
  6. }];

参考

在现有工程中实施基于CTMediator的组件化方案
打造完备的iOS组件化方案:如何面向接口进行模块解耦
iOS VIPER架构实践(三):面向接口的路由设计
蘑菇街
routable-ios

模块化与组件化有什么区别
App 组件化与业务拆分那些事

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注