27

苹果开发中文网站解读 iOS 组件化与路由的本质

 4 years ago
source link: http://www.cocoachina.com/ios/20190531/27025.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

suiling· 2019-05-31
本文来自 indulge_in ,作者 suiling

前言

虽然 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 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 交给对应的业务工程师。

为什么要定义 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
链接:https://www.jianshu.com/p/40060fa2a564


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK