在由多个屏幕组成的任何应用程序中,都需要在其组件之间实现导航。 似乎这应该不成问题,因为在UIKit中有相当方便的容器组件,例如UINavigationController和UITabBarController以及灵活的模式显示方法:只需在正确的时间使用正确的导航即可。

但是,一旦应用程序通过推送通知或链接显示到屏幕的过渡,一切都会变得更加复杂。 马上有很多问题:
- 与现在在屏幕上的视图控制器怎么办?
- 如何切换上下文(例如UITabBarController中的活动标签)?
- 当前导航堆栈的屏幕是否正确?
- 什么时候应该忽略导航?

在iOS开发中,我们Badoo遇到了所有这些问题。 结果,我们将解决方案方法设计到了用于导航的组件库中,我们将其用于所有新产品。 在本文中,我将更详细地讨论我们的方法。 在一个小型
演示项目中可以看到所描述实践的应用
示例 。
我们的问题
通常,导航问题是通过添加一个全局组件来解决的,该组件知道应用程序中屏幕的结构并决定在特定情况下该怎么做。 屏幕的结构表示有关控制器的当前层次结构和应用程序部分中容器的存在的信息。
Badoo具有类似的组件。 它与Facebook相当旧的库以类似的方式工作,现在不再可以在其公共存储库中找到它。 导航基于与应用程序屏幕关联的URL。 基本上,所有逻辑都包含在一个类中,该类与选项卡栏的存在以及Badoo特定的其他一些功能有关。 该组件的复杂性和连通性是如此之高,以至于解决需要更改导航逻辑的任务所花费的时间可能比计划的时间长几倍。 该类的可测试性也提出了很大的问题。
该组件是在只有一个应用程序时创建的。 我们无法想象,将来会开发出彼此完全不同的几种产品(
Bumble ,
Lumen和其他产品)。 因此,我们最成熟的应用程序Badoo中的导航器无法在其他产品中使用,并且每个团队都必须提出一些新的东西。
不幸的是,对于特定的应用,新的方法也得到了改进。 随着项目数量的增加,问题变得很明显,并提出了创建一个库的想法,该库将提供一组特定的组件,包括通用导航逻辑。 这将有助于最大程度地减少新产品中类似功能的实施时间。
我们实现了通用路由器
全局导航器解决的主要任务并不多:
- 查找当前的活动屏幕。
- 以某种方式将活动屏幕的类型及其内容与需要显示的内容进行比较。
- 根据需要执行过渡(过渡顺序)。
任务的制定也许看起来有点抽象,但是正是这种抽象使逻辑的通用化成为可能。
1.活动屏幕搜索
第一项任务似乎非常简单:您只需要遍历整个屏幕层次结构,然后找到顶部的
UIViewController即可 。

