您的位置:首页 > 运维架构 > 网站架构

iOS VIPER架构实践(三):面向接口的路由设计

2018-01-18 14:18 651 查看


路由是实现模块间解耦的一个有效工具。如果要进行组件化开发,路由是必不可少的一部分。目前iOS上绝大部分的路由工具都是基于URL匹配的,优缺点都很明显。这篇文章里将会给出一个更加原生和安全的设计,这个设计的特点是:
路由时用protocol寻找模块
可以对模块进行固定的依赖注入和运行时依赖注入
支持不同模块间进行接口适配和转发,因此无需和某个固定的protocol关联
充分解耦的同时,增加类型安全
支持移除已执行的路由
封装UIKit界面跳转方法,可以一键跳转和移除
支持storyboard,支持其他任意模块
可以检测界面跳转时的大部分错误

如果你想要一个能够充分解耦、类型安全、有依赖注入功能的路由器,那这个就是目前所能找到的最佳方案。

这个路由工具是为了实践VIPER模式而设计的,目的是为VIPER提供依赖注入功能,不过它也可以用于MVC、MVP、MVVM,没有任何限制。

工具和Demo地址:ZIKRouter


Router的作用

首先,我们需要梳理清楚,为什么我们需要Router,Router能带来什么好处,解决什么问题?我们需要一个什么样的Router?


路由缺失时的情况

没有路由时,界面跳转的代码就很容易产生模块间耦合。

iOS中执行界面跳转时,用的是UIViewController上提供的跳转方法:
[sourceViewController.navigationController pushViewController:destinationViewController animated:YES];

[sourceViewController presentViewController:destinationViewController animated:YES completion:nil];


如果是直接导入destinationViewController的头文件进行引用,就会导致和destinationViewController模块产生耦合。类似的,一个模块引用另一个模块时也会产生这样的耦合。因此我们需要一个方式来获取destinationViewController,但又不能对其产生直接引用。

这时候就需要路由提供的"寻找模块"的功能。以某种动态的方式获取目的模块。

那么路由是怎么解决模块耦合的呢?在上一篇VIPER讲解里,路由有这几个主要职责:
寻找指定模块,执行具体的路由操作
声明模块的依赖
声明模块的对外接口
对模块内各部分进行依赖注入

通过这几个功能,就能实现模块间的完全解耦。


寻找模块

路由最重要的功能就是给出一种寻找某个指定模块的方案。这个方案是松耦合的,获取到的模块在另一端可以随时被另一个相同功能的模块替换,从而实现两个模块之间的解耦。

寻找模块的实现方式其实只有有限的几种:
用一个字符串identifier来标识某个对应的界面(URL Router、UIStoryboardSegue)
利用Objective-C的runtime特性,直接调用目的模块的方法(CTMediator)
用一个protocol来和某个界面进行匹配(蘑菇街的第二种路由和阿里的BeeHive),这样就可以更安全的对目的模块进行传参

这几种方案的优劣将在之后逐一细说。


声明依赖和接口

一个模块A有时候需要使用其他模块的功能,例如最通用的log功能,不同的app有不同的log模块,如果模块A对通用性要求很高,log方法就不能在模块A里写死,而是应该通过外部调用。这时这个模块A就依赖于一个log模块了。App在使用模块A的时候,需要知道它的依赖,从而在使用模块A之前,对其注入依赖。

当通过cocoapods这样的包管理工具来配置不同模块间的依赖时,一般模块之间是强耦合的,模块是一一对应的,当需要替换一个模块时会很麻烦,容易牵一发而动全身。如果是一个单一功能模块,的确需要依赖其他特定的各种库时,那这样做没有问题。但是如果是一个业务模块中引用了另一个业务模块,就应该尽量避免互相耦合。因为不同的业务模块一般是由不同的人负责,应该避免出现一个业务模块的简单修改(例如调整了方法或者属性的名字)导致引用了它的业务模块也必须修改的情况。

这时候,业务模块就需要在代码里声明自己需要依赖的模块,让app在使用时提供这些模块,从而充分解耦。

示例代码:
@protocol ZIKLoginServiceInput <NSObject>
- (void)loginWithAccount:(NSString *)account
password:(NSString *)password
success:(void(^_Nullable)(void))successHandler
error:(void(^_Nullable)(void))errorHandler;
@end

@interface ZIKNoteListViewController ()
//笔记界面需要登录后才能查看,因此在头文件中声明,让外部在使用的时候设置此属性
@property (nonatomic, strong) id<ZIKLoginServiceInput> loginService;
@end


这个声明依赖的工作其实是模块的Builder的职责。一个界面模块大部分情况下都不止有一个UIViewController,也有其他一些Manager或者Service,而这些角色都是有各自的依赖的,都统一由模块的Builder声明,再在Builder内部设置依赖。不过在上一篇文章的VIPER讲解里,我们把Builder的职责也放到了Router里,让每个模块单独提供一个自己的Router。因此在这里,Router是一个离散的设计,而不是一个单例Router掌管所有的路由。这样的好处就是每个模块可以充分定制和控制自己的路由过程。

可以声明依赖,也就可以同时声明模块的对外接口。这两者很相似,所以不再重复说明。


Builder和依赖注入

执行路由的同时用Builder进行模块构建,构建的时候就对模块内各个角色进行依赖注入。当你调用某个模块的时候,需要的不是某个简单的具体类,而是一个构建完毕的模块中的某个具体类。在使用这个模块前,模块需要做一些初始化的操作,比如VIPER里设置各个角色之间的依赖关系,就是一个初始化操作。因此使用路由去获取某个模块中的类,必定需要通过模块的Builder进行。很多路由工具都缺失了这部分功能。

