11 | 功能组件:如何使用路由,支持多页面导航?

[toc]

前言

本文来自拉勾网课程整理

随着App功能的不断丰富,以内容和体验为导向的导航模式变得越来越流行。这种导航模式的特点是一个页面可以导航到任意一个其他的页面。

比如在iOS里使用UIKit来实现导航功能时,源ViewController 需要知道目标 ViewController的类型信息,换句话说就是源 ViewController必须直接依赖目标 ViewController。这会导致什么问题呢?如果 App的多个模块之间需要相互导航,那么它们之间就会产生循环依赖,如下图所示。

9c35d66619bdbace9b89348ad3338a75

假如随着Moments App 不断发展,除了朋友圈功能以外,我们还可能新增商城功能和实时通讯功能。当用户点击朋友圈信息的时候可以打开商品信息页面,当点击朋友头像时可以进入实时通讯页面。而在商品信息页面里面,用户还可以打开朋友圈页面进行分享。

这种模块之间的循环依赖会引起一系列的问题,比如因为代码强耦合,导致代码变得难以维护。如果不同功能由不同产品研发团队负责开发与维护,循环依赖还会增加很多的沟通成本,每次一点小改动都需要通知其他团队进行更新。

那么,有没有什么好的办法解决这种问题呢?

路由方案的架构与实现

我们可以使用一套基于 URL的路由方案来解决多个模块之间的导航问题。下面是这套路由方案的架构图

443eab49e63eb1e9c418a738811064c7

这个架构分成三层,因为上层组件依赖于下层组件,我们从下往上来看。

  • 最底层是基础组件层,路由模块也属于基础组件,路由模块不依赖于任何其他组件。
  • 中间层是功能业务层,各个功能都单独封装为一个模块,他们都依赖于基础组件层,但功能层内的各个模块彼此不相互依赖,这能有效保证多个功能研发团队并行开发。
  • 最上层App 容器模块,它负责把所有功能模块整合起来,形成一个完整的产品。

我们先来看路由模块里的AppRoutingAppRouter。其中,AppRouting协议定义了路由模块的接口而AppRouterAppRouting协议的实现类。

AppRouting协议的代码如下。

protocol AppRouting {
    func register(path: String, navigator: Navigating)
    func route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)
}

这个协议只有两个方法:

  • 用于注册 Navigator(导航器)的register(path: String, navigator: Navigating)方法;
  • 触发路由的route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)方法。

其中route(to:from:using)方法接收三个参数。

第一个是 URL,我们整套路由系统都是基于URL的,因此需要把URL传递进来进行导航。

第二个是类型为RoutingSource的参数,该RoutingSource是一个协议,代码如下:

protocol RoutingSource: class { }
extension UIViewController: RoutingSource { }

第三个参数是TransitionType类型。代码如下:

enum TransitionType: String {
    case show, present
}

TransitionType是一个枚举(enum)类型,用于表示导航过程中的转场动作。show用于把新的目标 ViewController 推进(push)到当前的UINavigationController里面。而present会把新的目标ViewController通过模态窗口(modal)的方式来呈现。

至于AppRouterAppRouting协议的实现类,其他的具体代码如下:

final class AppRouter: AppRouting {
    static let shared: AppRouter = .init()
    private var navigators: [String: Navigating] = [:]
    private init() { }
    func register(path: String, navigator: Navigating) {
        navigators[path.lowercased()] = navigator
    }
    func route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType = .present) {
        guard let url = url, let sourceViewController = routingSource as? UIViewController ?? UIApplication.shared.rootViewController else { return }
        let path = url.lastPathComponent.lowercased()
        guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
        let parameters: [String: String] = (urlComponents.queryItems ?? []).reduce(into: [:]) { params, queryItem in
            params[queryItem.name.lowercased()] = queryItem.value
        }
        navigators[path]?.navigate(from: sourceViewController, using: transitionType, parameters: parameters)
    }
}


AppRouter首先定义了一个用于存储各个Navigator的私有属性navigatorsnavigators是一个字典类型,它的Key是字符串类型,用于保存URL的路径值。而所存储的值是具体的 Navigator的实例。

然后,AppRouter实现了registerroute两个方法。register方法的实现非常简单,就是把pathnavigator存到私有属性navigators里面。接着我详细介绍一下route方法的实现。