我们对象的接口可能看起来像这样:
protocol TopViewControllerProvider { var topViewController: UIViewController? { get } }
但是,尚不清楚如何确定层次结构的根元素以及如何处理UIPageViewController等容器屏幕和特定于应用程序的容器。
确定根元素的最简单选择是从活动屏幕中获取根控制器:
UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
这种方法可能并不总是适用于有多个窗口的应用程序。 但这是一种相当罕见的情况,可以通过显式传递所需窗口作为参数来解决该问题。
可以通过为容器屏幕创建特殊的协议来解决容器屏幕的问题,该协议将包含获取活动屏幕的方法,或者您可以使用上面宣布的协议。 应用程序中使用的所有容器控制器必须实现此协议。 例如,对于
UITabBarController,实现可能如下所示:
extension UITabBarController: TopViewControllerProvider { var topViewController: UIViewController? { return self.selectedViewController } }
剩下的只是遍历整个层次结构并获得顶部屏幕。 如果下一个控制器实现了TopViewControllerProvider,我们将通过声明的方法在其上显示屏幕。 否则,将自动检查其上显示的控制器(如果有)。
2.当前情况
确定当前上下文的任务看起来要复杂得多。 我们要确定屏幕的类型,并可能确定在其上显示的信息。 创建包含此信息的结构似乎是合乎逻辑的。
但是哪些类型应该具有对象属性? 我们的最终目标是将上下文与需要显示的内容进行比较,因此他们应该实现
Equatable协议。 这可以通过通用类型实现:
struct ViewControllerContext<ScreenType: Equatable, InfoType: Equatable>: Equatable { let screenType: ScreenType let info: InfoType? }
但是,由于Swift的特性,这对使用此类型施加了某些限制。 为避免出现问题,我们的应用程序中的这种结构外观略有不同:
protocol ViewControllerContextInfo { func isEqual(to info: ViewControllerContextInfo?) -> Bool } struct ViewControllerContext: Equatable { public let screenType: String public let info: ViewControllerContextInfo? }
另一个选择是利用Swift的新功能
Opaque Types ,但是它仅从iOS 13开始可用,这对于许多产品还是不可接受的。
上下文比较的实现非常明显。 为了不为已经实现了Equatable的类型编写isEqual函数,您可以做一个简单的技巧,这次使用Swift的优点:
extension ViewControllerContextInfo where Self: Equatable { func isEqual(to info: ViewControllerContextInfo?) -> Bool { guard let info = info as? Self else { return false } return self == info } }
太好了,我们有一个比较的对象。 但是如何将其与
UIViewController相关联? 一种方法是使用
关联对象 ,这在某些情况下是Objective C语言的有用功能,但首先,它不是很明确,其次,通常我们只想比较某些应用程序屏幕的上下文。 因此,创建协议看起来是个好主意:
protocol ViewControllerContextHolder { var currentContext: ViewControllerContext? { get } }
及其仅在必要的屏幕中实施。 如果活动屏幕未实现此协议,则可以认为它的内容不重要,并且在显示新协议时不会考虑该内容。
3.过渡执行
让我们看看我们已经拥有了什么。 随时可以以特定数据结构的形式获取有关活动屏幕信息的能力。 通过打开的URL,推送通知或其他启动导航的方式从外部接收的信息,可以转换为相同类型的结构并用作导航意图。 如果顶部屏幕已经显示了必要的信息,那么您可以简单地忽略导航或更新屏幕内容。

但是过渡本身呢?
合理的做法是制作一个组件(将其称为
路由器 ),该组件将采用您需要在输入中显示的内容,将其与已经显示的内容进行比较,然后执行转换或转换序列。 另外,路由器可能包含用于处理和验证信息以及应用程序状态的通用逻辑。 最主要的是,您不应在此组件中包括特定于域或应用程序功能的逻辑。 如果您遵守此规则,它将可以在不同的应用程序中重复使用,并且易于维护。
这种协议的基本接口声明如下所示:
protocol ViewControllerContextRouterProtocol { func navigateToContext(_ context: ViewControllerContext, animated: Bool) }
您可以通过传递一系列上下文来概括以上功能。 这不会对实施产生重大影响。
很明显,路由器将需要一个控制器工厂,因为仅在其输入处接收导航数据。 必须在工厂内部创建单独的屏幕,甚至可能根据所传送的上下文创建整个模块。 在
screenType字段中,
您可以从
info字段中确定要创建的屏幕-需要填充哪些数据:
protocol ViewControllersByContextFactory { func viewController(for context: ViewControllerContext) -> UIViewController? }
如果该应用程序不是Snapchat克隆,则很可能用于显示新控制器的方法数量将很少。 因此,对于大多数应用程序而言,更新
UINavigationController堆栈并显示模式屏幕就足够了。 在这种情况下,您可以使用可能的类型定义枚举,例如:
enum NavigationType { case modal case navigationStack case rootScreen }
屏幕的类型取决于其显示方式。 如果这是阻止通知,则需要以模态显示。 可能需要通过
UINavigationController将另一个屏幕添加到现有的导航堆栈中。
决定如何显示特定屏幕最好不在路由器本身中。 如果我们在
ViewControllerNavigationTypeProvider协议下添加路由器的依赖关系并实现特定于每个应用程序的所需方法集,那么我们将实现此目标:
protocol ViewControllerNavigationTypeProvider { func navigationType(for context: ViewControllerContext) -> NavigationType }
但是,如果我们想在其中一个应用程序中引入一种新型的导航,该怎么办? 需要为枚举添加一个新选项,所有其他应用程序都将知道吗? 在某些情况下,这也许正是我们的目标,但是如果您遵循
开放原则 ,那么为了获得更大的灵活性,您可以输入可以执行转换的对象的协议:
protocol ViewControllerContextTransition { func navigate(from source: UIViewController?, to destination: UIViewController, animated: Bool) }
然后,
ViewControllerNavigationTypeProvider将变为:
protocol ViewControllerContextTransitionProvider { func transition(for context: ViewControllerContext) -> ViewControllerContextTransition }
现在,我们不仅限于一组固定的屏幕显示类型,而且可以扩展导航功能,而无需更改路由器本身。
有时,您无需创建新的
UIViewController即可切换到某个屏幕,只需切换到现有的屏幕即可。 最明显的例子是在
UITabBarController中切换选项卡。 另一个示例是过渡到所示控制器堆栈中的现有元素,而不是创建具有相同内容的新屏幕。 为此,在路由器中,在创建新的
UIViewController之前
,您可以首先检查是否可以简单地切换上下文。
如何解决这个问题? 更多抽象!
protocol ViewControllerContextSwitcher { func canSwitch(to context: ViewControllerContext) -> Bool func switchContext(to context: ViewControllerContext, animated: Bool) }
对于选项卡,此协议可以由知道
UITabBarViewController包含的内容的组件实现,并且可以将
ViewControllerContext映射到特定的选项卡并切换选项卡。

