RouteComposer
RouteComposer
是一个基于协议、Cocoa UI抽象的库,它帮助处理iOS应用程序中视图控制器组合、导航和深链接任务。
可以作为Coordinator模式的全能替代。
目录
导航关注点
在iOS应用程序中,有两种实现导航的方法
- 苹果提供的内建机制,使用故事板和片段
- 直接在代码中进行的编程式导航
这两种解决方案的缺点
- 内置机制:在故事板中的导航相对静态,通常需要在
UIViewController
中加入额外的导航代码,从而导致大量的模板代码。 - 编程导航:强制
UIViewController
耦合或者根据选用的设计模式(路由器、协调器)可能变得复杂。
RouteComposer有助
- 简化应用程序导航的逻辑步骤的分割。
- 以声明性方式提供导航配置,解决大多数导航情况。
- 从
UIViewController
中删除导航代码。 - 根据应用状态以不同的方式组合
UIViewController
。 - 使每个
UIViewController
都能作为默认的深度链接使用。 - 简化使用不同导航和布局模式的用户界面A/B测试的创建。
- 能够与iOS应用程序中存在的任何其他导航机制并行工作:内置或定制的。
安装
RouteComposer可通过CocoaPods获取。要安装,只需将以下行添加到您的Podfile中
pod 'RouteComposer'
针对Xcode 10.1 / Swift 4.2的支持
pod 'RouteComposer', '~> 1.4'
然后运行pod install
。
成功集成后,只需将以下声明添加到您想使用RouteComposer的任何Swift文件中
import RouteComposer
查看包含的示例应用,因为它涵盖了大多数通用用例。
示例
要运行示例项目,请克隆仓库,然后在示例目录中首先运行pod install
。
要求
使用此库没有具体要求。但如果你要实现自定义容器和操作,你应该熟悉库的概念和UIKit中视图控制器导航堆栈的复杂性。
API文档
用户评价
Viz.ai
在Viz.ai——领先的同步中风护理服务公司,我们决定更换整个导航系统,并知道我们必须解决复杂和动态的导航场景。协调器和其他流程控制库都无法满足我们的需求,导致应用逻辑和导航混淆,或者创建庞大的协调器类。RouteComposer非常适合我们,实际上,正如库的创建者所说,它确实是任何当前使用的协调器代码的替代品。
这个库关心事项的分离是非常精致的,就像一切天才的东西一样,它都像魔法一样运作。虽然学习曲线有些小,但它的回报超过了协调器和流程控制器,一旦实施,它将节省大量编码时间。
这使得在应用中的导航变得简单至极,只需说出“去x,带y”,无需担心当前状态或堆栈。我全心全意地推荐它。
Elazar Yifrach,Viz.ai高级iOS开发者
哈德逊湾公司
在我们的iOS应用中,我们希望为用户提供无缝的体验,以确保他们无论点击推送通知还是电子邮件中的链接,都能无缝地到达应用中所需视图,无论应用状态如何。
我们在代码中尝试了一种编程导航方法,并尝试依赖几个其他库。然而,这似乎都无济于事。RouteComposer最初看起来过于复杂,因此并不是我们的首选。幸运的是,它最终证明是一个非常棒且优雅的解决方案。我们开始使用它不仅用于处理外部深度链接,也用于处理应用内部导航。它还成为了一个在针对不同用户有不同的导航模式时进行UI A/B测试的出色工具。它为我们节省了大量时间,我们非常喜爱其背后的逻辑。
库的制作者是超级响应的,解答了我们所询问的所有问题。我衷心推荐它!
Alexandra Mikhailouskaya,哈德逊湾公司高级骨干工程师
B.W.A.,一家拥有130年历史的零售银行。
最近,我们对应用进行了第五次也是最大的一次更新,这次更新涉及从零开始重构用户导航。我们从一个现有的(长度为六行的)协调器的简单迁移开始,在我们的一位高级开发人员建议我们先试验RouteComposer之前。原型验证很有挑战性,但Eugene Kazayev毫不犹豫地将自己贡献出来,帮助我们将RouteComposer整合到我们现有的企业级代码库中。当所有部件都到位时,结果简单至极。
我们的其他开发人员已经接受了RouteComposer,用它来代替segue、unwind segue、手动推送、弹出和模态弹出,我们在应用中的导航变得非常愉快。
感谢Eugene的大力帮助。skooter Martin,B.W.A.高级移动工程师
赞助此项目
如果您喜欢这个库,尤其是如果您正在生产环境中使用它,请考虑在此处赞助此项目。我在业余时间开发RouteComposer。赞助将帮助我继续在此项目上工作,并为开源社区做出贡献。
使用方式
RouteComposer使用3个主要实体(Factory
、Finder
、Action
),这些实体应由宿主应用程序定义以支持它。同时,它还提供了3个辅助实体(RoutingInterceptor
、ContextTask
、PostRoutingTask
),您可以根据需要实现这些实体以处理路由过程中的某些默认操作。以下是每个实体描述中带的2个关联类型:
ViewController
- 视图控制器的类型。例如:UINavigationController、CustomViewController等。Context
- 被宿主应用程序传递给路由器的上下文对象类型,该路由器将将其传递给将要构建的视图控制器。例如:String、UUID、Any等。可以是可选的。
注意:
Context
表示您需要传递到您的UIViewController
中的有效负载,以及使它区别于其他内容的东西。它不是ViewModel或某种类型的Presenter。它是缺失的信息。如果您的视图控制器需要productID
来显示其内容,而productID
是一个UUID,那么Context
的类型就是UUID。内部逻辑属于视图控制器。Context
回答以下问题:我需要什么来展示ProductViewController和我是否已经在为该产品展示ProductViewController。
实现
1. 工厂
工厂负责在请求时导航到路由器的 构建视图控制器。每个工厂实例必须实现 Factory
协议。
public protocol Factory {
associatedtype ViewController: UIViewController
associatedtype Context
func build(with context: Context) throws -> ViewController
}
这里最重要的功能是 build
,它实际上应该创建视图控制器。有关详细信息,请参阅文档。 prepare
方法为你提供在路由实际发生之前执行某事的方式。例如,你可以在该函数内部 抛出异常
以通知路由器你没有显示视图所需的数据。如果你在应用程序中实现通用链接并且无法处理路由,这可能会很有用,在这种情况下,应用程序可能会在 Safari 中打开提供的 URL。
示例:某个自定义 ProductViewController
视图控制器工厂的基本实现可能如下
class ProductViewControllerFactory: Factory {
func build(with productID: UUID) throws -> ProductViewController {
let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil)
productViewController.productID = productID // Parameter initialisation can be handled by a ContextAction, see below:
return productViewController
}
}
重要提示:在 Xcode 10.2 中自动 associatedtype
解决方案已损坏,你必须使用 typealias
关键字手动设置关联类型。Swift 编译器 错误 已报告。
2. 查找器
查找器帮助路由器确定视图控制器图中是否已经存在特定的视图控制器。所有查找器实例都应该遵循 Finder
协议。
public protocol Finder {
associatedtype ViewController: UIViewController
associatedtype Context
func findViewController(with context: Context) throws -> ViewController?
}
在某些情况下,你可能可以使用库提供的默认查找器。在其他情况下,当图中可以存在多个同一类型的视图控制器时,你可能需要实现自己的查找器。该库包含名为 StackIteratingFinder
的协议实现,该实现有助于解决视图控制器图中的迭代并处理它。你只需实现函数 isTarget
以确定是否为你要找的视图控制器。
ProductViewControllerFinder
的示例,它可以帮助路由器查找在视图控制器堆栈中显示特定产品的 ProductViewController
。
class ProductViewControllerFinder: StackIteratingFinder {
let iterator: StackIterator = DefaultStackIterator()
func isTarget(_ productViewController: ProductViewController, with productID: UUID) -> Bool {
return productViewController.productID == productID
}
}
SearchOptions
是一个枚举,它通知 StackIteratingFinder
如何在搜索时遍历图。请参阅文档。
3. 动作
动作实例解释给路由器,一个由代码创建的视图控制器应该通过 Factory
集成到视图控制器堆栈中。很可能,你不需要自己实现动作,因为库提供了大多数在 UIKit
中可以执行的动作,例如(GeneralAction.presentModally
、UITabBarController.add
、UINavigationController.push
等)。如果你正在做一些不寻常的事情,你可能需要自己实现动作。
查看示例应用以查看自定义动作的实现。
示例:因为你很可能不需要实现自己的动作,让我们看看库提供的 PresentModally
的实现。
class PresentModally: Action {
func perform(viewController: UIViewController, on existingController: UIViewController, animated: Bool, completion: @escaping (_: RoutingResult) -> Void) {
existingController.present(viewController, animated: animated, completion: {
completion(.success)
})
}
}
4. 路由拦截器
路由拦截器将在 路由器开始路由到目标视图控制器之前使用。 例如,为了导航到某个特定的视图控制器,用户可能需要登录。你可以创建一个实现 RoutingInterceptor
协议的类,如果用户没有登录,它将显示一个登录视图控制器,用户可以在该视图中登录。如果此过程成功完成,拦截器应该通知路由器,然后它将继续路由,否则停止路由。请参阅示例应用以获取详细信息。
示例:如果用户已登录,则路由器可以继续路由。如果用户未登录,则路由器不应该继续。
class LoginInterceptor<C>: RoutingInterceptor {
func perform(with context: C, completion: @escaping (_: RoutingResult) -> Void) {
guard !LoginManager.sharedInstance.isUserLoggedIn else {
completion(.failure("User has not been logged in."))
return
// Or present the LoginViewController. See Example app for more information.
}
completion(.success)
}
}
5. 上下文任务
如果您正在使用库提供的默认Factory
和Finder
实现,您仍然需要将数据设置到视图控制器上下文中。即使它已经在堆栈中存在,即使它只是由Factory
创建或在路由器找到/创建视图控制器时执行其他操作,您也必须这样做。只需实现ContextTask
协议即可。
示例:即使ProductViewController
已经显示在屏幕上或即将创建,您也必须设置productID以显示产品。
class ProductViewControllerContextTask: ContextTask {
func perform(on productViewController: ProductViewController, with productID: UUID) {
productViewController.productID = productID
}
}
请参阅示例应用以获取详细信息。
或使用库提供的ContextSettingTask
以避免额外代码。
6. 路由后任务
路由器在成功导航到目标视图控制器后将调用路由后的任务。您应该实现PostRoutingTask
协议并在其中执行所有必要操作。
示例:每当用户访问产品视图控制器时,您需要在分析中记录一个事件。
class ProductViewControllerPostTask: PostRoutingTask {
let analyticsManager: AnalyticsManager
init(analyticsManager: AnalyticsManager) {
self.analyticsManager = analyticsManager
}
func perform(on productViewController: ProductViewController, with productID: UUID, routingStack: [UIViewController]) {
analyticsManager.trackProductView(productID: productViewController.productID)
}
}
配置步骤
所有通过路由器执行的操作都使用DestinationStep
实例进行配置。您不需要自行实现此协议。使用库提供的StepAssembly
来配置路由器应执行路由的任何步骤。
示例:一个对路由器说明其应该在UINavigationController
中包装的ProductViewController
配置,这意味着从当前可见的任何视图控制器模态显示。
let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory())
.add(LoginInterceptor<UUID>()) // Have to specify the context type till https://bugs.swift.org/browse/SR-8719, https://bugs.swift.org/browse/SR-8705 are fixed
.add(ProductViewControllerContextTask())
.add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
.using(UINavigationController.push())
.from(NavigationControllerStep())
.using(GeneralActions.presentModally())
.from(GeneralStep.current())
.assemble()
此配置的含义是
- 使用
ProductViewControllerFinder
尝试在堆栈中查找现有的产品视图控制器,或如果未找到,则使用ProductViewControllerFactory
创建它。 - 如果已创建,则将其推入导航堆栈
- 导航堆栈应由另一个步骤
NavigationControllerStep
提供,这将创建一个UINavigationController
实例 UINavigationController
实例应从任何当前可见的视图控制器中模态显示- 在路由之前运行
LoginInterceptor
- 在创建或找到视图控制器之后,运行
ProductViewControllerContextTask
- 在成功路由之后运行
ProductViewControllerPostTask
请参阅示例应用以了解提供和存储路由步骤配置的不同方法。
查看高级 ProductViewController
配置,请点击此处(这里)。
导航
在实现所有必要的类并配置好路由步骤后,您可以开始使用Router
来进行导航。库提供了一个名为DefaultRouter
的类,它是Router
协议的实现,可以根据上述解释进行路由。
示例:用户在UITableView
中的一个单元格上点击,此时它将请求路由器将用户导航到ProductViewController
。用户需要登录才能查看产品详情。
struct Configuration {
static let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory())
.add(LoginInterceptor<UUID>())
.add(ProductViewControllerContextTask())
.add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
.using(UINavigationController.push())
.from(NavigationControllerStep())
.using(GeneralActions.presentModally())
.from(GeneralStep.current())
.assemble()
}
class ProductArrayViewController: UITableViewController {
let products: [UUID]?
let router = DefaultRouter()
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let productID = products[indexPath.row] else {
return
}
try? router.navigate(to: Configuration.productScreen, with: productID)
}
}
下面的示例展示了不使用 RouteComposer 的相同过程。
class ProductArrayViewController: UITableViewController {
let products: [UUID]?
let analyticsManager = AnalyticsManager.sharedInstance
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let productID = products[indexPath.row] else {
return
}
// Handled by LoginInterceptor
guard !LoginManager.sharedInstance.isUserLoggedIn else {
return
}
// Handled by a ProductViewControllerFactory
let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil)
// Handled by ProductViewControllerContextTask
productViewController.productID = productID
// Handled by NavigationControllerStep and UINavigationController.push
let navigationController = UINavigationController(rootViewController: productViewController)
// handled by DefaultActions.PresentModally
present(navigationController, animated: true) { [weak self] in
// Handled by ProductViewControllerPostTask
self?.analyticsManager.trackProductView(productID: productID)
}
}
}
在不使用RouteComposer
的情况下,代码可能看起来更简单,但实际上所有内容都在实际函数实现中被硬编码。而RouteComposer
允许您将所有内容拆分成可重用的小块,并将导航配置与视图逻辑分开存储。此外,当您尝试向应用程序添加 Universal Link 支持时,上述实现将显著增长。特别是当您需要选择在屏幕上是否已打开ProductViewController
时以及类似的情况。凭借此库,每个视图控制器都可在本质上通过深度链接。
如上所述的示例显示,Router
不会做任何会扭曲UIKit
基础的事情。它只是允许您将导航过程分解为小而可重用的部分。根据提供的配置,路由器将按正确的顺序调用它们。该库不会破坏 VIPER 或 MVVM 架构模式的原则,并且可以与它们并行使用。
请查看示例应用程序,以了解定义路由配置和实例化路由器的其他示例。
容器视图控制器
存在视图控制器,例如 UINavigationController
、UITabBarController
、UISplitController
等,它们可以在内部包含其他视图控制器。RouteComposer
调用了一种这样的视图控制器,即 ContainerViewController
。由于每个容器视图控制器都有其与内部视图控制器交互的独特方法,RouteComposer
使用称为ContainerAdapter的特殊实体。RouteComposer
包含用于随 UIKit
一起提供的许多主容器视图控制器的内置适配器。如果您使用自己的自定义容器视图控制器或来自另一个库的容器视图控制器,则可以创建自己的 ContainerAdapter
。如果您希望 RouteComposer
正确地与这些容器一起工作,例如切换其标签或在其内部显示另一个视图控制器等,请参阅示例应用程序以获取参考。
深度链接
使用 RouteComposer
,每个视图控制器都默认为深度可链接。如果您使用通用链接打开屏幕,还可以提供不同的配置。有关更多信息,请参阅示例应用程序。
let router = DefaultRouter()
func application(_ application: UIApplication,
open url: URL,
sourceApplication: String?,
annotation: Any) -> Bool {
guard let productID = extractProductId(from: url) else {
return false
}
try? router.navigate(to: Configuration.productScreen, with: productID)
return true
}
故障排除
如果由于某些原因您对结果不满意,并且认为这是路由器的问题,或者您发现您的特定情况未得到处理,您始终可以暂时用您的自定义实现替换路由器并自行实现简单路由。请创建新的问题,我们将尽快解决问题。
示例
func goToProduct(with productId: UUID) {
// If view controller with this product id is present on the screen - do nothing
guard ProductViewControllerFinder(options: .currentVisibleOnly).getViewController(with: productId) == nil else {
return
}
/// Otherwise, find visible `UINavigationController`, build `ProductViewController`
guard let navigationController = ClassFinder<UINavigationController, Any?>(options: .currentVisibleOnly).getViewController(),
let productController = try? ProductViewControllerFactory().execute(with: productId) else {
return
}
/// Apply context task if necessary
try? ProductViewControllerContextTask().execute(on: productController, with: productId)
/// Push `ProductViewController` into `UINavigationController`
navigationController.pushViewController(productController, animated: true)
}
SwiftUI
RouteComposer
兼容 SwiftUI。有关详细信息,请参阅示例应用程序。
高级配置
您可以在此处找到更多配置示例。
贡献
RouteComposer 正在积极开发中,我们欢迎您的贡献。
如果您想向此存储库贡献,请阅读贡献指南。
许可
RouteComposer 在MIT许可下分发。
RouteComposer免费提供给用户,按原样提供。我们不做出任何保证、承诺或道歉。开发者自重。
文章
英文
俄语
作者
叶夫根尼·卡萨耶夫,[email protected]。Twitter ekazaev
我很乐意回答您可能有的任何问题。只需创建一个新问题。