前言

虽然 iOS 组件化与路由的话题在业界谈了很久,但是貌似很多人都对其有所误解,甚至没搞明白“组件”、“模块”、“路由”、“解耦”的含义。

相关的博文也蛮多,其实除了那几个名家写的,具有参考价值的很少,况且名家的观点也并非都完全正确。架构往往需要权衡业务场景、学习成本、开发效率等,所以架构方案能客观解释却又带了些主观色彩,加上些个人特色的修饰就特别容易让人本末倒置。

所以要保持头脑清晰,以辩证的态度看待问题,以下是业界比较有参考价值的文章:
iOS应用架构谈 组件化方案
蘑菇街 App 的组件化之路
iOS 组件化 —— 路由设计思路分析
Category 特性在 iOS 组件化中的应用与管控
iOS 组件化方案探索

本文主要是笔者对 iOS 组件化和路由的理解,力求以更客观与简洁的方式来解释各种方案的利弊,欢迎批评指正。

本文的 DEMO

一、组件与模块的区别

image.png

“组件”强调的是复用,它被各个模块或组件直接依赖,是基础设施,它一般不包含业务或者包含弱业务,属于纵向分层(比如网络请求组件、图片下载组件)。

“模块”强调的是封装,它更多的是指功能独立的业务模块,属于横向分层(比如购物车模块、个人中心模块)。

所以从大家实施“组件化”的目的来看,叫做“模块化”似乎更为合理。

但“组件”与“模块”都是前人定义的意义,“iOS 组件化”的概念也已经先入为主,所以只需要明白“iOS 组件化”更多的是做业务模块之间的解耦就行了。

二、路由的意义

首先要明确的是,路由并非只是指的界面跳转,还包括数据获取等几乎所有业务。

(一) 简单的路由

内部调用的方式

效仿 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”]) { … }}
    形象一点:

image.png

拿到 URI 过后,始终有转换为目标和参数 (aim/params) 的逻辑,然后再真正的调用原生模块。显而易见,对于内部调用来说,解析 URI 这一步就是画蛇添足 (casa 在博客中说过这个问题)。

路由方法简化如下:

  • (void)gotoDetailWithName:(NSString *)name { DetailController *vc = [DetailController new]; vc.name = name; [… pushViewController:vc animated:YES];}
    使用起来就很简单了:

[Mediator gotoDetailWithName:@“xx”];
如此,方法的参数列表便能替代额外的文档,并且经过编译器检查。

如何支持外部 URI 方式调用

那么对于外部调用,只需要为它们添加 URI 解析的适配器就能解决问题:

image.png

路由方法写在哪儿

统一路由调用类便于管理和使用,所以通常需要定义一个Mediator类。又考虑到不同模块的维护者都需要修改Mediator来添加路由方法,可能存在工作流冲突。所以利用装饰模式,为每一个模块添加一个分类是不错的实践:

@interface Mediator (Detail)+ (void)gotoDetailWithName:(NSString *)name;@end
然后对应模块的路由方法就写到对应的分类中。

简单路由的作用

这里的封装,解除了业务模块之间的直接耦合,然而它们还是间接耦合了(因为路由类需要导入具体业务):

image.png

不过,一个简单的路由不需关心耦合问题,就算是这样一个简单的处理也有如下好处:

清晰的参数列表,方便调用者使用。

解开业务模块之间的耦合,业务更改时或许接口不需变动,外部调用就不用更改代码。

就算是业务更改,路由方法必须得变动,得益于编译器的检查,也能直接定位调用位置进行更改。

(二) 支持动态调用的路由

动态调用,顾名思义就是调用路径在不更新 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 { 1、动态调用逻辑(通过服务器下发配置判断) 2、通过 aim 和 params 动态调用具体业务}
    (关于如何动态调用具体业务的技术实现后文会讲,这里先不用管,只需要知道这里通过这两个参数就能动态定位到具体业务。)

然后,路由方法里面就这么写了:

  • (void)gotoDetailWithName:(NSString *)name { [self gotoAim:@“detail” params:@{@“name”:name}];}
    注意@”detail”是约定好的 Aim,内部可以动态定位到具体业务。

由此可见,统一路由方法入口必然需要硬编码,对于此方案来说自动化的动态调用必然需要硬编码。

那么,这里使用一个分类方法+ (void)gotoDetailWithName:(NSString *)name;将硬编码包装起来是个不错的选择,把这些 hard code 交给对应业务的工程师去维护吧。

Casa 的 CTMediator 分类就是如此做的,而这也正是蘑菇街组件化方案可以优化的地方。