你可以把依赖注入简单地看成对目的模块传参。在进行界面跳转和使用某个模块时,经常需要设置目的模块的一些参数,例如设置delegate回调。这时候就必须调用一些目的模块的方法,或者传递一些对象。由于每个模块需要的参数都不一样,目前大部分Router都是使用字典包裹参数进行传递。但其实还有更好、更安全的方案,下面将会进行详解。

你也可以把Router、Builder和Dependency Injector分开,不过如果Router是一个离散型的设计,那么都交给各自的Router去做也很合理,同时能够减少代码量,也能够提供细粒度的AOP。


现有的Router

梳理完了路由的职责,现在来比较一下现有的各种Router方案。关于各个方案的具体实现细节我就不再展开看,可以参考这篇详解的文章:iOS 组件化 —— 路由设计思路分析


URL Router

目前绝大多数的Router都是用一串URL来表示需要打开的某个界面,代码上看来大概是这样:
//注册某个URL,和路由处理进行匹配保存
[URLRouter registerURL:@"settings" handler:^(NSDictionary *userInfo) {
UIViewController *sourceViewController = userInfo[@"sourceViewController"];
//获取其他参数
id param = userInfo[@"param"];
//获取需要的界面
UIViewController *settingViewController = [[SettingViewController alloc] init];
[sourceViewController.navigationController pushViewController: settingViewController animated:YES];
}];

//调用路由
[URLRouter openURL:@"myapp://noteList/settings?debug=true" userInfo:params completion:^(NSDictionary *info) {

}];


传递一串URL就能打开noteList界面的settings界面,用字典包裹需要传递的参数,有时候还会把UIKit的push、present等方法进行简单封装,提供给调用者。

这种方式的优点和缺点都很突出。


优点


极高的动态性


这是动态性最高的方案,甚至可以在运行时随时修改路由规则,指向不同的界面。也可以很轻松地支持多级页面的跳转。

如果你的app是电商类app,需要经常做活动,app内的跳转规则经常变动,那么就很适合使用URL的方案。

统一多端路由规则


URL的方案是最容易跨平台实现的,iOS、Andorid、web、PC都按照URL来进行路由时,也就可以统一管理多端的路由规则,降低多端各自维护和修改的成本,让不懂技术的运营人员也可以简单快速地修改路由。

和上一条一样,这也是一个和业务强相关的优点。如果你有统一多端的业务需求,使用URL也很合适。

适配URL scheme


iOS中的URL scheme可以跨进程通信,从app外打开app内的某个指定页面。当app内的页面都能使用URL打开时,也就直接兼容了URL scheme,无需再做额外的工作。


缺点


不适合通用模块


URL Router的设计只适合UI模块,不适合其他功能性模块的组件。功能性模块的调用并不需要如此强的动态特性,除非是有模块热更新的需求,否则一个模块的调用在一个版本里应该总是稳定不变的,即便要进行模块间解耦,也不应该用这种方式。

安全性差


字符串匹配的方式无法进行编译时检查,当页面配置出错时,只能在运行时才能发现。如果某个开发人员不小心在字符串里加了一个空格,编译时也无法发现。你可以用宏定义来减少这种出错的几率。

维护困难


没有高效地声明接口的方式,只能从文档里查找,编写时必须仔细对照字符串及其参数类型。

传参通过字典来进行,参数类型无法保证,而且也无法准确地知道所调用的接口需要哪些参数。当目的模块进行了接口升级,修改了参数类型和数量,那所有用到的地方都要一一修改,并且没有编译器的帮助,你无法知道是否遗漏了某些地方。这将会给维护和重构带来极大的成本。

针对这个问题,蘑菇街的选择是用另一个Router,用protocol来获取目的模块,再进行调用,增加安全性。


Protocol Router

这个方案也很容易理解。把之前的字符串匹配改成了protocol匹配,就能获取到一个实现了某个protocol的对象。

开源方案里只看到了BeeHive实现了这样的方式:
id<ZIKLoginServiceInput> loginService = [[BeeHive shareInstance] createService:@protocol(ZIKLoginServiceInput)];


优点


安全性好,维护简单


再对这个对象调用protocol中的方法,就十分安全了。在重构和修改时,有了编译器的类型检查,效率更高。

适用于所有模块


Protocol更加符合OC和Swift原生的设计思想,任何模块都可以使用,而不局限于UI模块。

优雅地声明依赖


模块A需要用到登录模块,但是它要怎么才能声明这种依赖关系呢?如果使用Protocol Router,那就只需要在头文件里定义一个属性:
@property (nonatomic, string) id<ZIKLoginServiceInput> *loginService;


如果这个依赖是必需依赖,而不是一个可选依赖,那就添加到初始化参数里:
@interface ModuleA ()
- (instancetype)initWithLoginService:(id<ZIKLoginServiceInput>)loginService;
@end


问题是,如果这样的依赖很多,那么初始化方法就会变得很长。因此更好的做法是由Builder进行固定的依赖注入,再提供给外部。目前BeeHive并没有提供依赖注入的功能。


缺点


动态性有限


你可以维护一份protocol和模块的对照表,使用动态的protocol来尝试动态地更改路由规则,也可以在Protocol Router之上封装一层URL Router专门用于动态性的需求。

需要额外适配URL Scheme


使用了Protocol Router就需要再额外处理URL Scheme了。不过这样也是正常的,解析URL Scheme本来就应该放到另一个单独的模块里。


Protocol是否会导致耦合?

很多谈到这种方案的文章都会指出,和URL Router相比,Protocol Router会导致调用者引用目的模块的protocol,因此会产生"耦合"。我认为这是对"解耦"的错误理解。

要想避免耦合,首先要弄清楚,我们需要什么程度的解耦。我的定义是:模块A调用了模块B,模块B的接口或者实现在做出简单的修改时,或者模块B被替换为相同功能的模块C时,模块A不需要进行任何修改。这时候就可以认为模块A和模块B是解耦的。

业务设计的互相关联


