虽然 iOS 组件化与路由的话题在业界谈了好久,可是貌似不少人都对其有所误解,甚至没搞明白“组件”、“模块”、“路由”、“解耦”的含义。html
相关的博文也蛮多,其实除了那几个名家写的,具备参考价值的不多,何况名家的观点也并不是都彻底正确。架构每每须要权衡业务场景、学习成本、开发效率等,因此架构方案能客观解释却又带了些主观色彩,加上些我的特点的修饰就特别容易让人本末倒置。ios
因此要保持头脑清晰,以辩证的态度看待问题,如下是业界比较有参考价值的文章:
iOS应用架构谈 组件化方案
蘑菇街 App 的组件化之路
iOS 组件化 —— 路由设计思路分析
Category 特性在 iOS 组件化中的应用与管控
iOS 组件化方案探索git
本文主要是笔者对 iOS 组件化和路由的理解,力求以更客观与简洁的方式来解释各类方案的利弊,欢迎批评指正。github
本文的 DEMOweb
因此从你们实施“组件化”的目的来看,叫作“模块化”彷佛更为合理。安全
但“组件”与“模块”都是前人定义的意义,“iOS 组件化”的概念也已经先入为主,因此只须要明白“iOS 组件化”更多的是作业务模块之间的解耦就好了。bash
首先要明确的是,路由并不是只是指的界面跳转,还包括数据获取等几乎全部业务。服务器
效仿 web 路由,最初的 iOS 原生路由看起来是这样的:网络
[Mediator gotoURI:@"protocol://detail?name=xx"];
复制代码
缺点很明显:字符串 URI 并不能表征 iOS 系统原生类型,要阅读对应模块的使用文档,大量的硬编码。闭包
代码实现大概就是:
+ (void)gotoURI:(NSString *)URI {
解析 URI 获得目标和参数
NSString *aim = ...;
NSDictionary *parmas = ...;
if ([aim isEqualToString:@"Detail"]) {
DetailController *vc = [DetailController new];
vc.name = parmas[@"name"];
[... pushViewController:vc animated:YES];
} else if ([aim isEqualToString:@"list"]) {
...
}
}
复制代码
形象一点:
拿到 URI 事后,始终有转换为目标和参数 (aim/params
) 的逻辑,而后再真正的调用原生模块。显而易见,对于内部调用来讲,解析 URI 这一步就是多此一举 (casa 在博客中说过这个问题)。
路由方法简化以下:
+ (void)gotoDetailWithName:(NSString *)name {
DetailController *vc = [DetailController new];
vc.name = name;
[... pushViewController:vc animated:YES];
}
复制代码
使用起来就很简单了:
[Mediator gotoDetailWithName:@"xx"];
复制代码
如此,方法的参数列表便能替代额外的文档,而且通过编译器检查。
那么对于外部调用,只须要为它们添加 URI 解析的适配器就能解决问题:
统一路由调用类便于管理和使用,因此一般须要定义一个Mediator
类。又考虑到不一样模块的维护者都须要修改Mediator
来添加路由方法,可能存在工做流冲突。因此利用装饰模式,为每个模块添加一个分类是不错的实践:
@interface Mediator (Detail)
+ (void)gotoDetailWithName:(NSString *)name;
@end
复制代码
而后对应模块的路由方法就写到对应的分类中。
这里的封装,解除了业务模块之间的直接耦合,然而它们仍是间接耦合了(由于路由类须要导入具体业务):
不过,一个简单的路由不需关心耦合问题,就算是这样一个简单的处理也有以下好处:
动态调用,顾名思义就是调用路径在不更新 App 的状况下发生变化。好比点击 A 触发跳转到 B 界面,某一时刻又须要点击 A 跳转到 C 界面。
要保证最小粒度的动态调用,就须要目标业务的完整信息,好比上面说的aim
和params
,即目标和参数。
而后须要一套规则,这个规则有两个来源:
+ (void)gotoDetailWithName:(NSString *)name {
if (本地防御逻辑判断 DetailController 出现异常) {
跳转到 DetailOldController
return;
}
DetailController *vc = [DetailController new];
vc.name = name;
[... pushViewController:vc animated:YES];
}
复制代码
开发者须要明确的知道“某个业务”支持动态调用而且动态调用的目标是“某个业务”。也就是说,这是一种“伪”动态调用,代码逻辑是写死的,只是触发点是动态的而已。
试想,上面那种方法+ (void)gotoDetailWithName:(NSString *)name;
能支持自动的动态调用么?
答案是否认的,要实现真正的“自动化”,必需要知足一个条件:须要全部路由方法的一个切面。
这个切面的目的就是拦截路由目标和参数,而后作动态调度。一提到 AOP 你们可能会想到 Hook 技术,可是对于下面两个路由方法:
+ (void)gotoDetailWithName:(NSString *)name;
+ (void)pushOldDetail;
复制代码
你没法找到它们之间的相同点,难以命中。
因此,拿到一个切面的方法笔者能想到的只有一个:统一路由方法入口。
定义这样一个方法:
- (void)gotoAim:(NSString *)aim params:(NSDictionary *)params {
一、动态调用逻辑(经过服务器下发配置判断)
二、经过 aim 和 params 动态调用具体业务
}
复制代码
(关于如何动态调用具体业务的技术实现后文会讲,这里先不用管,只须要知道这里经过这两个参数就能动态定位到具体业务。)
而后,路由方法里面就这么写了:
+ (void)gotoDetailWithName:(NSString *)name {
[self gotoAim:@"detail" params:@{@"name":name}];
}
复制代码
注意@"detail"
是约定好的 Aim,内部能够动态定位到具体业务。
图解以下:
固然,外部调用能够不通过内部调用,那么就能够作到具体业务无感知的动态定位本地资源:
因而可知,统一路由方法入口必然须要硬编码,对于此方案来讲自动化的动态调用必然须要硬编码。
那么,这里使用一个分类方法+ (void)gotoDetailWithName:(NSString *)name;
将硬编码包装起来是个不错的选择,把这些 hard code 交给对应业务的工程师去维护吧。
Casa 的 CTMediator 分类就是如此作的,而这也正是蘑菇街组件化方案能够优化的地方。
前面笔者表达了内部调用不须要走 URI 的观点(请查看 图3 以及其演变)。
可能有些朋友以为内部调用只须要在 URI 那一套上再封装一层编译器可检查的语法糖(好比一个分类),就变成下图这个样子:
Q1:能够理解的是,这样处理看起来有一个能说服人的理由:全部的路由调用都统一通过了 URI 解析。那么,这个解析 URL 的方法就至关于一个拦截器了,彷佛能作到上面提到的动态调用?
A1:然而,这样只能支持预知的动态调用,也就是说,你须要明确某一个具体的业务,而后写上一些“死”代码,只能让触发点是动态的。那么这样的预知的动态调用代码都写在“内部调用(语法糖)”里面就能够了,经不通过统一的 URI 解析根本不重要了,这样的代码集中在一处与散落各地没有区别。
下面是解耦方式:
Q2:可能有人会说,“图3 - 演变 - URI解耦”作法,不须要导入具体的业务代码,不就实现了自动化的动态调用了?
A2:然而,这样作后不就至关于有两个拦截器了?解耦方式调用业务,自己就拥有了一个统一的入口了。因此,内部调用通过这个统一的 URI 解析方法拦截器就没有意义了。
Q3:能够又有人说,他只是想经过统一的 URI 解析拦截入口作一些事情,并非作自动化的动态调用。
A3:这也就是意味着,他不会在这个拦截入口中作对具体业务的彻底解耦,且拦截作的这些事情与具体业务无关(若与具体业务有关又回到了 Q1 的问题),那么就是“图3 - 演变 - URI”作法。这种场景彷佛能成为一个理由,好比记录全部路由调用却又不涉及具体业务模块?可是内部调用不通过 URI 解析也能作到:
不要说这样会产生硬编码,由于内部调用通过 URL 解析仍然有硬编码。
笔者的观点是:内部调用走 URI 方式是没必要要的。若是你非要这么作,笔者说一下缺点:
aim / params
时可能须要转换为原生参数(好比字符串转 NSData)的工做,那么内部调用 (须要将 NSData 转换为字符串) -> URI 解析 (再将字符串转换为 NSData) -> aim / pamras
,明显转换过程多余了。(casa 在博客中也大概说了一下这个问题)在软件开发中,“统一”彷佛成为了一个强迫症思惟,其实应该结合具体业务深刻场景,分析真正的意义才能更好的实施架构。
可能这部分表述有些抽象,若有疑问欢迎在文末留言(留言仍是在简书好一点,方便笔者回复,其它转发平台的回复不必定能及时看到)。
能够发现笔者用了大篇幅讲了路由,却未说起组件化,那是由于有路由不必定须要组件化。
路由的设计主要是考虑需不须要作全链路的自动化动态调用,列举几个场景:
能够发现,真正的全链路动态调用成本是很是高的。
前面对路由的分析提到了使用目标和参数 (aim/params
) 动态定位到具体业务的技术点。实际上在 iOS Objective-C 中大概有反射和依赖注入两种思路:
aim
转化为具体的Class
和SEL
,利用 runtime 运行时调用到具体业务。aim
映射到一段代码,调用时执行具体业务。能够明确的是,这两种方式都已经让Mediator
免去了对业务模块的依赖:
而这些解耦技术,正是 iOS 组件化的核心。
组件化主要目的是为了让各个业务模块独立运行,互不干扰,那么业务模块之间的彻底解耦是必然的,同时对于业务模块的拆分也很是考究,更应该追求功能独立而不是最小粒度。
为 Mediator 定义了一个统一入口方法:
/// 此方法就是一个拦截器,可作容错以及动态调度
- (id)performTarget:(NSString *)target action:(NSString *)action params:(NSDictionary *)params {
Class cls; id obj; SEL sel;
cls = NSClassFromString(target);
if (!cls) goto fail;
sel = NSSelectorFromString(action);
if (!sel) goto fail;
obj = [cls new];
if (![obj respondsToSelector:sel]) goto fail;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
return [obj performSelector:sel withObject:params];
#pragma clang diagnostic pop
fail:
NSLog(@"找不到目标,写容错逻辑");
return nil;
}
复制代码
简单写了下代码,原理很简单,可用 Demo 测试。对于内部调用,为每个模块写一个分类:
@implementation BMediator (BAim)
- (void)gotoBAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack {
[self performTarget:@"BTarget" action:@"gotoBAimController:" params:@{@"name":name, @"callBack":callBack}];
}
@end
复制代码
能够看到这里是给BTarget
发送消息:
@interface BTarget : NSObject
- (void)gotoBAimController:(NSDictionary *)params;
@end
@implementation BTarget
- (void)gotoBAimController:(NSDictionary *)params {
BAimController *vc = [BAimController new];
vc.name = params[@"name"];
vc.callBack = params[@"callBack"];
[UIViewController.yb_top.navigationController pushViewController:vc animated:YES];
}
@end
复制代码
定义分类的目的前面也说了,至关于一个语法糖,让调用者轻松使用,让 hard code 交给对应的业务工程师。
可能有些人对这些类的管理存在疑虑,下图就表示它们的关系(一个块表示一个 repo):
图中“注意”处箭头,B 模块是否须要引入它本身的分类 repo,取决因而否须要作全部界面跳转的拦截,若是须要那么 B 模块仍然要引入本身的 repo 使用。
完整的方案和代码能够查看 Casa 的 CTMediator,设计得比较完备,笔者没挑出什么毛病。
下面简单实现了两个方法:
- (void)registerKey:(NSString *)key block:(nonnull id _Nullable (^)(NSDictionary * _Nullable))block {
if (!key || !block) return;
self.map[key] = block;
}
/// 此方法就是一个拦截器,可作容错以及动态调度
- (id)excuteBlockWithKey:(NSString *)key params:(NSDictionary *)params {
if (!key) return nil;
id(^block)(NSDictionary *) = self.map[key];
if (!block) return nil;
return block(params);
}
复制代码
维护一个全局的字典 (Key -> Block),只须要保证闭包的注册在业务代码跑起来以前,很容易想到在+load
中写:
@implementation DRegister
+ (void)load {
[DMediator.share registerKey:@"gotoDAimKey" block:^id _Nullable(NSDictionary * _Nullable params) {
DAimController *vc = [DAimController new];
vc.name = params[@"name"];
vc.callBack = params[@"callBack"];
[UIViewController.yb_top.navigationController pushViewController:vc animated:YES];
return nil;
}];
}
@end
复制代码
至于为何要使用一个单独的DRegister
类,和前面“Runtime 解耦”为何要定义一个Target
是一个道理。一样的,使用一个分类来简化内部调用(这是蘑菇街方案能够优化的地方):
@implementation DMediator (DAim)
- (void)gotoDAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack {
[self excuteBlockWithKey:@"gotoDAimKey" params:@{@"name":name, @"callBack":callBack}];
}
@end
复制代码
能够看到,Block 方案和 Runtime 方案 repo 架构上能够基本一致(见图6),只是 Block 多了注册这一步。
为了灵活性,Demo 中让 Key -> Block,这就让 Block 里面要写不少代码,若是缩小范围将 Key -> UIViewController.class 能够减小注册的代码量,但这样又难以覆盖全部场景。
注册所产生的内存占用并非负担,主要是大量的注册可能会明显拖慢启动速度。
这种方式仍然要注册,使用一个全局的字典 (Protocol -> Class) 存储起来。
- (void)registerService:(Protocol *)service class:(Class)cls {
if (!service || !cls) return;
self.map[NSStringFromProtocol(service)] = cls;
}
- (id)getObject:(Protocol *)service {
if (!service) return nil;
Class cls = self.map[NSStringFromProtocol(service)];
id obj = [cls new];
if ([obj conformsToProtocol:service]) {
return obj;
}
return nil;
}
复制代码
定义一个协议服务:
@protocol CAimService <NSObject>
- (void)gotoCAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack;
@end
复制代码
用一个类实现协议而且注册协议:
@implementation CAimServiceProvider
+ (void)load {
[CMediator.share registerService:@protocol(CAimService) class:self];
}
#pragma mark - <CAimService>
- (void)gotoCAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack {
CAimController *vc = [CAimController new];
vc.name = name;
vc.callBack = callBack;
[UIViewController.yb_top.navigationController pushViewController:vc animated:YES];
}
@end
复制代码
至于为何要使用一个单独的ServiceProvider
类,和前面“Runtime 解耦”为何要定义一个Target
是一个道理。
使用起来很优雅:
id<CAimService> service = [CMediator.share getObject:@protocol(CAimService)];
[service gotoCAimControllerWithName:@"From C" callBack:^{
NSLog(@"CAim CallBack");
}];
复制代码
看起来这种方案不须要硬编码很舒服,可是它有个致命的问题 ——— 没法拦截全部路由方法。
这也就意味着这种方案作不了自动化动态调用。
阿里的 BeeHive 是目前的最佳实践。注册部分它能够将待注册的类字符串写入 Data 段,而后在 Image 加载的时候读取出来注册。这个操做只是将注册的执行放到了+load
方法以前,仍然会拖慢启动速度,因此这个优化笔者没有看到价值。
想象一下,解耦意味着调用方只有系统原生的标识,如何定位到目标业务? 必然有个映射。 而 runtime 能够直接调用目标业务,其它两种方式只有创建映射表。 固然 Protocol 方式也能够不创建映射表,直接遍历全部类,找出遵循这个协议的类也能找到,不过明显这样是低效且不安全的。
对于不少项目来讲,并不是一开始就须要实施组件化,为了不在未来业务稳定须要实施的时候一筹莫展,在项目之初最好有一些前瞻性的设计,同时编码过程当中也要尽可能下降各个业务模块的耦合。
在设计路由时,尽可能下降未来组件化时的迁移成本,因此理解各类方案的实施条件很重要。若是项目未来几乎不可能作自动化动态路由,那么使用 Protocol -> Class 方案就能去除硬编码;不然,仍是使用 Runtime 或者 Key -> Block 方案,二者都有不一样程度的硬编码但 Runtime 不须要注册。
设计一个方案时,最好的方式是穷举全部方案,分别找出优点和劣势,而后根据业务需求,进行权衡和取舍。可能有的时候业界的方案并不彻底适合本身的项目,这个时候就须要作一些创造性的改进。
不要总说“就应该是这样”,而多想“为何要这样”。