因为整套路由方案都是基于URL进行导航,因此在该方法里面,首先需要检测url是否为空,如果为空就直接返回了,然后把routingSource向下转型 (downcast) 为UIViewController,如果为空就使用rootViewController作为sourceViewController来表示导航过程中的源ViewController

这些检验都通过以后,我们从url来取出path作为导航的 Key,同时从 Query String 里面取出parameters并作为参数传递给目标ViewController

最后一步是根据path从navigators属性中取出对应的 Navigator,然后调用其navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String])方法进行导航。

Navigating 协议

除了AppRoutingAppRouter以外,路由模块的核心还包含了一个叫作Navigating的协议。它负责具体的导航工作,下面我们一起看看这个协议的定义与实现吧。


protocol Navigating {
    func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String])
}
extension Navigating {
    func navigate(to destinationViewController: UIViewController, from sourceViewController: UIViewController, using transitionType: TransitionType) {
        switch transitionType {
        case .show:
            sourceViewController.show(destinationViewController, sender: nil)
        case .present:
            sourceViewController.present(destinationViewController, animated: true)
        }
    }
}


Navigating协议负责桥接路由模块和其他功能模块,它只定义了一个名叫navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String])的方法供AppRouter来调用。

同时我们也给Navigating定义了一个叫作navigate(to destinationViewController: UIViewController, from sourceViewController: UIViewController, using transitionType: TransitionType)的扩展方法 (Extension method) 来统一封装导航的处理逻辑。

transitionType.show的时候,该方法会调用UIViewControllershow(_ vc: UIViewController, sender: Any?)方法进行导航。在调用show方法的时候,iOS 系统会判断sourceViewController是存放在NavigationController 还是 SplitViewController 里面,并触发相应的换场(Transition)动作。例如当sourceViewController存放在 NavigationController 里面的时候就会把destinationViewController推进 NavigationController 的栈(Stack)里面。

transitionType.present的时候,我们就调用UIViewControllerpresent(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil)方法进行导航。在调用present方法的时候,iOS 系统会把destinationViewController通过模态窗口的方式呈现。

有了Navigating协议以后,我们看看功能模块是怎样关联到路由模块的。

导航组件

所有功能模块都通过 Navigator 类型为路由模块提供导航功能。一个目标ViewController 对应一个 Navigator。假如商城模块有商城主页和商品信息页面两个ViewController,那么商城模块就需要提供两个 Navigtor 来分别导航到这两个ViewController。

下面我们以 Moments App 中内部隐藏功能菜单模块为例子,看看 Navigator是怎样实现的。

625b67bd101b5ee887bf872a12f037e1

内部隐藏功能菜单模块有两个 ViewController,因此需要定义两个不同的 Navigator。它们都遵循了Navigating协议

InternalMenuNavigator

InternalMenuNavigator负责导航到InternalMenuViewController。下面是它的具体代码实现。

struct InternalMenuNavigator: Navigating {
    func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String : String]) {
        let navigationController = UINavigationController(rootViewController: InternalMenuViewController())
        navigate(to: navigationController, from: viewController, using: transitionType)
    }
    }

从代码可以看到,InternalMenuNavigator的实现非常简单。首先,初始化InternalMenuViewController的实例,然后把该实例放置到一个UINavigationController里面。接下来我们调用Navigating的扩展方法navigate(to destinationViewController: UIViewController, from sourceViewController: UIViewController, using transitionType: TransitionType)来进行导航。

DesignKitDemoNavigator

DesignKitDemoNavigator负责导航到DesignKitDemoViewController。下面是实现的代码。

struct DesignKitDemoNavigator: Navigating {
    func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String]) {
        guard let productName = parameters["productname"], let versionNumber = parameters["version"] else {
            return
        }
        let destinationViewController = DesignKitDemoViewController(productName: productName, versionNumber: versionNumber)
        navigate(to: destinationViewController, from: viewController, using: transitionType)
    }
}

InternalMenuNavigator不一样的地方是,DesignKitDemoNavigatorparameters中取出了productNameversionNumber两个参数的值,然后传递给DesignKitDemoViewController进行初始化。最后也是调用Navigating的扩展方法navigate(to:from:using:)进行导航。

路由方案的使用

以上是有关路由方案的架构和实现,有了这个路由方案以后,那我们该如何使用它呢?接下来我将从它的注册与调用、Universal Links的路由和验证来介绍下。

路由的注册与调用

因为App容器模块依赖所有的功能模块和路由模块,我们可以把路由注册的逻辑放在该模块的AppDelegate里面,代码如下:

let router: AppRouting = AppRouter.shared
router.register(path: "InternalMenu", navigator: InternalMenuNavigator())
router.register(path: "DesignKit", navigator: DesignKitDemoNavigator())

从上面可以看到,我们通过传递pathnavigator的实例来注册路由信息。注册完毕以后,各个功能模块就可以调用route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)方法进行路由。下面是如何路由到内部功能菜单页面的代码。

router.route(to: URL(string: "\(UniversalLinks.baseURL)InternalMenu"), from: rootViewController, using: .present)


路由的过程中需要传入一个 URL,源 ViewController 以及换场的类型三个参数。

下面是路由到 DesignKit范例页面的具体代码。

router.route(to: URL(string: "\(UniversalLinks.baseURL)DesignKit?productName=DesignKit&version=1.0.1"), from: routingSourceProvider(), using: .show)

这个例子中,我们通过 Query String 的方式把productNameversion参数传递给目标ViewController

Universal Links 的路由

我们之所以选择基于 URL 的路由方案,其中的一个原因是对 Universal Links 的支持。当我们的App支持Universal Links以后,一旦用户在 iOS设备上打开 Universal Links 所支持的 URL时,就会自动打开我们的App

根据 App 是否支持Scenes来区分,目前在 UIKit里面支持 Universal Links有两种方式。如果 App还不支持Scenes的话,我们需要在AppDelegate里面添加Universal Links 的支持的代码,如下所示:

func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
        let incomingURL = userActivity.webpageURL else {
        return false
    }
    let router: AppRouting = AppRouter.shared
    router.route(to: incomingURL, from: nil, using: .present)
    return true
}


我们首先检查userActivity.activityType是否为NSUserActivityTypeBrowsingWeb,并把URL取出来。如果验证都通过,就可以调用AppRoutingroute(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)方法进行路由。

在调用route方法的时候,我们把nil传递给routingSource并指定换场方式为.present。这样路由模块就会通过模态窗口把目标ViewController 呈现出来。

如果App已经使用Scene,例如我们的 Moments App,那么我们需要修改SceneDelegatescene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)方法来支持 Universal Links,代码如下:

if let userActivity = connectionOptions.userActivities.first,
    userActivity.activityType == NSUserActivityTypeBrowsingWeb,
    let incomingURL = userActivity.webpageURL {
    let router: AppRouting = AppRouter.shared
    router.route(to: incomingURL, from: nil, using: .present)
}

从代码可见,当我们从connectionOptions取出userActivity以后,后面的处理逻辑和上面AppDelegate的实现方式一模一样,在这里我就不赘述了。

路由的验证

当我们的 App 支持 Universal Links以后,我们需要在Navigator里面增加一些验证的代码,否则可能会引起外部系统的攻击,例如 Moments App 的内部隐藏功能菜单不想给 App Store 用户使用,我们可以在InternalMenuNavigator里面添加以下的验证代码。

let togglesDataStore: TogglesDataStoreType = BuildTargetTogglesDataStore.shared
guard togglesDataStore.isToggleOn(BuildTargetToggle.debug) || togglesDataStore.isToggleOn(BuildTargetToggle.internal) else {
    return
}

这段代码会检查当前的 App是否为开发环境或者测试环境的版本,如果“不是”,说明当前的 AppApp Store 版本,我们就直接退出,不允许打开内部功能菜单。

总结

在本文介绍了一个基于URL的通用路由方案的实现方式,有了这个路由方案,不但可以帮助所有功能模块的解耦,而且能很方便地支持 Universal Links

5b16dbe336d8457fcaf665e459ee3955

当我们的 App 支持 Universal Links 以后,需要特别注意对路由的URL进行验证,否则会很容易被外部系统进行攻击。这些验证的手段包括不应该允许 Universal Links更新或者删除数据,不允许Universal Links访问任何敏感数据。

路由推荐OC版

本文路由理解

  1. 注册register,key为URL,value跳转的控制器初始化
  2. route to为路由转发,需要跳转,
    1. 拿到Key 和 当前控制器
    2. 解析处理当前key对应的控制器和参数
    3. 解析处理当前key对应的控制器去调用navigate(from: (控制器内实现)
    4. 解析得到fromVC 和 toVC
  3. 拿到fromVC 调用show或者present去调整toVC

遗留了一个问题,就是参数如果传递过去

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容