有些时候,表达出两个模块之间的关联是有意义的。

当一个界面A需要展示一个登录界面时,它可能需要向登录界面传递一个"提示语"参数,用于在登录界面显示一串提示。这时候,界面A在调用登录界面时,是要求登录界面能够显示这个自定义提示语的,在业务设计中就存在两个模块间的强关联性。这时候,URL Router和Protocol Router没有任何区别,包括下面将要提到的
Target-Action
路由方式,都存在耦合,但是Protocol
Router通过简单地改善,是可以把这部分耦合去除的。

URL Router:
[URLRouter openURL:@"login" userInfo:@{@"message":@"请登录查看笔记详情"}];


Protocol Router:
@protocol LoginViewInput <NSObject>
@property (nonatomic, copy) NSString *message;
@end

//获取登录界面进行设置
UIViewController<LoginViewInput> *loginViewController = [ProtocolRouter destinationForProtocol:@protocol(LoginViewInput)];
loginViewController.message = @"请登录查看笔记详情";


由于字典传参的原因,URL Router只不过是把这种接口上的关联隐藏到了字典key里,它在参数字典里使用
@"message"
时,就是在隐式地使用
LoginViewInput
的接口。

这种业务设计上导致的模块之间互相关联是不可避免的,也是不需要去隐藏的。隐藏了反而会引来麻烦。如果登录界面的属性名字变了,从
NSString *message
改成了
NSString
*notifyString
,那么URL Router在register的时候也必须修改传参时的代码。如果register是由登录界面自己执行和处理的,而不是由App Context来处理的,那么此时参数key是固定为
@"notifyString"
的,那就会要求所有调用者的传参key也修改为
notifyString
,这种修改如果缺少编译器的帮助会很危险,目前是用宏来减少这种修改导致的工作量。而Protocol
Router在修改时就能充分利用编译器进行检查,能够保证100%安全。

因此,URL Router并不能做到解耦,只是隐藏了接口关联而已。一旦遇到了需要修改或者重构的情况,麻烦就出现了,在替换宏的时候,你还必须仔细检查有没有哪里有直接使用字符串的key。只是简单地修改名字还是可控的,如果是需要增加参数呢?这时候就根本无法检查哪里遗漏了参数传递了。这就是字典传参的坏处。

关于这部分的讨论,也可以参考Peak大佬的文章:iOS组件化方案

Protocol Router在这种情况下也需要作出修改,但是它能帮助你安全高效地进行重构。而且只要稍加改进,也可以完全无需修改。解决方法就是把Protocol分离为
Required
Interface
Provided Interface


Required Interface
 和 
Provided
Interface


模块的接口其实是有
Required Interface
Provided
Interface
的区别的。
Required Interface
就是调用者需要用到的接口,
Provided
Interface
就是实际的被调用者提供的接口。

在UML的组件图中,就很明确地表现出了这两者的概念。下图中的半圆就是
Required
Interface
,框外的圆圈就是
Provided Interface




那么如何实施
Required Interface
Provided
Interface
?上一篇文章里已经讨论过,应该由App Context在一个adapter里进行接口适配,从而使得调用者可以继续在内部使用
Required
Interface
,adapter负责把
Required Interface
和修改后的
Provided
Interface
进行适配。

示例代码:
@protocol ModuleARequiredLoginViewInput <NSObject>
@property (nonatomic, copy) NSString *message;
@end

//Module A中的调用代码
UIViewController<ModuleARequiredLoginViewInput> *loginViewController = [ZIKViewRouterToView(LoginViewInput) makeDestination];
loginViewController.message = @"请登录查看笔记详情";

//Login Module Provided Interface
@protocol ProvidedLoginViewInput <NSObject>
@property (nonatomic, copy) NSString *notifyString;
@end

//App Context 中的 Adapter,用Objective-C的category或者Swift的extension进行接口适配
@interface LoginViewController (ModuleAAdapte) <ModuleARequiredLoginViewInput>
@property (nonatomic, copy) NSString *message;
@end
@implementation LoginViewController (ModuleAAdapte)
- (void)setMessage:(NSString *)message {
self.notifyString = message;
}
- (NSString *)message {
return self.notifyString;
}
@end


用category、extension、NSProxy等技术兼容新旧接口,工作全部由模块的使用和装配者
App Context
完成。如果
LoginViewController
已经有了自己的
message
属性,这时候就说明新的登录模块是不可兼容的,必须有某一方做出修改。当然,接口适配能做的事情是有限的,例如一个接口从同步变成了异步,那么这时候两个模块也是不能兼容的。

因此,如果模块需要进行解耦,那么它的接口在设计的时候就应该十分仔细,尽量不要在参数中引入太多其他的模块依赖。

只有存在
Required Interface
Provided
Interface
概念的设计,才能做到彻底的解耦。目前的路由方案都缺失了这一部分。


Target-Action

CTMediator的方案,把对模块的调用封装到Target-Action中,利用了Objective-C的runtime特性,省略了Target-Action的注册和绑定工作,直接通过CTMediator中介者调用目的模块的方法。
@implementation CTMediator (CTMediatorModuleAActions)
- (UIViewController *)CTMediator_viewControllerForDetail
{
UIViewController *viewController = [self performTarget:kCTMediatorTargetA
action:kCTMediatorActionNativFetchDetailViewController
params:@{@"key":@"value"}
shouldCacheTarget:NO
];
if ([viewController isKindOfClass:[UIViewController class]]) {
// view controller 交付出去之后,可以由外界选择是push还是present
return viewController;
} else {
// 这里处理异常场景,具体如何处理取决于产品
return [[UIViewController alloc] init];
}
}
@end


-performTarget:action:params:shouldCacheTarget:
方法通过
NSClassFromString
,获取目的模块提供的Target类,再调用Target提供的Action,实现了方法调用:
@implementation CTMediator
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{

NSString *targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
Class targetClass;

NSObject *target = self.cachedTarget[targetClassString];
if (target == nil) {
targetClass = NSClassFromString(targetClassString);
target = [[targetClass alloc] init];
}

SEL action = NSSelectorFromString(actionString);

if (target == nil) {
// 这里是处理无响应请求的地方之一,这个demo做得比较简单,如果没有可以响应的target,就直接return了。实际开发过程中是可以事先给一个固定的target专门用于在这个时候顶上,然后处理这种请求的
return nil;
}

if (shouldCacheTarget) {
self.cachedTarget[targetClassString] = target;
}

if ([target respondsToSelector:action]) {
return [self safePerformAction:action target:target params:params];
} else {
// 有可能target是Swift对象
actionString = [NSString stringWithFormat:@"Action_%@WithParams:", actionName];
action = NSSelectorFromString(actionString);
if ([target respondsToSelector:action]) {
return [self safePerformAction:action target:target params:params];
} else {
// 这里是处理无响应请求的地方,如果无响应,则尝试调用对应target的notFound方法统一处理
SEL action = NSSelectorFromString(@"notFound:");
if ([target respondsToSelector:action]) {
return [self safePerformAction:action target:target params:params];
} else {
// 这里也是处理无响应请求的地方,在notFound都没有的时候,这个demo是直接return了。实际开发过程中,可以用前面提到的固定的target顶上的。
[self.cachedTarget removeObjectForKey:targetClassString];
return nil;
}
}
}
}
@end


优点

实现简洁,整个实现的代码量很少
省略了路由注册的步骤,可以减少一部分内存消耗和时间消耗,但是也略微降低了调用时的性能
使用场景不局限于界面模块,所有模块都可以通过中介者调用


缺点

在调用action时使用字典传参,无法保证类型安全,维护困难
直接使用runtime互相调用,难以明确地区分
Required Interface
Provided
Interface
,因此其实无法实现完全解耦。和URL Router一样,在目的模块变化时,调用模块也必须做出修改
过于依赖runtime特性,和Swift的类型安全设计是不兼容的,也无法跨平台多端实现


UIStoryboardSegue

苹果的storyboard其实也有一套路由API,只不过它的局限性很大。在这里简单介绍一下:
@implementation SourceViewController

- (void)showLoginViewController {
//调用在storyboard中定义好的segue identifier
[self performSegueWithIdentifier:@"presentLoginViewController" sender:nil];
}

//perform segue时的回调
- (BOOL)shouldPerformSegueWithIdentifier:(NSString *)identifier sender:(nullable id)sender {
return YES;
}

//配置目的界面
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
//用[segue destinationViewController]获取目的界面,再对目的界面进行传参
}
@end


UIStoryboardSegue是和storyboard一起使用的,storyboard中定义好了一些界面跳转的参数,比如转场方式(push、present等),在执行路由前,执行路由的UIViewController会收到回调,让调用者配置目的界面的参数。

在storyboard中连接segue,其实是跳转到一个已经配置好界面的view controller,也就是和View相关的参数,都已经做好了依赖注入。但是自定义的依赖,却还是需要在代码中注入,所以又给了我们一个
-prepareForSegue:sender:
回调。

我不建议使用segue,因为它会导致强耦合。但是我们可以借鉴UIStoryboardSegue的sourceViewController、destinationViewController、封装跳转逻辑到segue子类、对页面执行依赖注入等设计。


总结

总结了几个路由工具之后,我的结论是:路由的选择还是以业务需求为先。当对动态性要求极高、或者需要多平台统一路由,则选择URL Router,其他情况下,我更倾向于使用Protocol Router。和Peak大大的结论一致。

Protocol Router目前并没有一个成熟的开源方案。因此我造了个轮子,增加了上面提到的一些需求。


ZIKRouter的特性


离散式管理

每个模块都对应一个或者多个router子类,在子类中管理各自的路由过程,包括对象的生成、模块的初始化、路由状态管理、AOP等。路由时,需要使用对应的router子类,而不是一个单例router掌管所有的路由。如果想要避免引用子类带来的耦合,可以用protocol动态获取router子类,或者用父类+泛型在调用者中代替子类。

采用离散式的设计的原因是想让各个模块对路由拥有充分的控制权。

一个router子类的简单实现如下:
@interface ZIKLoginViewRouter : ZIKViewRouter
@end

@implementation ZIKLoginViewRouter
//app启动时,注册对应的模块和Router
//不使用+load和+initialize方法,因为在Swift中已经不适用
+ (void)registerRoutableDestination {
[self registerView:[ZIKLoginViewController class]];
[self registerViewProtocol:ZIKRoutableProtocol(ZIKLoginViewProtocol)];
}
//执行路由时,返回对应的viewController或者UIView
- (id)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
UIStoryboard *sb = [UIStoryboard storyboardWithName:@"Main" bundle:nil];
ZIKLoginViewController *destination = [sb instantiateViewControllerWithIdentifier:@"ZIKLoginViewController"];
return destination;
}
//检查来自storyboard的界面是否需要让外界进行
+ (BOOL)destinationPrepared:(UIViewController<ZIKLoginViewProtocol> *)destination {
if (destination.loginService != nil) {
return YES;
}
return NO;
}
//初始化工作
- (void)prepareDestination:(UIViewController<ZIKLoginViewProtocol> *)destination configuration:(__kindof ZIKViewRouteConfiguration *)configuration {
if (destination.loginService == nil) {
//ZIKLoginService也可以用ZIKServiceRouter动态获取
destination.loginService = [ZIKLoginService new];
}
}
//路由时的AOP回调
+ (void)router:(nullable ZIKViewRouter *)router willPerformRouteOnDestination:(id)destination fromSource:(id)source {
}
+ (void)router:(nullable ZIKViewRouter *)router didPerformRouteOnDestination:(id)destination fromSource:(id)source {
}
+ (void)router:(nullable ZIKViewRouter *)router willRemoveRouteOnDestination:(id)destination fromSource:(id)source {
}
+ (void)router:(nullable ZIKViewRouter *)router didRemoveRouteOnDestination:(id)destination fromSource:(id)source {
}
@end


你甚至可以在不同情况下返回不同的destination,而调用者对此完全不知情。例如一个alertViewRouter为了兼容UIAlertView和UIAlertController,可以在router内部,iOS8以上使用UIAlertController,iOS8以下则使用UIAlertView。

一切路由的控制都在router类内部,不需要模块做出任何额外的修改。


自由定义路由参数

路由的配置信息都存储在configuration里,在调用者执行路由的时候传入。基本的跳转方法如下:
//跳转到Login界面
[ZIKLoginViewRouter
performFromSource:self //界面跳转时的源界面
configuring:^(ZIKViewRouteConfiguration *config) {
//跳转类型,支持push、presentModally、presentAsPopover、performSegue、show、showDetail、addChild、addSubview、custom、getDestination
config.routeType = ZIKViewRouteTypePush;
config.animated = NO;
config.prepareDestination = ^(id<ZIKLoginViewProtocol> destination) {
//跳转前配置界面
};
config.routeCompletion = ^(id<NoteEditorProtocol> destination) {
//跳转成功并结束处理
};
config.performerErrorHandler = ^(ZIKRouteAction routeAction, NSError * error) {
//跳转失败处理,有失败的详细信息
};
}];


Configuration只能在初始化block里配置,出了block以后就无法修改。你也可以用一个configuration子类添加更多自定义信息。

如果不需要复杂的配置,也可以只用最简单的跳转:
[ZIKLoginViewRouter performFromSource:self routeType:ZIKViewRouteTypePush];


移除已执行的路由

你可以先初始化一个router,再交给其他角色执行路由:
//初始化router
self.loginRouter = [[ZIKLoginViewRouter alloc] initWithConfiguring:^(ZIKViewRouteConfiguration * _Nonnull config) {
config.source = self;
config.routeType = ZIKViewRouteTypePush;
}];

//执行路由
if ([self.loginRouter canPerform] == NO) {
NSLog(@"此时无法执行路由:%@",self.loginRouter);
return;
}
[self.loginRouter performRouteWithSuccessHandler:^{
NSLog(@"performer: push success");
} performerErrorHandler:^(ZIKRouteAction routeAction, NSError * _Nonnull error) {
NSLog(@"performer: push failed: %@",error);
}];


当你需要消除已经展示的界面,或者销毁一个模块时,可以调用移除路由方法一键移除:
if ([self.loginRouter canRemove] == NO) {
NSLog(@"此时无法移除路由:%@", self.loginRouter);
return;
}
[self.loginRouter removeRouteWithSuccessHandler:^{
NSLog(@"performer: pop success");
} performerErrorHandler:^(ZIKRouteAction routeAction, NSError * _Nonnull error) {
NSLog(@"performer: pop failed,error:%@",error);
}];


从而无需再区分调用pop、dismiss、removeFromParentViewController、removeFromSuperview等方法。


通过protocol获取对应模块


Protocol作为匹配标识

我们不想让外部引用
ZIKLoginViewRouter
头文件导致耦合,调用者只需要获取一个符合了
ZIKLoginViewProtocol
的view
controller,因此只需要根据
ZIKLoginViewProtocol
获取到对应的router子类,然后在子类上调用父类
ZIKViewRouter
提供的路由方法即可,这样就可以做到隐藏子类。

使用
ZIKViewRouterToView
和ZIKViewRouterToModule宏,即可通过protocol获取到对应的router子类,并且子类返回的destination必定符合
ZIKLoginViewProtocol

[ZIKViewRouterToView(ZIKLoginViewProtocol)
performFromSource:self
configuring:^(ZIKViewRouteConfiguration *config) {
config.routeType = ZIKViewRouteTypePush;
config.prepareDestination = ^(id<ZIKLoginViewProtocol> destination) {
//跳转前配置界面
};
config.routeCompletion = ^(id<ZIKLoginViewProtocol> destination) {
//跳转成功并结束处理
};
}];


这时候
ZIKLoginViewProtocol
就相当于LoginView模块的唯一identifier,不能再用到其他view controller上。你可以用多个protocol注册同一个router,用于区分
requiredProtocol
providedProtocol


多对一匹配

有时候,一些第三方的模块或者系统模块并没有提供自己的router,你可以为其封装一个router,此时可以有多个不同的router管理同一个UIViewController或者UIView类。这些router可能提供了不同的功能,比如同样是alertRouter,routerA可能是用于封装UIAlertController,routerB可能是用于兼容UIAlertView和UIAlertController,这时候要如何区分并获取两个不同的router?

像这种提供了独特功能的router,需要你使用configuration的子类,然后让子类conform对应功能的protocol。于是就可以根据configuration的protocol来获取对应的router:
[ZIKViewRouterToModule(ZIKCompatibleAlertConfigProtocol)
performFromSource:self
configuring:^(ZIKViewRouteConfiguration<ZIKCompatibleAlertConfigProtocol> * _Nonnull config) {
config.routeType = ZIKViewRouteTypeCustom;
config.title = @"Compatible Alert";
config.message = @"Test custom route for alert with UIAlertView and UIAlertController";
[config addCancelButtonTitle:@"Cancel" handler:^{
NSLog(@"Tap cancel alert");
}];
[config addOtherButtonTitle:@"Hello" handler:^{
NSLog(@"Tap hello button");
}];
config.routeCompletion = ^(id _Nonnull destination) {
NSLog(@"show custom alert complete");
};
}];


如果模块自己提供了router,并且这个router用于依赖注入,不能被其他router替代,可以声明本router为本模块的唯一指定router,当有多个router尝试管理此模块时,启动时就会产生断言错误。


依赖注入和依赖声明


固定依赖和运行时依赖

模块的依赖分为固定依赖和运行时参数依赖。

固定依赖就类似于VIPER各角色之间的依赖关系,是一个模块中固定不变的依赖。这种依赖只需要在router内部的
-prepareDestination:configuration:
固定配置即可。

运行时依赖就是外部传入的参数,由configuration负责传递,然后同样是在
-prepareDestination:configuration:
中,获取configuration并配置destination。你可以用一个configuration子类和router配对,这样就能添加更多自定义信息。

如果依赖参数很简单,也可以让router直接对destination进行配置,声明router的destination遵守
ZIKLoginViewProtocol
,让调用者在
prepareDestination
里设置destination。但是如果依赖涉及到了model对象的传递,并且由于需要隔离View和Model,destination不能接触到这些model对象,这时候还是需要让configuration传递依赖,在router内部再把model传给负责管理model的角色。

因此,configuration和destination的protocol就负责依赖声明和暴露接口。调用者只需要传入protocol里要求的参数或者调用一些初始化方法即可,至于router内部怎么使用和配置这些依赖,调用者就不用关心了。


直接在头文件中声明

声明一个protocol是一个router的config protocol或者view protocol时,只需要让这个protocol继承自
ZIKViewConfigRoutable
或者
ZIKViewRoutable
即可。这样,所有的依赖声明都可以在头文件里明确表示,不必再从文档中查找。


使用泛型指明特定router

一个模块可以直接在内部用
ZIKViewRouterToModule
ZIKViewRouterToView
动态获取router,也可以在头文件里添加一个router属性,让builder注入。

那么一个模块怎么向builder声明自己需要某个特定功能的router呢?答案是父类+泛型。

