路由跳转的思考

业务需要

一切脱离业务需求的“结构”设计都是耍流氓(我觉得我们这小打小闹完全谈不上架构这个词)

那我们先梳理一下我们现在的业务场景

目前我们有一个首要问题是跳转

  • 书架banner是个运营位置,需要灵活可配的各种跳转
  • 开机弹框也是个运营位置,依然需要各种跳转
  • push,更别说了,各种跳转
  • H5书城,运营活动H5落地页,通过Bridge还需要各种跳转

我们现在是怎么做的呢?拿书架banner举例

服务器会下发一个type号,(随便假设)1代表打开webview,2代表打开图书,3代表打开个人中心…等等,相关参数会随着type的不同,下发不同字段,因此代码会长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
switch (type) {
case 1:
{
//jumping code
NSString *url = /*解析对应url字段*/
NSString *title = /*解析对应title字段*/
NSString *ydwebview = [[ydwebview alloc]init];
ydwebview.url = url;
ydwebview.navititle = title;
[self.navigationController pushViewController:ydwebview animated:YES];
}
break;
case 2:
{
//balabalaba
}
break;

可以看下我们的switch有多恐怖

  • 书架banner跳转有6个switch,其中第一个switch有4种子switch
  • 开机弹窗有2个switch,支持能力弱
  • push,这可了不得有20个switch
  • H5bridge跳转,有10+个switch

那我们每次新增加一个功能模块的时候改怎么办呢?

假设新作一个模块叫”英式没品笑话百科”(我很爱看的一个微博号╮(╯_╰)╭)

我们就需要在书架,弹框,push,H5Bridge,四处核心跳转点全都新增代码,先要import “EnglishJoke.h”,然后还要新增一个switch,新增一坨跳转viewcontroller的代码

有没有感觉?what the fuck!

我们的代码就好像是这样,一团乱麻。

一团乱麻

假如A模块是书架,它本身含有书架banner的跳转代码,所以他需要耦合各种跳转目标。比如跳转到B模块书城,形成了 A==>B

假如B模块是书城,它本身含有书城H5Brdige的跳转代码,所以他也需要耦合各种跳转目标,比如跳转到A模块书架,形成了 B==>A

假如所有模块都有这种蛋疼的跳转其他模块的需求,他们之间相互跳来跳去(没错,有时候需求就是这么的不讲道理),那么我们的代码结构就会如图一样,随着业务结构的逐渐庞大,就会变成一张复杂的蜘蛛网,难以维护。

结构梳理

仔细思考一下,我们的业务需求的最直接痛点所在就是各种跳转,但往深层考虑一下,这里面其实是耦合的问题,这里说的不是业务逻辑耦合,而是引用耦合

  • 逻辑耦合,作为程序员,作为面向对象开发的基本思路,一个业务逻辑模块,做到模块化,不把自己自身的业务逻辑与外部不相干的模块进行混杂,所有都以接口的形式提供给外部调用,这是一个最基本的设计理念,这是没有问题,也是必须要做到的
  • 引用耦合,被抽象成一个模块,外部要使用的时候势必要import这个模块的头文件,再根据头文件的api,进行调用,这无可厚非,但是如果发生这种处理需要统一跳转多个不同模块的逻辑的时候,引用耦合就会显得混乱不好管理

当面对这种当引用耦合一团乱麻的情况下,随着业务逐渐壮大,我们将会面对着一张复杂的如同蛛网一般的相互引用关系,这时候我们又该如何去处理?

其实有两种方案,都在被普遍使用

  • 中间人
  • urlroute

这个思路其实源自这篇文章 iOS移动端架构的那些事,但只是这篇文章中提到的一部分,我们的app还没发展到需要严谨的组件化设计,庞大的架构才能支撑下多条业务的相互合作

我们目前的需求相对简单,就是解决跳转问题,但是解决跳转问题的时候,我们能够参考和借鉴,提前预知一些坑和风险,从而设计一个不只停留于表面需求,还能为未来业务扩展进行考虑和设计

首先看一下,无论是选择哪个方案,归根结底,都是抽象出一个中间层来对纷乱的引用关系来进行统一的跳转,这是软件工程的基本思路,而Mediator和URLRouter两种方案就是在思考下面2个问题上,产生了不同

  • 中间层如何调用具体的业务模块or组件
  • 业务模块用什么方式操作中间层

这两个问题其实来源于iOS组件化方案探索,本文也很大程度的参考bang的这篇文章

URLRoute

Github上有很多开源的URLRoute方案,一艘能搜到很多,但不外乎都是以注册block,注册viewcontroller为主的,比如Routable-ios,Github上1000多个star

他就是一种基于注册式的URLRoute的统跳协议

  • 注册url
  • 各模块需要一种以字典为媒介的统一创建模式
  • openUrl

希望通过注册URL的方式,来实现通过一个url就能任意打开已注册的界面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//首先需要注册url,urlroute只能识别注册过的url,在注册url的同时也注册参数信息
[[Routable sharedRouter] map:@"users/:id" toController:[UserController class]];
//其次需要各个界面模块统一一个创建模式,只认分析url分析出来的字典结果
@implementation UserController
- (id)initWithRouterParams:(NSDictionary *)params {
if ((self = [self initWithNibName:nil bundle:nil])) {
self.userId = [params objectForKey:@"id"];
}
return self;
}
//任何代码只要想跳转,只需要一个url,使用Router,openurl即可
NSString *aUrl = @"users/4";
[[Routable sharedRouter] open:aUrl];

这种Router一般都支持2种注册

  • 注册viewcontroller
  • 注册callback

一般就是通过一个注册方法-map:toXX:来把一个字符串以及所对应的函数代码块或者界面初始化代码块,在一个统一的hash表里面进行key-value注册。

当router识别url的时候,发现是对应已注册过的字符key,并且把url字符串里面的参数信息解析完毕后,通过字符key,找到对应的代码块去执行代码。

urlroute

发起调用方的模块只依赖URLRoute模块,但是视注册的情况,URLRoute不一定会依赖各个模块,这里说的依赖,简单点说就是import

  • 以VC形式注册,由于强制要求固定名称和参数的构造函数,以Runtime Call的方案,URLRoute不依赖各个模块(见源码)无需import对应类
  • 以Block形式注册,由于执行代码是外部block传入的,外部要调用啥类还是需要import,因此在注册block的时候必然会产生模块依赖

这种方案虽然已经被广泛使用,并且非常适合解决我们的困境,页面之间的各种跳转,对于结构梳理时候的2个问题,他的答案是

  • 中间层如何调用具体的业务模块or组件
  • 业务模块用什么方式操作中间层

  • 中间层如何调用具体的业务模块or组件?

    • 使用block的形式,在注册的时候模块调用写在block里
    • 使用map VC的形式,所有VC都统一字典创建入口,在注册的时候识别参数,构建字典
  • 业务模块用什么方式操作中间层?
    • 维护一个注册表,只有注册过的apiName,才能被router识别
    • 将url字符串输入给URLRouter

这个方案看起来就是天生为解决我们的“各种跳转”所设计的,但这种方案并不是我目前打算采用的方案,原因我前面提到了一部分,

希望不只停留于表面需求,还能为未来业务扩展进行考虑和设计

后面我还会详细说明,我们先看另外一种。

中间人

我们把所有的调用都集合在一起,使用一个中间人来管理,抽象出一个Mediator类

1
2
3
4
5
6
7
8
9
10
11
12
13
//Mediator.m
#import "ModuleA.h"
#import "ModuleB.h"
@implementation Mediator
+ (UIViewController *)ModuleA_viewController:(NSString *)AName {
ModuleA *a = [[ModuleA alloc]initWithName:AName];
return a;
}
+ (UIViewController *)ModuleB_viewController:(NSString *)BID {
ModuleB *b = [[ModuleB alloc]initWithID:BID];
return b;
}
@end

所有调用的地方只需要使用中间人的方法,就可以调用另外一个模块

[Mediator ModuleA_viewController:@"name"]

这样就形成了这样的引用依赖

中间人1

有人会说,你这无非就是封装了一层而已,只是起到了便于维护和管理的作用,但是引用耦合依然存在,以前A与B直接相互引用依赖,现在A引用依赖中间层,中间层引用依赖B,这种引用耦合并没有解决的。

想解决这种引用耦合?完全没问题,OC有运行时,我完全可以这么做

1
2
3
4
5
6
7
8
9
10
11
12
13
//Mediator.m
@implementation Mediator
+ (UIViewController *)ModuleA_viewController:(NSString *)AName {
Class cls = NSClassFromString(@"ModuleA");
/* runtime msgsend call initWithName*/
return a;
}
+ (UIViewController *)ModuleB_viewController:(NSString *)BID {
Class cls = NSClassFromString(@"ModuleB");
/* runtime msgsend call initWithName*/
return b;
}
@end

我们完全不importA和B的类,完全通过拼接字符串,使用OC的runtime运行时来动态的调用方法,如何写runtime代码还是比较繁琐,并且要求一定的OC runtime知识,在此我先进行了省略因为后面会介绍一个我自己写的运行时神器工具(自卖自夸一把╮(╯_╰)╭)。

这样引用关系就变成了这样

中间人2

这样做有啥好处呢?引用依赖彻底消失,如果我的工程完全删掉D的代码,整个工程也能build通,完全不会报错,不需要修改代码。

此外,只要做好Mediator的异常判断和保护,也可以完全不担心,因为删掉D的代码而产生的崩溃。

这种方案对于结构梳理时候的2个问题,他的答案是

  • 中间层如何调用具体的业务模块or组件?
    • 使用runtime的形式,完全不import,不依赖任何模块代码
  • 业务模块用什么方式操作中间层?
    • 直接通过Mediator的头文件接口,直接调用API,享受Xcode代码补全

同时他还有两个好处

  • 可以设计任意形式的跨模块调用API,接口形式没有任何限制,传参种类没有任何限制

  • 随着业务模块逐渐增多,还可以以category的形式,按种类,把众多代码分类保存和管理

题外话:有人会说,干嘛还要中间人,所有模块之间完全都使用runtime去调用,不就彻底没依赖了么?当然可以,但这也太蛋疼了,对使用者要求高,且代码复杂没有自动提示,封装在mediator里,至少可以保证写mediator的人开发一次,其他使用者就再也不用考虑和担心runtime的问题了。

基于中间人的URLRoute

从组件解耦回到正题,回到我们一直聊得“各种跳转”的问题,还是那句话,我们要解决业务痛点,我们不是在探讨一个“高大上”的“组件化”话题。

刚才提到了中间人模式,看起来远没有URLRoute模式简单直观好用,URLRoute仿佛就是天生就是为了灵活的在app内来回任意跳转而设计的,看起来非常符合我们的需求,但URLRouter有没有什么弊端?

传参限制

使用url的方式传参,很明显我们要把所有用到的参数都转化成字符串并且拼接到url里面,例如

demo://openLogin?defaultUser="Name"

这样的结果就是我们只能传那些可以被字符化的参数,简单来说就是json型参数,那些数字,字符串,字典,数组,都可以被json化形成字符串,从而拼接到url里面,但是如果我们的界面初始化需要非json型参数呢?如果未来新的业务需要传入一个UIImage做参数怎么办呢?

有人会说,既然选择了url这种跳转形式,我们自然面临了这样的问题,但是收益也是巨大的,他甚至能统一wap到app得跳转,做到更加灵活和自由

url是一种无论是对服务器的http请求,还是对前端wap页面跳转,都是一套统一的规则,所以我们可以暂且把这种APP内url跳转叫做远程调用,我们把那种传app直接调用称作本地调用

  • 远程调用:通过一种server,wap,na都可以认可url协议的形式进行调用和传参。
  • 本地调用:直接通过NA独有的消息函数的形式进行调用和传参。

远程调用只支持可以字符化json化的数据,本地调用可以支持任何na的参数,所以说远程调用本地调用的子集

原文引用自iOS组件化方案探索

那我们能不能既保留了url这种灵活跨wap/NA的跳转方式,又能支持业务传非json型参数么?答案显然是可以的

我们的中间人模式,就是一种很明显的本地调用,那我们可以考虑在中间人模式之上,封装一层URL协议解析,从而实现了我们的基于中间人的URLRoute

urlaction

这种方案对于结构梳理时候的2个问题,他的答案是

  • 中间层如何调用具体的业务模块or组件
    • 只通过Mediator,走runtime的形式调用业务模块组件
  • 业务模块用什么方式操作中间层
    • 需要openURL的时候,解析url,再调用Mediator来实现调用
    • 需要本地调用的时候,直接通过Mediator来实现调用

VKURLAction

上面提到一种基于中间人模式的URLRoute,我已经完全实现了源码,并且写好了demo工程,起了个中二的名字 VKURLAction ==> GitHub 地址

整个代码里包括

  • openURL模块 VKURLAction
  • URL解析模块 VKURLParser
  • 中间人模式设计 VKMediatorAction
  • runtime工具 VKMsgSend

既然是基于中间人模式,那么VKMediatorAction就是整个代码的核心
VKMediatorAction的主代码并未实现什么核心内容,写了个单例是为了以后可能进行的功能扩展,从现有的代码角度,可以干掉单例,全写成类方法

本地调用

VKMediatorAction的category才是中间人的主要代码所在,使用category的方式便于在业务无限庞大的时候,分拆代码便于管理,可以看demo工程中的VKMediatorAction+webVC.m的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-(void)doAlertWebViewControllerWith:(NSString *)title withMainUrl:(NSString *)url
{
Class cls = NSClassFromString(@"WebViewController");
id vc = [[cls alloc]VKCallSelectorName:@"initWithTitle:url:" error:nil,title,url];
[vc VKCallSelectorName:@"doAlertAction" error:nil];
}
-(id)getWebViewControllerWithTitle:(NSString *)title withMainUrl:(NSString *)url
{
Class cls = NSClassFromString(@"WebViewController");
id vc = [[cls alloc]VKCallSelectorName:@"initWithTitle:url:" error:nil,title,url];
return vc;
}

可以看到所有API设计都是native的api,你可以设计任意的参数命名,任意参数种类。

换句话说我们如果想执行本地调用,只需要引入VKMediatorAction,然后调用你想要的api就够了

1
2
3
4
5
6
7
- (IBAction)native1click:(id)sender {
[[VKMediatorAction sharedInstance] doAlertWebViewControllerWith:@"webview" withMainUrl:@"http://awhisper.github.io"];
}
- (IBAction)native2click:(id)sender {
UIViewController *vc = [[VKMediatorAction sharedInstance]getWebViewControllerWithTitle:@"webview" withMainUrl:@"http://awhisper.github.io"];
[self.navigationController pushViewController:vc animated:YES];
}

需要注意的是,VKMediatorAction+webVC.m内的源码完全不import业务模块,因此需要你使用runtime的方式去调用对应方法,而我的源码里有一个封装的VKMsgSend的工具,使用这个工具可以减少写runtime代码的成本VKCallSelectorName这个方法就是工具提供的,后续还会详细的介绍。

远程调用

当你需要使用url的方式打开界面的时候,首先,你需要让中间人有能力接收url传来的字典型参数,所以特意为上面代码的2个mediatoraction,增加了处理字典参数的版本,可以看到,这个代码最初符合设计初衷,url的action最后还是会调用原来的native的action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-(void)doAlertWebViewControllerWithURLParams:(NSDictionary *)params
{
NSString *title = params[@"title"];
NSString *url = params[@"url"];
[self doAlertWebViewControllerWith:title withMainUrl:url];
}
-(id)getWebViewControllerWithURLParams:(NSDictionary *)params
{
NSString *title = params[@"title"];
NSString *url = params[@"url"];
return [self getWebViewControllerWithTitle:title withMainUrl:url];
}

完成了Mediator对URL参数的支持,其实就已经可以直接使用VKURLAction了

VKURLAction使用

VKURLAction在使用的时候需要提前指定url的scheme和host,经过指定app的scheme和host,凡是不匹配的scheme与host的url都不会进行识别,具体参见demo源码吧,源码很简单不细说了

VKURLAction支持对URL加入sign校验,如果url开启了sign校验功能,所有url必须附带sign参数,并且符合签名校验规则,不然不会进行识别跳转,具体看源码吧,这都是细节,不细说了

只要完成了Mediator对URL参数的支持,其实就已经可以直接使用VKURLAction,比如我们已经写好了getWebViewControllerWithURLParams:方法,那么我们可以直接把getWebViewControllerWithURLParams当做url的path,使用scheme://host/path?params=aa&parms2=bb的形式来打开url,这样就会自动的把url中的参数解析成字典,传入到Mediator得对应方法里

1
2
3
4
5
6
//初始化URLAction
[VKURLAction setupScheme:@"demo" andHost:@"nativeOpenUrl"];
//写url
NSString * url =@"demo://nativeOpenUrl/getWebViewControllerWithURLParams?title=webView&url=http%3A%2F%2Fawhisper.github.io";
//openURL
[VKURLAction doActionWithUrlString:url];

更多使用方法参见demo工程

URL生成

如果不熟悉如何写url,VKURLAction提供了接口来自动生成接口,尤其是开启了签名校验后,url的签名规则会比较复杂,如果想测试,可以使用相关接口来自动生成url,避免手写各种出错

url的参数必须经过url标准的encode,这一点,自动生成url工具已经实现,如果由别的方式生成url(server下发之类的),请注意调试

URL简写

getWebViewControllerWithURLParams当做一个path名字,拼写在url里面实在是有点冗长,并且不好记,因此VKURLAction提供了方法,可以注册简写(注意这不是必须的,不写也一样能够执行url)

1
2
3
4
//注册简写
[VKURLAction mapKeyword:@"openWeb" toActionName:@"getWebViewControllerWithURLParams"];
//url就可以这么写了
NSString * url =@"demo://nativeOpenUrl/openWeb?title=webView&url=http%3A%2F%2Fawhisper.github.io";

简写注册之后,写url也清爽了不少,也少去了别人猜测我们app代码的问题,╮(╯_╰)╭

URLParser

整个VKURLAction都是依托在URLParser这个模块之上,他可以进行解析url,识别出url种的scheme,host,识别出url种的path,识别出url种的每一个参数,拼接成字典,校验签名的可靠性,具体代码见源码吧这块不是很复杂

运行时工具VKMsgSend

中间人Mediator之所以可以不import具体业务代码,就能调用各个业务就是因为使用了这个VKMsgSend

VKMsgSend ==> Github

VKMsgSend 比系统API好用的MsgSend

VKMsgSend 实现原理详解

简单的说

  • 系统API performSelector缺点
    • 参数限制,performSelector只支持id
    • 参数个数,performSelector在NSObject里系统最多只支持4个参数
    • 用法,每加一个参数必须多写一个withObject,过于麻烦
  • 运行时API objc_msgSend缺点
    • 32Bit下使用起来非常方便
    • 64Bit下由于系统底层传参方案改动非常大,因此强制要求进行参数类型,返回类型的函数类型转换,如果不进行类型转换,像32Bit那样直接调用就会crash
    • 每一次调用都,手写调用函数的类型转换,也是挺麻烦的
  • 运行时API runtime直接取函数Imp调用缺点
    • Impobjc_msgSend其实是同一个原因,二者本是一个意思

VKMsgSend就是为了解决这些使用上不方便的缺点而进行的简单封装

本文参考博客

iOS移动端架构的那些事

iOS 组件化方案探索 bang哥的文章,在前一阵子的架构神仙打架的时候,受益匪浅

iOS应用架构谈 组件化方案 casa的文章,本文基本上完全就是casa的思路