iOS进阶之路iOS开发攻城狮的集散地iOS面试知识点

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

2019-05-30  本文已影响269人  波儿菜

前言

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

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

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

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

本文的 DEMO

一、组件与模块的区别

图1

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

但“组件”与“模块”都是前人定义的意义,“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"]) {
        ...
    }
}

形象一点:


图2

拿到 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 解析的适配器就能解决问题:


图3

路由方法写在哪儿

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

@interface Mediator (Detail)
+ (void)gotoDetailWithName:(NSString *)name;
@end

然后对应模块的路由方法就写到对应的分类中。

简单路由的作用

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


图4

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

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

动态调用,顾名思义就是调用路径在不更新 App 的情况下发生变化。比如点击 A 触发跳转到 B 界面,某一时刻又需要点击 A 跳转到 C 界面。

要保证最小粒度的动态调用,就需要目标业务的完整信息,比如上面说的aimparams,即目标和参数。

然后需要一套规则,这个规则有两个来源:

预知的动态调用

+ (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 分类就是如此做的,而这也正是蘑菇街组件化方案可以优化的地方。

路由总结

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

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

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

三、组件化的意义

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

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

图5

而这些解耦技术,正是 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):


图6

图中“注意”处箭头,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 <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方法之前,仍然会拖慢启动速度,所以这个优化笔者没有看到价值。

组件化总结

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

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

后语

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

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

上一篇 下一篇

猜你喜欢

热点阅读