一组此类对象可以作为依赖项传递到路由器。
总而言之,上下文处理算法将如下所示:
func navigateToContext(_ context: ViewControllerContext, animated: Bool) { let topViewController = self.topViewControllerProvider.topViewController if let contextHolder = topViewController as? ViewControllerContextHolder, contextHolder.currentContext == context { return } if let switcher = self.contextSwitchers.first(where: { $0.canSwitch(to: context) }) { switcher.switchContext(to: context, animated: animated) return } guard let viewController = self.viewControllersFactory.viewController(for: context) else { return } let navigation = self.transitionProvider.navigation(for: context) navigation.navigate(from: self.topViewControllerProvider.topViewController, to: viewController, animated: true) }
以UML图的形式呈现路由器依赖关系图很方便:

生成的路由器可用于自动启动的过渡或通过用户操作启动的过渡。 在我们的产品中,如果导航不是自动发生的,则使用标准的系统功能,并且大多数模块都不知道是否存在全局路由器。 记住在必要时必须执行
ViewControllerContextHolder协议的重要性,这样路由器才能始终找出用户当前所看到的信息。
优缺点
最近,我们开始将描述的导航管理方法引入Badoo产品。 尽管事实证明实现比
演示项目中介绍的选项要复杂一些,但我们对结果感到满意。 让我们评估上述方法的优缺点。
其中的好处包括:
- 普遍性
- 与“替代方案”部分中介绍的选项相比,相对易于实施,
- 在应用程序的架构以及屏幕之间常规导航的实现方面没有限制。
缺点部分是优点的结果。
- 控制器需要知道他们显示了什么信息。 如果考虑应用程序的体系结构,则应该将UIViewController分配给显示层,并且不应将业务逻辑存储在该层中。 包含导航上下文的数据结构必须从业务逻辑层在那里实现,但是尽管如此,控制器仍将存储此信息,这不是很正确。
- 有关应用程序状态的真相的来源是所显示屏幕的层次结构,在某些情况下可能是一个限制。
替代品
此方法的替代方法是手动构建活动模块的层次结构。 这种解决方案的一个示例是协调器模式的
实现 ,其中协调器形成一个树结构,用作确定活动屏幕的真相源,而显示此屏幕或不显示该屏幕的决定逻辑包含在协调器本身中。
在我们的Android团队
使用的
RIBs体系结构中可以找到类似的想法。
这样的替代方案提供了更灵活的抽象,但是需要架构上的统一性,并且对于许多应用而言可能过于繁琐。
如果您采用其他方法来解决此类问题,请在评论中毫不犹豫地谈论它。