路由总结

可以发现笔者用了大篇幅讲了路由,却未提及组件化,那是因为有路由不一定需要组件化。

路由的设计主要是考虑需不需要做全链路的自动化动态调用,列举几个场景:

原生页面出现问题,需要切换到对应的 wap 页面。

wap 访问流量过大切换到原生页面降低消耗。

可以发现,真正的全链路动态调用成本是非常高的。

三、组件化的意义

前面对路由的分析提到了使用目标和参数 (aim/params) 动态定位到具体业务的技术点。实际上在 iOS Objective-C 中大概有反射和依赖注入两种思路:

将aim转化为具体的Class和SEL,利用 runtime 运行时调用到具体业务。

对于代码来说,进程空间是共享的,所以维护一个全局的映射表,提前将aim映射到一段代码,调用时执行具体业务。

可以明确的是,这两种方式都已经让Mediator免去了对业务模块的依赖:

image.png

而这些解耦技术,正是 iOS 组件化的核心。

组件化主要目的是为了让各个业务模块独立运行,互不干扰,那么业务模块之间的完全解耦是必然的,同时对于业务模块的拆分也非常考究,更应该追求功能独立而不是最小粒度。

(一) Runtime 解耦

为 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 popfail: 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 交给对应的业务工程师。

为什么要定义 Target “靶子”

避免同一模块路由逻辑散落各地,便于管理。

路由并非只有控制器跳转,某些业务可能无法放代码(比如网络请求就需要额外创建类来接受路由调用)。

便于方案的接入和摒弃(灵活性)。

可能有些人对这些类的管理存在疑虑,下图就表示它们的关系(一个块表示一个 repo):

image.png

图中“注意”处箭头,B 模块是否需要引入它自己的分类 repo,取决于是否需要做所有界面跳转的拦截,如果需要那么 B 模块仍然要引入自己的 repo 使用。

完整的方案和代码可以查看 Casa 的 CTMediator,设计得比较完备,笔者没挑出什么毛病。

(二) Block 解耦

