1. 背景

我们知道前端领域以路由来定位页面,要跳转到对应页面只需访问对应路由即可,十分方便。可一直以来 iOS 领域没有路由这个概念,跳转一个页面需要创建出目标页面的实例,然后通过导航控制器进行跳转,十分繁琐。有这么个需求:当点击推送消息(或点击某个区域)时需要跳转到任意一个可能的页面。乍一听我是抗拒的,但这个需求似乎又合情合理。作为一群有追求的开发者我们正式启动了百瓶统跳路由项目。

2. 解决哪些问题

按照惯例,启动一个项目我们先梳理痛点,整理出要解决的问题,进而确定需求。

2.1 页面跳转

页面跳转只需提供目的页面路由给统跳路由 SDK,统跳路由 SDK 就能准确无误地跳转到目的页面,而不是像传统方式那样创建出页面实例后通过导航控制器跳转。

2.2 入参携带

页面跳转时需要携带一些参数给目标页面实例,以供目标页面正确处理逻辑。

2.3 返回值回传

有时我们需要在目标页面关闭时回传一些我们感兴趣的值。如:选择收货地址页面,在选择完地址后需要把刚选中的地址信息回传给上一个页面。

2.4 回退到指定路由

有时我们需要回退到指定的页面。以发布短视频为例:首先从首页(M)进入视频拍摄页面(A),拍摄完成进入视频编辑页面(B),编辑完成进入发布页面(C),发布完视频应回退到进入拍摄页面(A)之前的页面(M)而不是视频编辑页面(B)。

2.5 统一的路由规则

基本思路

  • 一个路由对应一个页面
  • 路由应是一个有意义的字符串
  • 路由规则应适用于各端
  • 各端应把路由与页面做绑定

方案

由以上思路很容易想到我们的路由规范沿用前端路由规范即可,既符合 RestfulURI

