[toc]
前言
本文来自拉勾网课程整理
随着App
功能的不断丰富,以内容和体验为导向的导航模式变得越来越流行。这种导航模式的特点是一个页面可以导航到任意一个其他的页面。
比如在iOS
里使用UIKit
来实现导航功能时,源ViewController
需要知道目标 ViewController
的类型信息,换句话说就是源 ViewController
必须直接依赖目标 ViewController
。这会导致什么问题呢?如果 App
的多个模块之间需要相互导航,那么它们之间就会产生循环依赖
,如下图所示。
假如随着Moments App
不断发展,除了朋友圈功能以外,我们还可能新增商城功能和实时通讯功能。当用户点击朋友圈信息的时候可以打开商品信息页面,当点击朋友头像时可以进入实时通讯
页面。而在商品信息页面里面,用户还可以打开朋友圈页面进行分享。
这种模块之间的循环依赖会引起一系列的问题,比如因为代码强耦合
,导致代码变得难以维护。如果不同功能由不同产品研发团队负责开发与维护,循环依赖还会增加很多的沟通成本,每次一点小改动都需要通知其他团队进行更新。
那么,有没有什么好的办法解决这种问题呢?
路由方案的架构与实现
我们可以使用一套基于 URL
的路由方案来解决多个模块之间的导航问题。下面是这套路由方案的架构图
。
这个架构分成三层
,因为上层组件依赖于下层组件,我们从下往上来看。
-
最底层
是基础组件层,路由模块也属于基础组件,路由模块不依赖于任何其他组件。 -
中间层
是功能业务层,各个功能都单独封装为一个模块,他们都依赖于基础组件层,但功能层内的各个模块彼此不相互依赖,这能有效保证多个功能研发团队并行开发。 -
最上层
是App
容器模块,它负责把所有功能模块整合
起来,形成一个完整的产品。
我们先来看路由模块里的AppRouting
和AppRouter
。其中,AppRouting
协议定义了路由模块的接口而AppRouter
是AppRouting
协议的实现类。
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
)的方式来呈现。
至于AppRouter
是AppRouting
协议的实现类,其他的具体代码如下:
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
的私有属性navigators
。navigators
是一个字典类型,它的Key
是字符串类型,用于保存URL
的路径值。而所存储的值是具体的 Navigator
的实例。
然后,AppRouter
实现了register
和route
两个方法。register
方法的实现非常简单,就是把path
和navigator
存到私有属性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 协议
除了AppRouting
和AppRouter
以外,路由模块的核心还包含了一个叫作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
的时候,该方法会调用UIViewController
的show(_ vc: UIViewController, sender: Any?)
方法进行导航。在调用show
方法的时候,iOS
系统会判断sourceViewController
是存放在NavigationController
还是 SplitViewController
里面,并触发相应的换场(Transition)
动作。例如当sourceViewController
存放在 NavigationController
里面的时候就会把destinationViewController
推进 NavigationController
的栈(Stack)
里面。
当transitionType
为.present
的时候,我们就调用UIViewController
的present(_ 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
。下面是它的具体代码实现。
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
不一样的地方是,DesignKitDemoNavigator
从parameters
中取出了productName
和versionNumber
两个参数的值,然后传递给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())
从上面可以看到,我们通过传递path
和navigator
的实例来注册路由信息。注册完毕以后,各个功能模块就可以调用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
的方式把productName
和version
参数传递给目标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
取出来。如果验证都通过,就可以调用AppRouting
的route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)
方法进行路由。
在调用route
方法的时候,我们把nil
传递给routingSource
并指定换场方式为.present
。这样路由模块就会通过模态窗口把目标ViewController
呈现出来。
如果App
已经使用Scene
,例如我们的 Moments App
,那么我们需要修改SceneDelegate
的scene(_ 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
是否为开发环境或者测试环境的版本,如果“不是”,说明当前的 App
是 App Store
版本,我们就直接退出,不允许打开内部功能菜单。
总结
在本文介绍了一个基于URL
的通用路由方案的实现方式,有了这个路由方案,不但可以帮助所有功能模块的解耦,而且能很方便地支持 Universal Links
。
当我们的 App
支持 Universal Links
以后,需要特别注意对路由的URL
进行验证,否则会很容易被外部系统进行攻击。这些验证的手段包括不应该允许 Universal Links
更新或者删除数据,不允许Universal Links
访问任何敏感数据。
路由推荐OC版
- CTMediator
- MGJRouter
本文路由理解
- 注册register,key为URL,value跳转的控制器初始化
- route to为路由转发,需要跳转,
- 拿到Key 和 当前控制器
- 解析处理当前key对应的控制器和参数
- 解析处理当前key对应的控制器去调用navigate(from: (控制器内实现)
- 解析得到fromVC 和 toVC
- 拿到fromVC 调用show或者present去调整toVC
遗留了一个问题,就是参数如果传递过去