下面简单实现了两个方法:

  • (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 解耦

这种方式仍然要注册,使用一个全局的字典 (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 - (void)gotoCAimControllerWithName:(NSString *)name callBack:(void (^)(void))callBack;@end
用一个类实现协议并且注册协议:

@implementation CAimServiceProvider+ (void)load { [CMediator.share registerService:@protocol(CAimService) class:self];}#pragma mark - - (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 service = [CMediator.share getObject:@protocol(CAimService)];[service gotoCAimControllerWithName:@“From C” callBack:^{ NSLog(@“CAim CallBack”);}];
看起来这种方案不需要硬编码很舒服,但是它有个致命的问题 ——— 无法拦截所有路由方法。

这也就意味着这种方案做不了自动化动态调用。

阿里的 BeeHive 是目前的最佳实践。注册部分它可以将待注册的类字符串写入 Data 段,然后在 Image 加载的时候读取出来注册。这个操作只是将注册的执行放到了+load方法之前,仍然会拖慢启动速度,所以这个优化笔者没有看到价值。

为什么 Protocol -> Class 和 Key -> Block 需要注册?

想象一下,解耦意味着调用方只有系统原生的标识,如何定位到目标业务?
必然有个映射。
而 runtime 可以直接调用目标业务,其它两种方式只有建立映射表。
当然 Protocol 方式也可以不建立映射表,直接遍历所有类,找出遵循这个协议的类也能找到,不过明显这样是低效且不安全的。

组件化总结

对于很多项目来说,并非一开始就需要实施组件化,为了避免在将来业务稳定需要实施的时候束手无策,在项目之初最好有一些前瞻性的设计,同时编码过程中也要尽量降低各个业务模块的耦合。

在设计路由时,尽量降低将来组件化时的迁移成本,所以理解各种方案的实施条件很重要。如果项目将来几乎不可能做自动化动态路由,那么使用 Protocol -> Class 方案就能去除硬编码;否则,还是使用 Runtime 或者 Key -> Block 方案,两者都有不同程度的硬编码但 Runtime 不需要注册。

后语

设计一个方案时,最好的方式是穷举所有方案,分别找出优势和劣势,然后根据业务需求,进行权衡和取舍。可能有的时候业界的方案并不完全适合自己的项目,这个时候就需要做一些创造性的改进。

不要总说“就应该是这样”,而多想“为什么要这样”。

作者:indulge_in

解读 iOS(马甲包) 组件化与路由的本质相关推荐

  1. 解读 iOS 组件化与路由的本质

    前言 虽然 iOS 组件化与路由的话题在业界谈了很久,但是貌似很多人都对其有所误解,甚至没搞明白"组件"."模块"."路由"."解 ...

  2. iOS-解读 iOS 组件化与路由的本质

    前言 虽然 iOS 组件化与路由的话题在业界谈了很久,但是貌似很多人都对其有所误解,甚至没搞明白"组件"."模块"."路由"."解 ...

  3. iOS 组件化与路由的本质

    前言 虽然 iOS 组件化与路由的话题在业界谈了很久,但是貌似很多人都对其有所误解,甚至没搞明白"组件"."模块"."路由"."解 ...

  4. iOS爱康APP组件化架构

    iOS爱康APP组件化架构 随着公司业务需求的不断增加以及快速产出,要对应用的架构做相关的设计和优化,使可以快速复用扩展.减少耦合.减少开发时间成本.减少测试成本等.基础框架架构就是为解决这些问题所设 ...

  5. 【Android 组件化】路由组件 ( 注解处理器参数选项设置 )

    文章目录 一.注解处理器 接收参数设置 二.注解处理器 生成路由表 Java 代码 三.博客资源 组件化系列博客 : [Android 组件化]从模块化到组件化 [Android 组件化]使用 Gra ...

  6. 【Android 组件化】路由组件 ( 注解处理器获取被注解的节点 )

    文章目录 一.设置支持的注解类型 二.注解处理器中打印日志 三.主应用中使用注解 四.注解处理器 获取注解节点 五.博客资源 组件化系列博客 : [Android 组件化]从模块化到组件化 [Andr ...

  7. 【Android 组件化】路由组件 ( 注解处理器中使用 JavaPoet 生成代码 )

    文章目录 一.注解节点类型 二.JavaPoet 简介 三.注解处理器中使用 JavaPoet 生成代码 四.路由框架说明 五.博客资源 组件化系列博客 : [Android 组件化]从模块化到组件化 ...

  8. 【Android 组件化】路由组件 ( 页面跳转参数依赖注入 )

    文章目录 一.参数自动注入 二.自定义注解 三.使用 @Extra 自定义注解 四.注解处理器解析 @Extra 自定义注解 并生成相应 Activity 对应代码 五.博客资源 组件化系列博客 : ...

  9. 【Android 组件化】路由组件 ( 路由框架概述 )

    文章目录 一.路由框架概述 二.路由框架整体流程 三.博客资源 组件化系列博客 : [Android 组件化]从模块化到组件化 [Android 组件化]使用 Gradle 实现组件化 ( Gradl ...

最新文章

  1. python3 编写守护进程程序思路
  2. 文本省略并显示省略号
  3. Kaggle知识点:数据分布不一致的验证
  4. 数据科学家访谈录 百度网盘_您应该在数据科学访谈中向THEM提问。
  5. python 列表中dict中key排序
  6. 线性代数:矩阵乘向量的特性学习笔记
  7. python全网表情包_Python爬虫爬取最右公众号表情包资源
  8. make it a chorus笔记
  9. 过来人的亲身经验告诉你,如何从菜鸟晋升月薪过万的测试工程师
  10. 接收流信息---字符串
  11. 厨师服识别yolov5明厨亮灶
  12. 飞机大战,坦克大战源码、简单仿记事本、错题本源码及笔记
  13. ANSYS下载安装+使用学习过程
  14. 微信支付服务商接入指引
  15. 【使用 BERT 的问答系统】第 1 章 : 自然语言处理简介
  16. 测试开发工程师成长日记011 - linux常用命令day03
  17. 第六章 Cesium学习入门之添加Geojson数据(dataSource)
  18. initial-scale
  19. SAP中国区总裁萧洁云:我加盟SAP的三个原因
  20. BSN开放联盟链巡礼——文昌链的技术、架构、应用介绍

热门文章

  1. 【雷达通信】基于Matlab GUI中频PD雷达仿真系统【含Matlab源码 1055期】
  2. 构造原理中的独立性条件如果不满足,是否原结论仍然成立?试用模拟的方法验证你的结论。
  3. 文字开头隐藏css,浅析CSS隐藏页面文字的几种方式总结
  4. word绘制柱状图显示少一列数据-解决办法
  5. Fescar 源码解析系列
  6. Python和Java结合的项目实战
  7. LAMP架构搭建网站商城
  8. UIP协议栈笔记·二
  9. RSA算法理解与实现
  10. php让视频自动全屏播放,完美解码怎么设置打开视频文件就全屏