ZIKRouter支持用泛型指定参数类型。在OC中可以这样使用:
//注意这个示例代码只是用于演示泛型的意思,实际运行时必须要用一个ZIKViewRouter子类才可以
[ZIKViewRouter<UIViewController> *>
performFromSource:self
configuring:^(ZIKViewRouteConfiguration<ZIKLoginConfigProtocol> *config) {
config.routeType = ZIKViewRouteTypePerformSegue;
config.configureSegue(^(ZIKViewRouteSegueConfiguration *segueConfig) {
segueConfig.identifier = @"showLoginViewController";
);
}];


ZIKViewRouter<UIViewController> *>
就是一个指定了泛型的类,尖括号中指定了router的destination和configuration类型。这一串说明相当于是在声明:这是一个destination为UIViewController类型,用
ZIKViewRouteConfiguration<ZIKLoginConfigProtocol>
*
作为执行路由时的configuration的router类。你可以对configuration再添加protocol,表明这个configuration必须遵守指定的protocol。

这时你就可以用父类+泛型来声明一个router类,这个router类的configuration符合特定的config protocol。而且在写的时候Xcode会给你自动补全。这是一种很好的隐藏子类的方式,而且符合原生的语法。

但是由于OC中的类都是
Class
类型,因此只能这样声明一个实例属性:
@property (nonatomic, strong) ZIKViewRouter<UIViewController> *> *loginViewRouter;


Builder只能注入一个router实例,而不是一个router class。因此在OC里一般不这么使用。

但是在Swift这种类型安全语言中这种模式就能更好地发挥作用了,你可以直接注入一个符合某个泛型的router:

//在Builder中注入alertRouter
swiftSampleViewController.alertRouter = Router.to(RoutableViewModule<ZIKCompatibleAlertConfigProtocol>())

class SwiftSampleViewController: UIViewController {
//在Builder里注入alertRouterClass后,就可以直接用这个routerClass执行路由
var alertRouter: ViewRouter<Any, ZIKCompatibleAlertConfigProtocol>!

@IBAction func testInjectedRouter(_ sender: Any) {
self.alertRouter.perform(
from: self,
configuring: { (config, prepareDestination, prepareModule) in
prepareModule({ moduleConfig in
//moduleConfig在类型推断时就是ZIKCompatibleAlertConfigProtocol,无需在判断后再强制转换
moduleConfig.title = "Compatible Alert"
moduleConfig.message = "Test custom route for alert with UIAlertView and UIAlertController"
moduleConfig.addCancelButtonTitle("Cancel", handler: {
print("Tap cancel alert")
})
moduleConfig.addOtherButtonTitle("Hello", handler: {
print("Tap Hello alert")
})
})
}
}
}


声明了
ViewRouter<Any,
ZIKCompatibleAlertConfigProtocol>
的属性后,外部就可以直接注入一个对应的router。可以用这种设计模式来转移、集中获取router的职责。

Router可以在定义的时候限制自己的泛型:

Objective-C:
@interface ZIKCompatibleAlertViewRouter : ZIKViewRouter<UIViewController> *>

@end


Swift:
class ZIKCompatibleAlertViewRouter: ZIKViewRouter<UIViewController> {

}


这样在传递的时候,就可以让编译器检查router是否正确。


调用安全和类型安全

上面的演示已经展示了类型安全的处理,由protocol和泛型共同完成了这个类型安全的设计。不过有一些问题还需要特别注意。


编译检查

使用
ZIKViewRouterToModule
ZIKViewRouterToView
时,会对传入的protocol进行编译检查。保证传入的protocol是可路由的protocol,不能随意滥用。具体用到的方式有些复杂,而且在Objective-C和Swift上使用了两种方式来实现编译检查,具体实现可以看源代码。


泛型的协变和逆变

Swift的自定义泛型不支持协变,所以使用起来有点奇怪。
let alertRouterClass: ZIKViewRouter<UIViewController>.Type

//编译错误
//ZIKCompatibleAlertViewRouter.Type is ZIKViewRouter<UIViewController>.Type
alertRouterClass = ZIKCompatibleAlertViewRouter.self


Swift的自定义泛型不支持子类型转为父类型,因此把
ZIKViewRouter<UIViewController>.Type
赋值给
ZIKViewRouter<UIViewController>.Type
类型时就会出现编译错误。奇怪的是反过来逆变反而没有编译错误。而Swift原生的集合类型是支持协变的。从2015年开始就有人提议Swift对自定义泛型加入协变,到现在也没支持。在Objective-C里自定义泛型是可以正常协变的。

因此在swift里,使用了另一个类来包裹真正的router,而这个类是可以随意指定泛型的。


用Adapter兼容接口

可以用不同的protocol获取到相同的router。也就是
requiredProtocol
providedProtocol
只要有声明,都可以获取到同一个router。

首先检查
requiredProtocol
providedProtocol
,确定两个接口提供的功能是一致的。否则无法兼容。


Provided
模块添加
Required
Interface

requiredProtocol
是外部的要求目的模块额外兼容的,由App Context在ZIKViewAdapter的子类里进行接口兼容。
@protocol ModuleARequiredLoginViewInput <ZIKViewRoutable>
@property (nonatomic, copy) NSString *message;
@end

//Module A中的调用代码
UIViewController<ModuleARequiredLoginViewInput> *loginViewController = [ProtocolRouter destinationForProtocol:@protocol(LoginViewInput)];
loginViewController.message = @"请登录查看笔记详情";

//Login Module Provided Interface
@protocol ProvidedLoginViewInput <NSObject>
@property (nonatomic, copy) NSString *notifyString;
@end

//ZIKEditorAdapter.h,ZIKViewAdapter子类
@interface ZIKEditorAdapter : ZIKViewRouteAdapter
@end

//ZIKEditorAdapter.m
//用Objective-C的category、Swift的extension进行接口适配
@interface LoginViewController (ModuleAAdapte) <ModuleARequiredLoginViewInput>
@property (nonatomic, copy) NSString *message;
@end
@implementation LoginViewController (ModuleAAdapte)
- (void)setMessage:(NSString *)message {
self.notifyString = message;
}
- (NSString *)message {
return self.notifyString;
}
@end

@implementation ZIKEditorAdapter

+ (void)registerRoutableDestination {
//注册NoteListRequiredNoteEditorProtocol和ZIKEditorViewRouter匹配
[ZIKEditorViewRouter registerViewProtocol:ZIKRoutableProtocol(NoteListRequiredNoteEditorProtocol)];
}

@end


用中介者转发接口

如果遇到protocol里的一些delegate需要兼容:
@protocol ModuleARequiredLoginViewDelegate <NSObject>
- (void)didFinishLogin;
@end

@protocol ModuleARequiredLoginViewInput <ZIKViewRoutable>
@property (nonatomic, copy) NSString *message;
@property (nonatomic, weak) id<ModuleARequiredLoginViewDelegate> delegate;
@end

@protocol LoginViewDelegate <NSObject>
- (void)didLogin;
@end

@protocol ProvidedLoginViewInput <NSObject>
@property (nonatomic, copy) NSString *notifyString;
@property (nonatomic, weak) id<LoginViewDelegate> delegate;
@end


这种情况在OC里可以hook
-setDelegate:
方法,用
NSProxy
来进行消息转发,把
LoginViewDelegate
的消息转发为对应的
ModuleARequiredLoginViewDelegate
中的消息。

不过Swift里就不适合这么干了,相同方法有不同参数类型时,可以用一个新的router代替真正的router,在新的router里插入一个中介者,负责转发接口:
@implementation ZIKEditorMediatorViewRouter
+ (void)registerRoutableDestination {
//注册NoteListRequiredNoteEditorProtocol,和新的ZIKEditorMediatorViewRouter配对,而不是目的模块中的ZIKEditorViewRouter
//新的ZIKEditorMediatorViewRouter负责调用ZIKEditorViewRouter,插入一个中介者
[self registerView:/* mediator的类*/];
[self registerViewProtocol:ZIKRoutableProtocol(NoteListRequiredNoteEditorProtocol)];
}
- (id)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
//用ZIKEditorViewRouter获取真正的destination
id<ProvidedLoginViewInput> realDestination = [ZIKEditorViewRouter makeDestination];
//获取一个负责转发ProvidedLoginViewInput和ModuleARequiredLoginViewInput的mediator
id<ModuleARequiredLoginViewInput> mediator = MediatorForDestination(realDestination);
return mediator;
}
@end


一般来说,并不需要立即把所有的protocol都分离为
requiredProtocol
providedProtocol
。调用模块和目的模块可以暂时共用protocol,或者只是简单地改个名字,在第一次需要替换模块的时候再对protocol进行分离。


封装UIKit跳转和移除方法


封装iOS的路由方法

ZIKViewRouter
把UIKit中路由相关的方法:
-pushViewController:animated:

-presentViewController:animated:completion:

UIPopoverController
-presentPopoverFromRect:inView:permittedArrowDirections:animated:

UIPopoverPresentationController
的配置
-performSegueWithIdentifier:sender:

-showViewController:sender:

-showDetailViewController:sender:

-addChildViewController:

-addSubview:


全都统一封装,可以用枚举一键切换:
[ZIKViewRouterToView(ZIKLoginViewProtocol)
performFromSource:self routeType::ZIKViewRouteTypePush];


对应的枚举值:
ZIKViewRouteTypePush

ZIKViewRouteTypePresentModally

ZIKViewRouteTypePresentAsPopover

ZIKViewRouteTypePerformSegue

ZIKViewRouteTypeShow

ZIKViewRouteTypeShowDetail

ZIKViewRouteTypeAddAsChildViewController

ZIKViewRouteTypeAddAsSubview

ZIKViewRouteTypeCustom

ZIKViewRouteTypeGetDestination


移除路由时,也不必再判断不同情况分别调用
-popViewControllerAnimated:
-dismissViewControllerAnimated:completion:
-dismissPopoverAnimated:
-removeFromParentViewController
-removeFromSuperview
等方法。

ZIKViewRouter
会在内部自动调用对应的方法。


识别
adaptative
类型的路由

-performSegueWithIdentifier:sender:
-showViewController:sender:
-showDetailViewController:sender:
这些
adaptative
的路由方法,系统会根据不同的情况适配
UINavigationController
UISplitViewController
,选择调用
push
present
或者其他方式。直接调用时无法明确知道最终调用的是哪个方法,也就无法移除界面。

ZIKViewRouter
可以识别这些路由方法在调用后真正执行的路由操作,所以你现在也可以在使用这些方法后移除界面。


支持自定义路由

ZIKViewRouter
也支持在子类中提供自定义的路由和移除路由方法。只要写好对应的协议即可。


关于extension里的跳转方法

App extension里还有一些特有的跳转方法,比如
Watch
扩展里
WKInterfaceController
-pushControllerWithName:context:
-popController
Share
扩展里
SLComposeServiceViewController
-pushConfigurationViewController:
-popConfigurationViewController


看了一下extension的种类有十几个,懒得一个个去适配了。而且extension里的界面不会特别复杂,不是特别需要路由工具。如果你需要适配extension,可以自己增加,也可以用
ZIKViewRouteTypeCustom
来适配。


支持storyboard

ZIKViewRouter
支持storyboard,这也是和其他Router相比更强的地方。毕竟storyboard有时候也是很好用的,当使用了storyboard的项目中途使用router的时候,总不能为了适配router,把所有使用storyboard的界面都重构吧?

适配storyboard的原理是hook了所有UIViewController的
-prepareForSegue:sender:
方法,检查destinationViewController是否遵守
ZIKRoutableView
协议,如果遵守,就说明是一个由router管理的界面,获取注册的对应router类,生成router实例,对其进行依赖注入。如果destination需要传入动态参数,就会调用sourceViewController的
-prepareDestinationFromExternal:configuration:
方法,让sourceViewController传参。如果有多个router类注册了同一个view
controller,则取随机的一个router。

你不需要对现有的模块做任何修改,就可以直接兼容。而且原来view controller中的
-prepareForSegue:sender:
也能照常使用。


AOP

ZIKViewRouter
会在一个界面执行路由和移除路由的时候,对所有注册了此界面的router回调4个方法:
+ (void)router:(nullable ZIKViewRouter *)router willPerformRouteOnDestination:(id)destination fromSource:(id)source {
}
+ (void)router:(nullable ZIKViewRouter *)router didPerformRouteOnDestination:(id)destination fromSource:(id)source {
}
+ (void)router:(nullable ZIKViewRouter *)router willRemoveRouteOnDestination:(id)destination fromSource:(id)source {
}
+ (void)router:(nullable ZIKViewRouter *)router didRemoveRouteOnDestination:(id)destination fromSource:(id)source {
}


你可以在这些方法中检查界面是否配置正确。也可以用于AOP记录。

例如,你可以为
UIViewController
这个所有view controller的父类注册一个router,这样就可以监控所有的
UIViewController
子类的路由事件。


路由错误检查

ZIKRouter
会在启动时进行所有router的注册,这样就能检测出router是否有冲突、protocol是否和router正确匹配,保证所有router都能正确工作。当检测到错误时,断言将会失败。

ZIKViewRouter
在执行界面路由时,会检测并报告路由时的错误。例如:
使用了错误的protocol执行路由
执行路由时configuration配置错误
不支持的路由方式(router可以限制界面只能使用push、present等有限的跳转方式)
在其他界面的跳转过程中,执行了另一个界面的跳转(
unbalanced transition
错误,会导致
-viewWillAppear:
-viewDidAppear:
-viewWillDisAppear:
-viewDidDisappear:
等事件的顺序发生错乱)
Source view controller此时的状态无法执行当前路由
路由时container view controller配置错误
segue在代理方法中被取消,导致路由未执行
重复执行路由

基本上包含了界面跳转时会发生的大部分错误事件。


支持任意模块

ZIKRouter
包含
ZIKViewRouter
ZIKServiceRouter
ZIKViewRouter
专门用于界面跳转,
ZIKServiceRouter
则可以添加任意类进行实例获取。

你可以用
ZIKServiceRouter
管理需要的类,并且
ZIKServiceRouter
增添了和
ZIKViewRouter
相同的动态性和泛型支持。


性能

为了错误检查、支持storyboard和注册,
ZIKViewRouter
ZIKServiceRouter
会在app启动时遍历所有类,进行hook和注册的工作。注册时只是把view
class、protocol和router class的地址加入字典,不会对内存有影响。

在release模式下,iPhone6s机型上,测试了5000个UIViewController以及5000个对应的router,遍历所有类并且hook的耗时大约为15ms,注册router的耗时大约为50ms。基本上不会遇到性能问题。

如果你不需要支持storyboard,可以去掉view class和router class配对的注册,去掉以后就无法自动为storyboard里的view controller创建router。至于protocol和router的注册,目前似乎是无法避免的。


项目地址和Demo

简单来说,ZIKRouter就是一个用于模块间路由,基于接口进行模块发现和依赖注入的Router。它以原生的语法执行路由,在OC和Swift中都能使用。

项目地址在:ZIKRouter。里面包含了一个demo,用于演示iOS中大部分的界面路由场景,建议在横屏iPad上运行。

最后记得点个star~

Demo截图,控制台的输出就是界面路由时的AOP回调:




参考

iOS组件化方案
iOS 组件化 —— 路由设计思路分析
component-diagrams
BeeHive
CTMediator
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
相关文章推荐