既:URI = scheme:[//authority]path[?query][#fragment]

我们给不同领域的路由定义了对应的 scheme,即 Native: native,Flutter:flutter,http/https:http/https,小程序:wxmp,三方应用:tp(third party)等。

2.6 跨模块 API 调用

有时我们需要像向 WebServer 发起 GET 请求的方式一样访问其他模块提供的方法。传统模式下,跨模块方法互调需要引用目标模块,很繁琐,有时还会发生循环引用。

2.7 行为路由

有时我们需要为页面某个区域配置点击行为(非页面跳转行为)。如:有个 HTML5 页面需要在点击页面上某个区域时调起 Native 的分享行为。

2.8 路由拦截器

有时我们需要对路由进行鉴权、参数重整、打断点、重定向等需求。因此为统跳路由 SDK 添加拦截器功能是一个很好的解决方案。

2.9 分组路由拦截器

有时我们需要对某组路由进行鉴权、参数重整、打断点、防沉迷等需求。因此我们为统跳路由 SDK 添加分组拦截器功能。

2.10 路由重定向

随着不断迭代和技术的更新,很多页面会被其他更合适的技术重写,此时历史代码还在用老路由,如果要把老的代码都改为新路由则要考虑版本控制和改动成本以及可能引入的风险。有了重定向功能则可以无成本切入新路由。

2.11 不同技术栈之间互不干扰(保持优雅)

当把一个路由传入统跳路由 SDK 时,统跳路由 SDK 应能区分把当前路由分发至哪个路由调度器进行调度。如:Native、Flutter、HTML5 各自实现的页面应交给各自的路由调度器。

3. 提供哪些 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

3.1 注册/移除路由调度器

路由调度器用来隔离不同领域的路由,便于解除耦合。开发者使用统跳路由 SDK 时可以非常方便的定义自己的路由调度器,实现自己的路由逻辑。

Native 页面、Flutter 页面和 HTML5 页面跳转逻辑肯定存在差异,此时应有对应的路由调度器实现跳转行为。当然「行为路由」也有对应的路由调度器。

总之你可以发挥想象,尽情发挥统跳路由 SDK 的能力,你只需定义一个适合你的路由调度器。

3.2 注册/移除路由与页面的绑定关系

我们需要把路由与页面的绑定关系注册给统跳路由 SDK 以允许统跳路由 SDK 对路由进行动态解析,动态生成页面实例并实现自动跳转。

应该注意:这个注册应该允许更新,以实现路由表动态更新。

注册的不一定非要是一个页面,也可以是某个服务(Service)。如:目标页面是某个第三方提供的,只能通过调用对应 SDK 的某个方法打开,想直接注册页面是做不到的。此时我们可以注册一个服务做中转,在该服务被调用时,我们再调用 SDK 的对应方法即可轻松实现以上需求。

3.3 打开某个路由并获取回传值

统跳路由 SDK 应提供一个方法打开某个页面(或调用某个方法),并提供获取返回值的回调。

入参应有以下几个

  • uri:路由
  • parameters: 要携带的入参
  • exts:其他参数(非业务参数,如:指定转场动画方式)
  • callback:回调函数指针

3.4 回退到上个页面

统跳路由 SDK 应提供返回上个页面的方法。

3.5 回退到指定页面

统跳路由 SDK 应提供回退到回退栈内指定路由的方法并返回指定路由的实例。
统跳路由 SDK 应提供回退 N 层路由的方法。

3.6 路由未找到的处理

当消息推送了新版本特有的页面时,老版本应进入路由未找到的统一出口,可以在此处做一个重定向或提示用户升级到最新版本的操作。

4. 关键思路和规范

需求我们已经理顺了,紧接着就是设计统跳路由 SDK 的架构。

4.1 如何实现高可扩展

合理抽象和功能拆分是实现高可扩展的基础。

我们设计了路由调度器这个抽象的概念,路由调度器用来隔离不同领域的路由。

Native 路由、Flutter 路由、HTML5 路由、小程序路由等,分别有对应的路由调度器实现调度。行为路由也由对应的路由调度器实现调度。

4.2 如何避免侵入和耦合

统跳路由 SDK 立项时我们的项目已经有了一定规模,如果接入统跳路由 SDK 需要修改已有业务代码则无疑是个灾难。因此我们必须完美兼容传统开发方式,避免引入额外工作量和成员学习成本。

如你所想,我们近乎完美地兼容了传统的开发方式,详见 统跳路由 SDK(iOS 端实现)

4.3 科学管理路由表

  • 路由表集中管理
  • 版本管理(应用于动态下发路由表)
  • 路由表应标注路由名称、用途描述、入参、出参、其他额外限制(如要进入该页面需要的权限)

4.3.1 使用体验优化

通过脚本生成各端代码

避免硬编码:路由表映射为一个结构体,每个路由是一个属性,通过这种方式避免硬编码。

入参构造器:入参是一个字典,我们可以根据路由定义时的入参生成字典对应的构造器。

出参:出参是一个字典,我们可以根据路由表自动生成字典的关联属性。

版本管理:路由表仓库打 tag 后自动执行脚本生成各端代码(本文不展开)。

4.4 路由表动态下发

配置中心提供更新路由表能力,各端按约定的策略更新路由表。

5. 统跳路由 SDK(iOS 端实现)

5.1 兼容原生开发方式

以 iOS 传统开发方式为例,跳转一个新页面需要以下步骤

  1. 创建目标 ViewController 实例
  2. 入参以 ViewController 实例属性赋值方式传递
  3. 获取合适的 NavigationController 实例(若转场方式为模态,则需获取合适的 ViewController 实例)
  4. NavigationController 实例以 push 方式跳转新页面(或 ViewController 以模态方式跳转新页面)
  5. 以 block 或 delegate 方式回传值

以上方式已经能满足绝大部分场景,下面我们思考下如何以优雅的方式实现以上步骤

  1. 以键值对的方式实现 URI 与 ViewController 类的绑定,借助 Objective-C runtime 动态生成 ViewController 实例。
  2. URI 以 Query 方式携带入参(统跳路由 SDK 内部会把入参解析为 Dictionary),key 为 ViewController 属性(或实例变量)名,借助 Objective-C runtime 判断该 ViewController 类是否包含该属性或实例变量,并判断数据类型是否符合,如果符合则通过 Objective-C KVC 方式为该属性或实例变量赋值,从而实现入参传递。
  3. 通过遍历主 Window(未必是 keyWindow 要看实际情况)上的路由回退栈可以获取合适的 NavigationController 实例(present 时是栈顶 ViewController 实例)。
  4. 以上条件都具备了,此时能很容易实现页面跳转。
  5. 关于数据回传,我们可以通过 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

此刻我们会发现上面的「思路」已经被落实了,思路清晰易懂,并且完美兼容原生开发模式。从而可以使传统模式无痛渐进地切换到「路由模式」

5.2 架构

6. 如何使用

快速浏览 Demo 能更直观地了解一个框架,我们一起来看下常规用法和用途。

6.1 整体流程

初始化阶段

  • 加载路由表
  • 注册路由拦截器
  • 原生路由注册
  • 非页面路由注册
  • 分组拦截器注册

就绪阶段:此时统跳路由 SDK 已准备就绪。

6.2 页面类路由调用

自有 Native 页面

路由注册


// 注册 Objective-C 实现的 ViewController
BBRouter.register(withClassName: "MomentsViewController", andPath: BBRouterPaths.moments)

// 注册 Swift 实现的 ViewController(注意命名空间)
BBRouter.register(withClassName: swiftClassFullName("MomentsViewController", "Community"), andPath: BBRouterPaths.moments)

路由跳转


// 无返还值路由跳转
[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]);
}];

6.2 方法/行为类路由调用


// 注册行为
[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"}];

6.3 路由拦截器简单演示

参数重整: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
})

6.4 路由分组拦截器功能简单演示

这里使用分组拦截器实现一组页面需要先成功登录才能访问的需求,且实现了用户操作的连贯性。


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
}

6.5 路由未注册处理

// 可以在这里把未注册的路由信息交给 HTML5 落地页,此时就很灵活了,可以做重定向也可以提示用户升级。


BBRouter.setUndefinedRouteHandle { (parameter) in
    let url = parameter.url
    BBRouter.open(BBRouterPaths.routerNotFound, urlParams: ["url":url])
}

小结

百瓶统跳路由 SDK 使统跳成为现实,也为页面可视化搭建奠定了基础。到目前为止已交付使用一年左右,对组件化/模块化进程有重要的推动作用,很好的完成了立项时「解耦提效」的目标。更可喜的是 iOS 端能无痛渐进地从传统模式切换到「路由模式」,接入过程近乎零成本。

由于篇幅有限,很多重要地实现细节没有提到,许多应用场景也没有提到。另一方面也不希望把细节说的太透,免得先入为主,影响大家思考。

最后,真诚希望各位能指出方案的不足,并提出新的优化建议。大家如有疑问可在文章下方留言,我们会尽快回复。如果本文能对你有一点点启发也请顺手点个喜欢,如果能分享给你的朋友那就更感谢了。

更多精彩请关注我们的公众号「百瓶技术」,有不定期福利呦!

03-05 20:25