Skip to content

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

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

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

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

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

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

路由方案的架构与实现

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

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

  • 最底层是基础组件层,路由模块也属于基础组件,路由模块不依赖于任何其他组件。

  • 中间层是功能业务层,各个功能都单独封装为一个模块,他们都依赖于基础组件层,但功能层内的各个模块彼此不相互依赖,这能有效保证多个功能研发团队并行开发。

  • 最上层是 App 容器模块,它负责把所有功能模块整合起来,形成一个完整的产品。

这套路由方案主要由两大部分组成,独立的路由模块和嵌入功能模块里面的导航组件。 接下来,我们以 Moments App 为例子一起看看这套方案是怎样实现的吧。

路由模块

路由模块非常简单,主要有两个协议(Protocol)和一个类组成,如下图所示。

AppRouting 和 AppRouter

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

AppRouting协议的代码如下。

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

这个协议只有两个方法:

  1. 用于注册 Navigator(导航器)的register(path: String, navigator: Navigating)方法;

  2. 触发路由的route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)方法。

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

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

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

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

首先,我们定义一个名为RoutingSource的空协议,然后让UIViewController遵循该协议。这样就能让route(to:from:using)方法与UIViewController进行解耦。

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

swift
enum TransitionType: String {
    case show, present
}

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

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

swift
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。

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

Navigating 协议

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

swift
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 是怎样实现的。

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

InternalMenuNavigator

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

swift
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。下面是实现的代码。

swift
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里面,代码如下:

swift
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)方法进行路由。下面是如何路由到内部功能菜单页面的代码。

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

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

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

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

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

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

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

swift
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,代码如下:

swift
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里面添加以下的验证代码。

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

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

总结

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

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

思考题:

在软件开发中,只有合适的方案,没有完美的方案。基于 URL 的路有方案也有一些需要处理的难题,例如如何传递数组和大对象,请问你是怎样处理这些问题的呢?

可以把回答写到下面的留言区哦,我们一起探讨一下。下一讲将介绍如何设置多语言支持。