我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情
背景
前端以路由来定位页面,跳转页面只需访问对应路由即可,十分方便。可一直以来 iOS 领域没有比较理想的路由方案,大家只能默默忍受繁琐。
随着业务场景拓展和工程化进程不断推进,我们再也无法忍受 iOS 原生页面跳转方式,下面列举几个场景:
- 页面之间存在很多互相跳转的场景,头文件相互依赖,代码耦合严重
- 点击推送消息跳转到指定页面
- HTML5 页面跳转到指定页面
- 进入指定页面领取任务、获取奖励、开始计时等
- Flutter、HTML、Native 相互跳转
以上场景代码实现丑陋,沟通维护成本较高,甚至有些无法完美实现功能。这与我们追求优雅和效率的宗旨相违背,我们无法接受,于是启动了统跳(路由)项目。
解决哪些问题
我们先梳理痛点,梳理要解决的问题,确立需求。
页面跳转
只需向统跳 SDK 提供页面路由,统跳 SDK 就能准确无误的跳转到目的页面。
入参携带
页面跳转时会携带一些参数给目标页面。
路由回调
目标页面关闭时回传数据给调用方
回退到指定路由
回退到指定的页面。以发布短视频为例:首先从首页(M)进入视频拍摄页面(A),拍摄完成进入视频编辑页面(B),编辑完成进入发布页面(C),发布完视频应回退到进入拍摄页面(A)之前的页面(M)而不是视频编辑页面(B)。
统一的路由规则
要求:
- 一个路由对应一个页面
- 路由应是一个有意义的字符串
- 路由规则应适用于各端
- 各端应把路由与页面做绑定
方案:
我们的路由规范沿用前端路由规范即可,符合 Restful
的 URI。
既:URI = scheme:[//authority]path[?query][#fragment]
参见:RFC3986
给不同领域的路由定义对应的 scheme,即 Native: native,Flutter:flutter,http/https:http/https,小程序:wxmp,三方应用:tp(third party)等。
跨模块 API 调用
像向 WebServer 发起 GET 请求一样访问其他模块提供的方法。传统模式下,跨模块方法互调需要引用目标模块,很繁琐,有时还会发生循环引用。
行为路由
某个行为执行特定逻辑。如:HTML5 页面在点击按钮时调起 Native 的分享能力。
路由拦截器
对路由进行鉴权、参数重整、打断点、重定向。统跳 SDK 拦截器即可完美实现。
分组路由拦截器
对某组路由进行鉴权、参数重整、打断点、防沉迷等需求。统跳 SDK 分组拦截器即可完美实现。
路由重定向
页面被其他更合适的技术重写,此时历史代码还在用老路由,要把老代码都改为新路由则要考虑版本控制和改动成本以及可能引入的风险。重定向则可以无成本切入新路由。
不同技术栈之间互不干扰(互不知道)
当把一个路由传入统跳路由 SDK 时,统跳路由 SDK 应能区分把当前路由分发至哪个路由调度器进行调度。如:Native、Flutter、HTML5 各自实现的页面应交给各自的路由调度器。
提供哪些 API
下面是 BBRouter 关键 API 的设计:
NS_ASSUME_NONNULL_BEGIN
@interface BBRouter : NSObject <BBNativeRouter, BBBlockRouter>
@property (nonatomic, strong, class, readonly) BBRouterConfig *routerConfig;
/// 设置跳转未定义路由时的统一回调
/// @param undefinedRouteHandle 未定义路由时的统一回调
+ (void)setUndefinedRouteHandle:(void (^)(BBRouterParameter *))undefinedRouteHandle;
/// 设置将要打开指定页面的回调
+ (void)setWillOpenBlock:(void (^)(BBRouterParameter *))willOpenBlock;
/// 设置已经打开指定页面的回调
+ (void)setDidOpenBlock:(void (^)(BBRouterParameter *))didOpenBlock;
#pragma mark - 注册路由调度器 Dispatcher
/// 注册路由调度器
/// @param dispatcher 调度器
/// @param scheme scheme
+ (BOOL)registerRouterDispatcher:(id<BBRouterDispatcher>)dispatcher scheme:(NSString *)scheme;
#pragma mark - BBBlockRouter
/// 注册路由的实现 block
/// @param path 路由
/// @param action 实现
+ (BOOL)registerTask:(NSString *)path action:(BBBlockDispatcherAction)action;
/// 移除已注册的 block
/// @param path 对应的路由
+ (BOOL)removeTask:(NSString *)path;
#pragma mark - 路由跳转 API
/// 路由到指定页面(该方法为底层方法,不建议直接使用,请使用下面的👇便捷方法)
/// @param parameter 参数
+ (void)routeWithRouterParameter:(BBRouterParameter *)parameter;
#pragma mark - 已经存在 URL 的情况 页面跳转
/// 判断能否打开指定 URI
/// @param url 页面 URI
+ (BOOL)canOpen:(NSString *)url;
/// 打开页面
/// @param url 页面 URI
+ (void)open:(NSString *)url;
/// 打开页面并携带参数
/// @param url 页面 URI
/// @param urlParams 携带的参数 json 可序列化的数据类型(当 scheme 为 native 时 可以传递复杂数据结构)
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams;
/// 打开页面并携带参数且支持数据回传
/// @param url url
/// @param urlParams 携带的参数 json 可序列化的数据类型(当 scheme 为 native 时 可以传递复杂数据结构)
/// @param resultCallback 当需要回调结果时 通过该回调block实现
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback;
/// 打开页面并携带参数且支持数据回传
/// @param url url
/// @param urlParams 携带的参数 json 可序列化的数据类型(当 scheme 为 native 时 可以传递复杂数据结构)
/// @param exts 额外参数
/// @param resultCallback 当需要回调结果时通过该回调 block 实现
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams exts:(NSDictionary * __nullable)exts onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback;
/// 打开页面并携带参数且支持数据回传
/// @param url url
/// @param urlParams 携带的参数 json 可序列化的数据类型(当 scheme 为 native 时 可以传递复杂数据结构)
/// @param exts 额外参数 animated:是否有过渡动画
/// @param routerStyle 过渡动画
/// @param resultCallback 当需要回调结果时通过该回调 block 实现
+ (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams exts:(NSDictionary * __nullable)exts routerStyle:(KBBRouterStyle)routerStyle onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback;
#pragma - mark BBNativeRouter
///通过类注册视图控制器,path 为标识
+ (BOOL)registerClass:(Class)cls withPath:(NSString *)path;
///通过类名注册视图控制器 SDK,path 为标识
+ (BOOL)registerWithClassName:(NSString *)className andPath:(NSString *)path;
///通过类名注册视图控制器,path 为标识,确认参数是否匹配该路由
+ (BOOL)registerWithClassName:(NSString *)className andPath:(NSString *)path verifyBlock:(BOOL(^ __nullable)(NSString *path ,BBRouterParameter *routerParameter))verifyBlock;
///删除注册的视图控制器
+ (BOOL)removeRegisteredPath:(NSString *)path;
#pragma mark - BlockDispatcher 相关实现
/// 执行已注册的 block,同步返回
/// @param url url
/// @param urlParams 入参
+ (id _Nullable)invokeTaskWithUrl:(NSString *)url urlParams:(NSDictionary *)urlParams;
/// 执行已注册的 block,同步返回
/// @param url url
/// @param urlParams 入参
/// @param error 出错信息
+ (id _Nullable)invokeTaskWithUrl:(NSString *)url urlParams:(NSDictionary *)urlParams error:(NSError **)error;
#pragma mark - 分组
/// 添加 path 到指定分组
/// @param path 路由路径
/// @param group 分组名称
+ (BOOL)addPath:(NSString *)path toGroup:(NSString *)group;
/// 添加一组路径到指定分组
/// @param paths 路径分组
/// @param group 分组名称
+ (void)addPaths:(NSArray<NSString *> *)paths toGroup:(NSString *)group;
/// 指定分组下所有路由
/// @param group 分组名
+ (NSArray<NSString *> *)pathsInGroup:(NSString *)group;
/// 配置分组的回调函数 用以处理分组逻辑
/// @param group 分组名称
/// @param verifyBlock 回调闭包
+ (void)configGroup:(NSString *)group verifyBlock:(BOOL(^ __nullable)(NSString *path ,BBRouterParameter *routerParameter))verifyBlock;
///回退
+ (UIViewController * _Nullable)backwardCompletion:(void (^ __nullable)(void))completion;
/// 回退
/// @param animated 是否动画
+ (UIViewController * _Nullable)backwardAnimated:(BOOL)animated completion: (void (^ __nullable)(void))completion;
/// 回退,无动画
/// @param count 回退级数
+ (void)backwardCount:(NSInteger)count completion: (void (^ __nullable)(void))completion;
/// 打开指定新视图控制器
/// @param vc 新的视图控制器
+ (void)openVC:(UIViewController *)vc routerParameter:(BBRouterParameter *)parameter;
/// 当前是否存在路由指定的视图控制器实例
/// @param path 路由路径
+ (UIViewController * _Nullable)containsRouteObjectByPath:(NSString *)path;
/// 返回到指定页面的上一级
/// @param vc 指定的视图控制器实例
/// @param animatedBlock 是否需要动画
/// @param completion 完成
+ (UIViewController * _Nullable)backwardVC:(UIViewController *)vc animatedBlock:(BOOL(^)(NSString *toppath))animatedBlock completion: (void (^__nullable)(void))completion;
@end
NS_ASSUME_NONNULL_END
注册/移除路由调度器
路由调度器用来隔离不同领域的路由,用于解耦。开发者可以非常方便的实现自己的路由调度器,实现自己的路由逻辑。
Native 页面、Flutter 页面和 HTML5 页面跳转逻辑肯定存在差异,此时应有对应的路由调度器实现跳转行为。
可以发挥想象,尽情发挥统跳路由 SDK 的能力,只需定义一个适合你的路由调度器。
注册/移除路由与页面的绑定关系
我们需要把路由与页面的绑定关系注册给统跳路由 SDK 以允许统跳路由 SDK 对路由进行动态解析,动态生成页面实例并实现自动跳转。
**应该注意:**这个注册应该允许更新,以实现路由表动态更新。
注册的不一定非要是一个页面,也可以是某个服务(Service)。如:目标页面是某个第三方提供的,只能通过调用对应 SDK 的某个方法打开,想直接注册页面是做不到的。此时我们可以注册一个服务做中转,在该服务被调用时,我们再调用 SDK 的对应方法即可轻松实现以上需求。
打开某个路由并获取回传值
统跳路由 SDK 提供一个打开某个页面(或调用某个方法)的方法,并获取回调。
入参有以下几个:
- uri:路由
- parameters: 要携带的入参
- exts:其他参数(非业务参数,如:指定转场动画方式)
- callback:回调函数
回退到上个页面
统跳路由 SDK 应提供回退方法。
回退到指定页面
统跳路由 SDK 应提供回退到指定路由的方法并返回指定路由的实例。 统跳路由 SDK 应提供回退 N 层路由的方法。
路由未找到的处理
当消息推送了新版本特有的页面时,老版本应进入路由未找到的统一出口,可以在此处做一个重定向或提示用户升级到最新版本的操作。
关键思路和规范
需求我们已经理顺了,紧接着就是设计统跳路由 SDK 的架构。
如何实现高可扩展
合理抽象和功能拆分是实现高可扩展的基础。
我们设计了路由调度器这个概念,路由调度器用来隔离不同领域的路由。
Native 路由、Flutter 路由、HTML5 路由、小程序路由等,分别有对应的路由调度器实现调度。行为路由也由对应的路由调度器实现调度。
如何避免侵入和耦合
统跳路由 SDK 立项时项目已有一定规模,接入时需要修改已有业务代码无疑是个灾难。因此必须完美兼容传统开发方式,避免引入额外工作量和成员学习成本。
如你所想,我们近乎完美的兼容了传统的开发方式,详见 统跳路由 SDK(iOS 端实现)。
科学管理路由表
- 路由表集中管理
- 版本管理(应用于动态下发路由表)
- 路由表应标注路由名称、用途描述、入参、出参、其他额外限制(如要进入该页面需要的权限)
使用体验优化
通过脚本生成各端代码:
避免硬编码:路由表映射为一个结构体,每个路由是一个属性,通过这种方式避免硬编码。
入参构造器:入参是一个字典,我们可以根据路由定义时的入参生成字典对应的构造器。
出参:出参是一个字典,我们可以根据路由表自动生成字典的关联属性。
版本管理:路由表仓库打 tag 后自动执行脚本生成各端代码(本文不展开)。
路由表动态下发
配置中心提供更新路由表能力,各端按约定的策略更新路由表。
统跳路由 SDK(iOS 端实现)
兼容原生开发方式
以 iOS 传统开发方式为例,跳转一个新页面需要以下步骤:
- 创建目标 ViewController 实例
- 入参以 ViewController 实例属性赋值方式传递
- 获取合适的 NavigationController 实例(若转场方式为模态,则需获取合适的 ViewController 实例)
- NavigationController 实例以 push 方式跳转新页面(或 ViewController 以模态方式跳转新页面)
- 以 block 或 delegate 方式回传值
以上方式已经能满足绝大部分场景,下面我们思考下如何以优雅的方式实现以上步骤:
- 以键值对的方式实现 URI 与 ViewController 的绑定,借助 Objective-C runtime 动态生成 ViewController 实例。
- URI 以 Query 方式携带入参(统跳路由 SDK 内部会把入参解析为 Dictionary),key 为 ViewController 属性(或实例变量)名,借助 Objective-C runtime 判断该 ViewController 类是否包含该属性或实例变量,并判断数据类型是否符合,如果符合则通过 Objective-C KVC 方式为该属性或实例变量赋值,从而实现入参传递。
- 通过遍历主 Window(未必是 keyWindow 要看实际情况)上的路由回退栈可以获取合适的 NavigationController 实例(present 时是栈顶 ViewController 实例)。
- 以上条件都具备了,此时能很容易实现页面跳转。
- 关于数据回传,我们可以通过 ViewController 被移除时回传(一定不能是
dealloc
时,因为dealloc
在内存泄露时不会调用,而内存泄露又偶尔会发生)。
以上思路清晰可执行,使 ViewController 实例与路由相关参数建立联系。
我们把路由相关参数封装为 RouterParameter
,结构如下:
@interface RouterParameter : NSObject
/// 路由所属领域(由哪个路由调度器调度)
@property (nonatomic, copy) NSString *scheme;
/// 路由路径(不包含 query 和 fragment 部分)
@property (nonatomic, copy) NSString *fullPath;
/// URI query 部分
@property (nonatomic, copy) NSString *query;
/// URI fragment 部分
@property (nonatomic, copy) NSString *fragment;
/// 页面跳转方式(push/present)
@property (nonatomic, assign) KBBRouterStyle routerStyle;
/// 完整 URI(会把 addition 拼接入 query)
@property (nonatomic, copy) NSString *url;
/// 路由入参
@property (nonatomic, strong, readonly) NSMutableDictionary *addition;
/// 额外参数(路由行为参数,如:是否开启转场动画)
@property (nonatomic, strong, readonly) NSMutableDictionary *exts;
/// 回调值(code、message、data)
@property (nonatomic, strong) NSDictionary *response;
/// 回传值使用的回调函数
@property (nonatomic, copy) void (^__nullable callBackBlock)(NSDictionary *result);
把统跳 + (void)open:(NSString *)url urlParams:(NSDictionary * __nullable)urlParams exts:(NSDictionary * __nullable)exts routerStyle:(KBBRouterStyle)routerStyle onPageFinished:(void (^ __nullable)(NSDictionary *result))resultCallback
方法携带的相关参数转换为 RouterParameter 实例在统跳路由 SDK 内部传递,通过 UIViewController 分类(Category)和 Objective-C 「关联对象」的方式为 UIViewController 添加属性 routerParameter
。
此刻我们会发现上面的「思路」已经被落实了,思路清晰易懂,并且完美兼容原生开发模式。从而可以使传统模式 0 成本渐进地切换到「路由模式」。
架构
如何使用
整体流程
初始化阶段:
- 加载路由表
- 注册路由拦截器
- 原生路由注册
- 非页面路由注册
- 分组拦截器注册
就绪阶段:此时统跳路由 SDK 已准备就绪。
页面类路由调用
自有 Native 页面:
路由注册
// 注册 Objective-C 实现的 ViewController
BBRouter.register(withClassName: "MomentsViewController", andPath: BBRouterPaths.moments)
// 注册 Swift 实现的 ViewController(注意命名空间)
BBRouter.register(withClassName: swiftClassFullName("MomentsViewController", "Community"), andPath: BBRouterPaths.moments)
Flutter/HTML5 实现的页面不在此处注册,由 Flutter/HTML5 项目自己管理
路由跳转
// 无返还值路由跳转
[BBRouter open:BBRouterPaths.moments urlParams:@{@"momentId":@"11223344"}];
// 有返回值路由跳转(BBRouterPaths.selectAlcohol 这个页面可能是任意一种技术实现的如:Native[Swift\Objective-C]、Flutter、HTML5 等)
[BBRouter open:BBRouterPaths.selectAlcohol urlParams:@{@"alcoholId":@"112233"} onPageFinished:^(NSDictionary * _Nonnull result) {
// r_data 是通过 Objective-C 的 Category 和关联对象方式为 NSDictionary 添加的属性,从而干掉硬编码。
DEBUGLog(@"%@", [NSString stringWithFormat:@"%@", result.r_data]);
}];
BBRouterPaths.selectAlcohol:使用这种方式把路由硬编码干掉。直接硬编码无法使用编译器检查,维护成本高。统跳路由 SDK 的设计目标之一就是消灭硬编码。
方法/行为类路由调用
// 注册行为
[BBRouter registerTask:@"action://xxx.com/yyy/zzz" action:^id _Nullable(BBRouterParameter * _Nonnull routerParameter) {
return routerParameter.addition;
}];
// 方法异步调用(统跳统一方法进行路由,不区分路由所属领域)
[BBRouter open:@"action://xxx.com/yyy/zzz" urlParams:@{@"name":@"xiaoming"} onPageFinished:^(NSDictionary * _Nonnull result) {
DEBUGLog(@"%@", [NSString stringWithFormat:@"%@", result]);
}];
// 方法同步调用(事件专用方法进行路由)
NSError *error = nil;
id result = [BBRouter invokeTaskWithUrl:@"action://xxx.com/yyy/zzz" urlParams:@{@"name":@"xiaoming"} error:&error];
DEBUGLog(@"%@", [NSString stringWithFormat:@"%@", result]);
三方应用指定页面:
解包淘宝和天猫的 .ipa
文件,分析了他们的路由表和调用规则,抱着试一试的态度发现我们的统跳路由 SDK 也完美支持。
// 淘宝商品详情页
[BBRouter open:BBRouterPaths.threeSides urlParams:@{@"i":@"taobao://item.taobao.com/item.htm?id=554418184878"}];
// 天猫商品详情页
[BBRouter open:BBRouterPaths.threeSides urlParams:@{@"i":@"tmall://page.tm/itemDetail?itemID=551101867384"}];
路由拦截器简单演示
参数重整:Objective-C 里 id
是关键字,但其他语言可以正常使用,为了兼容这种场景可以在拦截器里做一个入参重新整理的操作。
BBRouter.register(withClassName: "XXXViewController", andPath: BBRouterPaths.xxx, verifyBlock: { path, routerParameter in
routerParameter.addition["ID"] = routerParameter.addition["id"]
return true
})
重定向:页面使用新的技术重构,新版本应跳转新页面,借助重定向能力,我们就不用修改已有代码了。即使老代码跳的还是老的路由,运行时也会被重定向到新页面。
BBRouter.register(withClassName: "XXXViewController", andPath: BBRouterPaths.xxx, verifyBlock: { path, routerParameter in
let newParameter = BBRouterParameter(byURI: BBRouterPaths.yyy, addition: routerParameter.addition.copy() as! [String : Any])
newParameter.actionBlock = routerParameter.actionBlock
newParameter.routerStyle = routerParameter.routerStyle
newParameter.exts.addEntries(from: routerParameter.exts as! [String : Any])
BBRouter.route(with: newParameter)
return false
})
路由分组拦截器功能简单演示
这里使用分组拦截器实现一组页面需要先成功登录才能访问的需求,且实现了用户操作的连贯性。
let isAuthed = "isAuthed"
BBRouter.addPaths(needAuthedPaths, toGroup: isAuthed);
BBRouter.configGroup(isAuthed) { (path, routerParameter) -> Bool in
if (memberId.isEmpty) {
BBRouter.open(BBRouterPaths.login, urlParams: Dictionary(), exts: Dictionary()) { (result) in
if (!memberId.isEmpty) {// 如果已登录 则继续之前的操作
BBRouter.route(with: routerParameter)
}
}
return false;
}
return true
}
路由未注册处理
// 可以在这里把未注册的路由信息交给 HTML5 落地页,此时就很灵活了,可以做重定向也可以提示用户升级。
BBRouter.setUndefinedRouteHandle { (parameter) in
let url = parameter.url
BBRouter.open(BBRouterPaths.routerNotFound, urlParams: ["url":url])
}
小结
统跳路由 SDK 使多端融合开发变为现实,为页面可视化搭建奠定了基础。到目前为止已交付使用一年左右,对组件化/模块化进程有重要的推动作用。更可喜的是 iOS 端能 0 成本渐进地从传统模式切换为「路由模式」,接入过程近乎零成本。
统跳路由最佳实践分享给大家,也希望大家参与讨论,评论必回!