@qidiandasheng
2022-08-07T21:27:45.000000Z
字数 7599
阅读 2111
架构
首先,“系统是一群关联个体组成”,这些“个体”可以是“子系统”“模块”“组件”等;架构需要明确系统包含哪些“个体”。 其次系统中的个体需要“根据某种规则”运作,架构需要明确个体运作和协作的规则。
类别 | 目的 | 特点 | 接口 | 成果 | 架构定位 |
---|---|---|---|---|---|
组件 | 重用、解耦 | 高重用、松耦合 | 无统一接口 | 基础库、基础组件 | 纵向分层 |
模块 | 隔离、封装 | 高内聚、松耦合 | 统一接口 | 业务框架、业务模块 | 横向分块 |
从代码组织层面上来区分,组件化开发是纵向分层,模块化开发是横向分块,所以模块化并没有要求一定组件化。也就是说你可以只做模块化开发,而不做组件化开发。那这样的结果是什么样的呢?就是说你的代码完全不考虑代码重用,只是把相同业务的代码做内聚整合,不同模块之间还是存在大量的重复代码。
组件化就比如公共的alert框,最初在许多页面都有使用,后面提取出一份相同的代码,其实就是基于代码复用的目的。
模块化就比如一个资讯功能,它本身只在这一个地方使用,没有复用的需求,但系统启动的时候要初始化它的数据,首页显示的时候要展示它的数据,显示红点的时候要拉取它的未读数。这样一来应用中就有很多地方涉及到它的代码。如果我们将它看做一个整体,那么资讯模块和主应用的耦合性就非常高了。所以我们也要把它封装成模块,把相关的代码放到独立的单元文件里,并提供公共方法,这就是高内聚的要求。
组件化主要关注的是重用,关键结果就是提炼出一个个组件给不同的功能使用。是具体功能的依赖提炼出来的组件,组件本身之间可能也有依赖关系,但一般不多。所以我们总结组件化开发的原则就是高重用,低依赖。
模块的定义,它是以关注点进行划分的,关注点说到底就是功能,或者说业务上的职责进行划分。如果未进行模块化开发的拆分,那么很多时候不同模块的同一类的代码都是直接写在一起的,比如系统启动的时候,我们会在启动方法里直接写多个模块的初始化代码。
而模块化开发就是为了解决这一问题,即提高内聚,将分属同一模块代码放到一起;降低耦合,将不同模块间的耦合程度弱化。
高内聚是目标,但是现状是有许多地方会用到多个模块,比如启动的时候会调用四个模块,首页会展示三个模块的界面。如果要高内聚,那么必然需要这些模块为不同的场景提供相同的方法,这就是说所有模块要实现同一套多个接口。这样主应用和模块之间的重耦合就变成了主应用和接口耦合,接口和模块耦合这样的松耦合。
但这样的简单模块只是轻模块,统一接口较少。而统一定义的接口越多,模块和统一接口的耦合就越高,也便是重模块。
我们一般讲的路由问题其实只是解决模块间耦合的问题,并不是模块化开发的必然需求,更多时候是基于产品上的动态化要求,只不过我们一般都会在这个时间考虑模块化这一事情而已,就像我们不会只做模块化开发同时不做组件化开发一样。
组件化框架基本都是通过服务中心控制反转/依赖注入来解耦创建强依赖。服务中心通过url来进行解析,解耦数据传递强依赖。
其实现在我们的组件化能力主要专注于松耦合,模块之间通过url来进行数据的交互,并不强依赖,并且统一通过服务中心来进行模块之间的调用。
比如系统A中,打车模块和住宿模块之间有相互的调用。如果我们的打车模块需要移植到系统B中,系统B中不需要住宿模块(不用依赖住宿模块即可),或者有自己的住宿模块(那只要实现相同的接口协议即可)。
现有的组件化框架主要解决的就是模块间解耦的问题(通过路由进行解决),路由最重要的功能就是给出一种寻找某个指定模块的方案。这个方案是松耦合的,获取到的模块在另一端可以随时被另一个相同功能的模块替换,从而实现两个模块之间的解耦。
寻找模块的实现方式其实只有有限的几种:
做任何事情都要讲求一个“度”,过度使用这条原则,非得给每个类都定义接口,接口满天飞,也会导致不必要的开发负担。至于什么时候,该为某个类定义接口,实现基于接口的编程,什么时候不需要定义接口,直接使用实现类编程,我们做权衡的根本依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。
这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。
其实这种方式跟我们现在用的方式差不多,CTMediator
这层主要就是运行时调用,通过字符串target
和action
动态来调用模块A这一层,模块A的命名域并不会对外渗透。而模块A里的Target_Action
则是对A实现层的一层调用封装。
Target_Action
层:
#import <UIKit/UIKit.h>
@interface Target_A : NSObject
- (UIViewController *)Action_viewController:(NSDictionary *)params;
@end
实现文件:
#import "Target_A.h"
#import "AViewController.h"
@implementation Target_A
- (UIViewController *)Action_viewController:(NSDictionary *)params
{
AViewController *viewController = [[AViewController alloc] init];
return viewController;
}
@end
实际的调用都是通过CTMediator
层来进行动态调用的,通过运行时字符串target
和action
的解析来调用。
业务方可以通过以下这样的方式直接调用:
[self performTarget:@"A" action:@"viewController" params:params shouldCacheTarget:NO];
//这里的类和方法子都是运行时动态生成的
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{
if (targetName == nil || actionName == nil) {
return nil;
}
NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
// generate target
NSString *targetClassString = nil;
if (swiftModuleName.length > 0) {
targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
} else {
targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
}
NSObject *target = [self safeFetchCachedTarget:targetClassString];
if (target == nil) {
Class targetClass = NSClassFromString(targetClassString);
target = [[targetClass alloc] init];
}
// generate action
NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
SEL action = NSSelectorFromString(actionString);
.......
}
但以上方式还是会存在这样两个问题:
直接硬编码的调用方式,参数是以 string 的方法保存在内存里,虽然和将参数保存在 Text 字段里占用的内存差不多,同时还可以避免.h 文件的耦合,但是其对代码编写效率的降低也比较明显。
由于是在运行时才确定的调用方法,调用方式由 [obj method] 变成 [obj performSelector:@""]。这样的话,在调用时就缺少类型检查,是个很大的缺憾。因为,如果方法和参数比较多的时候,代码编写效率就会比较低。
所以这里需要组件提供方来实现一个Category
。category
或 extension
以函数声明的方式,解决了参数的问题。调用者看这个函数长什么样子,就知道给哪些参数。在 category
或 extension
的方法实现中,把参数字典化,顺便把 target
、action
这俩字符串写死在调用里。
本质上就是把原来的字符串动态调用上面再封装一层,当然这个Category
是独立的,只依赖于CTMediator
,并不依赖于组件A。
- (UIViewController *)A_aViewController{
return [self performTarget:@"A" action:@"viewController" params:nil shouldCacheTarget:NO];
}
其实我觉得可以不用Category
这一层,主要单独为Category
来封装一个Pod成本过大,还有跨端调用时也并没有办法使用组件方提供的函数式调用。
优点:
缺点:
目前 iOS 上绝大部分的路由工具都是基于 URL 匹配的,或者是根据命名约定,用 runtime 方法进行动态调用。
这些动态化的方案的优点是实现简单,缺点是需要维护字符串表,或者依赖于命名约定,无法在编译时暴露出所有问题,需要在运行时才能发现错误。
代码示例:
// 注册某个URL
[URLRouter registerURL:@"app://editor" handler:^(NSDictionary *userInfo) {
UIViewController *editorViewController = [[EditorViewController alloc] initWithParam:userInfo];
return editorViewController;
}];
// 调用路由
[URLRouter openURL:@"app://editor/?debug=true" completion:^(NSDictionary *info) {
}];
URL 路由只能满足”支持模块单独编译”、”支持 OC 和 Swift”两条。它的解耦程度非常一般。
所有基于字符串的解耦方案其实都可以说是伪解耦,它们只是放弃了编译依赖,但是当代码变化之后,即便能够编译运行,逻辑仍然是错误的。
例如修改了模块定义时的 URL:
// 注册某个URL
[URLRouter registerURL:@"app://editorView" handler:^(NSDictionary *userInfo) {
...
}];
那么调用者的 URL 也必须修改,代码仍然是有耦合的,只不过此时编译器无法检查而已。这会导致维护更加困难,一旦 URL 中的参数有了增减,或者决定替换为另一个模块,参数命名有了变化,几乎没有高效的方式来重构代码。可以使用宏定义来管理字符串,不过这要求所有模块都使用同一个头文件,并且也无法解决参数类型和数量变化的问题。
URL 路由适合用来做远程模块的网络协议交互,而在管理本地模块时,最大的甚至是唯一的优势,就是适合经常跨多端运营活动的 app,因为可以由运营人员统一管理多平台的路由规则。
改进 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
为myWallet
,Action
为open
和getInfo
。同时还可以给参数设置类型。
+ (void)moduleDescription:(SCCModuleDescription *)description {
description
.name(@"myWallet")
.method(^(SCCModuleMethod * _Nonnull method) {
method
.name(@"open")
.selector(@selector(open:))
.parameters(^(SCCModuleParamEnumerator * _Nonnull enumerator) {
enumerator.next.type(SCCParamTypeString).name(@"code");
});
})
.method(^(SCCModuleMethod * _Nonnull method) {
method
.name(@"getInfo")
.selector(@selector(getWalletType:))
.parameters(^(SCCModuleParamEnumerator * _Nonnull enumerator) {
enumerator.next.type(SCCParamTypeBlock);
});
});
}
业务方调用的时候一种就是通过直接传URL,在路由层里面会对URL进行解析分解成Target_Action
,然后runtime
动态调用。一种就是直接调用Target
和Action
在路由层里面进行runtime
动态调用。
[[SCCModulor modulor] modulesURLString:@"ds://open/myWallet?code=111" performWithParams:nil callback:^(NSDictionary * _Nullable moduleInfo) {
//回调
}];
[[SCCModulor modulor] moduleName:@"myWallet" performSelectorName:@"open" withParams:@{@"code":@"111"} callback:^(NSDictionary * _Nullable moduleInfo) {
//回调
}];
在现有工程中实施基于CTMediator的组件化方案
打造完备的iOS组件化方案:如何面向接口进行模块解耦
iOS VIPER架构实践(三):面向接口的路由设计
蘑菇街
routable-ios