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版

  • CTMediator
  • MGJRouter

本文路由理解

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

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

你可能感兴趣的:(11 | 功能组件:如何使用路由,支持多页面导航?)