RouteComposer

CI Status Release Cocoapods Swift Package Manager SwiftUI Carthage compatible Swift 5.9 Platform iOS Documentation Code coverage Codacy Badge MIT License Twitter

RouteComposer 是一个面向协议的,基于 Cocoa UI 抽象的库,它帮助处理 iOS 应用程序中的视图控制器组合、导航和深度链接任务。

可以作为 Coordinator 模式的通用替代品。

目录

导航问题

在 iOS 应用程序中有两种实现导航的方式

这两种解决方案的缺点

RouteComposer 的作用

安装

CocoaPods

RouteComposer 可以通过 CocoaPods 获得。要安装它,只需将以下行添加到您的 Podfile 中

pod 'RouteComposer'

针对 Xcode 10.1 / Swift 4.2 的支持

pod 'RouteComposer', '~> 1.4'

然后运行 pod install

成功集成后,只需将以下语句添加到要使用 RouteComposer 的任何 Swift 文件中

import RouteComposer

查看包含的示例应用程序,因为它涵盖了大多数常见用例。

Swift Package Manager

Swift Package Manager 是一种用于自动化 Swift 代码分发的工具,并且已集成到 swift 编译器中。

设置好 Swift 包后,将 RouteComposer 作为依赖项添加到 Package.swiftdependencies 值中非常简单。

dependencies: [
    .package(url: "https://github.com/ekazaev/route-composer", .upToNextMajor(from: "2.10.4"))
]

示例

要运行示例项目,请克隆 repo,然后首先从 Example 目录运行 pod install

要求

使用此库没有实际的要求。但是,如果您要实现自定义容器和操作,则应熟悉库的概念和 UIKit 视图控制器导航堆栈的复杂性。

API 文档

详细的 API 文档可以在这里找到。测试覆盖率 - 这里

用户评价

Viz.ai

在 Viz.ai,领先的同步中风护理服务,我们开始替换我们的整个导航系统,我们知道我们需要解决复杂和动态的导航场景。协调器和其他流控制库根本无法满足我们的需求,并且导致应用程序逻辑和导航混合,或者创建大量的协调器类。RouteComposer 非常适合我们,实际上,正如该库的创建者所说,它您当前使用的任何协调器代码的直接替代品。

这个库对关注点的分离绝对是美妙的,而且就像任何天才之作一样,它都像魔法一样工作。它确实有一个小的学习曲线,但它所带来的回报远大于协调器和流程控制器,并且一旦您实现它,将为您节省大量的编码。

它使应用程序中的导航就像说“去带有 y 的 x”一样简单,而不必担心当前状态或堆栈。我全心全意地推荐它。

Elazar Yifrach,Viz.ai 高级 iOS 开发人员

Hudson's Bay Company

在我们的 iOS 应用程序中,我们希望为用户提供无缝体验,以确保无论他们点击推送通知还是电子邮件中的链接,他们都能无缝地进入应用程序中所需的视图,而不管应用程序的状态如何。

我们尝试在代码中使用编程式导航方法,并且还尝试依赖其他一些库。但是,似乎没有一个能做到。RouteComposer 并非我们的首选,因为它最初看起来太复杂了。值得庆幸的是,它最终成为一个梦幻般的优雅解决方案。我们开始使用它,不仅用于处理外部深度链接,还用于处理应用程序内部的内部导航。它也证明是在您为不同用户提供不同导航模式时,进行 UI A/B 测试的绝佳工具。它为我们节省了大量时间,并且我们非常喜欢它背后的逻辑。

该库的创建者反应非常迅速,并帮助解决了我们遇到的所有问题。我会彻底推荐它!

Alexandra Mikhailouskaya,Hudson's Bay Company 高级首席工程师

B.W.A.,一家拥有 130 年历史的零售银行。

我们最近进行了第五次也是最大规模的应用更新,其中涉及从头开始重组用户导航。在我们的一位高级开发人员建议我们试用 RouteComposer 之前,我们首先简单地迁移了我们现有的(六个文件长的)协调器。概念验证具有挑战性,但 Eugene Kazaev 随时待命,致力于将 RouteComposer 改装到我们现有的企业级代码库中,当所有部分都就位时,结果本身就是简单。

我们的其他开发人员已经接受了 RouteComposer,以代替 segues、unwind segues、手动推送、弹出窗口和模态下拉列表,并且由此产生的应用程序导航非常令人愉快。

非常感谢 Eugene 提供的所有帮助。 skooter Martin,B.W.A. 高级移动工程师

赞助此项目

如果您喜欢这个库,尤其是您在生产中使用它,请考虑这里赞助该项目。我在业余时间从事 RouteComposer 的工作。赞助将帮助我继续从事这个项目并继续为开源社区做出贡献。

使用

RouteComposer 使用 3 个主要实体(FactoryFinderAction),这些实体应由宿主应用程序定义以支持它。它还提供 3 个辅助实体(RoutingInterceptorContextTaskPostRoutingTask),您可以实现这些实体以在路由过程中处理一些默认操作。以下每个实体的描述中都有 2 个 associatedtype

注意

Context 表示您需要传递给 UIViewController 的有效负载,以及将其与其他视图控制器区分开来的内容。 它不是 View Model 或某种 Presenter。 它是缺失的信息。 如果您的视图控制器需要 productID 来显示其内容,并且 productIDUUID,则 Context 的类型为 UUID。 内部逻辑属于视图控制器。 Context 回答以下问题:* 我需要什么来呈现 ProductViewController * 以及 * 我是否已在呈现此产品的 ProductViewController *。

实现

1. Factory (工厂)

Factory 负责构建视图控制器,路由器必须根据请求导航到这些视图控制器。每个 Factory 实例必须实现 Factory 协议

public protocol Factory {

    associatedtype ViewController: UIViewController

    associatedtype Context

    func build(with context: Context) throws -> ViewController

}

这里最重要的函数是 build,它应该实际创建视图控制器。有关详细信息,请参阅文档prepare 函数为您提供了一种在实际进行路由之前执行某些操作的方法。例如,您可以从该函数内部 throw 以告知路由器您没有显示视图所需的数据。如果您在应用程序中实现通用链接并且无法处理路由,这可能会很有用,在这种情况下,应用程序可能会在 Safari 中打开提供的 URL。

示例:用于某个自定义 ProductViewController 视图控制器的 Factory 的基本实现可能如下所示

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 (查找器)

Finder 帮助路由器找出特定的视图控制器是否已存在于视图控制器图中。所有 Finder 实例应符合 Finder 协议。

public protocol Finder {

    associatedtype ViewController: UIViewController

    associatedtype Context

    func findViewController(with context: Context) throws -> ViewController?

}

在某些情况下,您可以使用库提供的默认 Finder。在其他情况下,当您在图中可以有多个相同类型的视图控制器时,您可以实现自己的 Finder。 此协议包含一个名为 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. Action (动作)

Action 实例向路由器解释**如何将 Factory 创建的视图控制器集成到视图控制器堆栈中**。大多数情况下,您不需要实现自己的 actions,因为该库为 UIKit 中可以执行的大多数默认动作提供了 actions,例如 (GeneralAction.presentModally, UITabBarController.add, UINavigationController.push 等)。如果您正在做一些不寻常的事情,您可能需要实现自己的 actions。

查看示例应用程序以了解自定义 action 的实现。

示例:由于您很可能不需要实现自己的 actions,让我们看看库提供的 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. Routing Interceptor (路由拦截器)

路由拦截器将**在路由器开始路由到目标视图控制器之前被使用**。例如,要导航到某个特定的视图控制器,用户可能需要登录。您可以创建一个实现 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. Context Task (上下文任务)

如果您使用库提供的默认 FactoryFinder 实现,您仍然需要**在上下文中为您的视图控制器设置数据。**即使它已经存在于堆栈中,或者只是要由 Factory 创建,或者在路由器找到/创建视图控制器时执行任何其他操作,您也必须这样做。只需实现 ContextTask 协议即可。

示例:即使 ProductViewController 出现在屏幕上或即将被创建,您也必须设置 productID 以呈现产品。

class ProductViewControllerContextTask: ContextTask {

    func perform(on productViewController: ProductViewController, with productID: UUID) {
        productViewController.productID = productID
    }

}

有关详细信息,请参阅示例应用程序。

或者使用库提供的 ContextSettingTask 以避免额外的代码。

6. Post Routing Task (路由后任务)

路由后任务将由路由器在**成功完成导航到目标视图控制器后**调用。您应该实现 PostRoutingTask 协议并在那里创建所有必要的 actions。

示例:您需要在每次用户到达产品视图控制器时,在您的分析中记录一个事件

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 来配置路由器在路由期间应执行的任何步骤。

示例:ProductViewController 配置,它向路由器解释,它应该被放在 UINavigationController 中,该 UINavigationController 应该从任何当前可见的视图控制器以模态方式呈现。

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()

此配置意味着

请参阅示例应用程序,了解提供和存储路由步骤配置的不同方法。

在此处查看高级 ProductViewController 配置 here

导航

在您实现了所有必要的类并配置了路由步骤之后,您可以开始使用 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 支持时,上述实现将会急剧增长。特别是如果必须选择从 universal link 打开 ProductViewController(如果它已经出现在屏幕上)等等。使用该库,您的每个视图控制器本质上都是可深度链接的。

正如您从上面的示例中看到的那样,Router 不会做任何调整 UIKit 基础的事情。它只是允许您将导航过程分解为小的可重用部分。路由器将根据提供的配置以正确的顺序调用它们。该库不会破坏 VIPER 或 MVVM 架构模式的规则,并且可以与它们并行使用。

请参阅示例应用程序,了解定义路由配置和实例化路由器的其他示例。

容器视图控制器

有一些视图控制器,例如 UINavigationController, UITabBarController, UISplitController 等,可以包含其他视图控制器在它们内部。RouteComposer 将这种视图控制器称为 ContainerViewControllers。由于每个容器视图控制器都有其自己与包含的视图控制器交互的独特方法,因此 RouteComposer 使用称为 ContainerAdapters 的特殊实体。RouteComposer 包含用于 UIKit 附带的主要容器视图控制器的内置适配器。如果您正在使用自己的自定义容器视图控制器或来自另一个库的容器视图控制器,您可以创建自己的 ContainerAdapters。如果您希望 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
    }

问题排查

如果由于某种原因您对结果不满意,并且您认为这是 Routers 的问题,或者您发现您的特定案例未涵盖,您可以随时临时将路由器替换为您自己的自定义实现并自己实现简单的路由。请创建一个 新 issue,我们将尽快尝试修复该问题。

示例

     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

RouteComposerSwiftUI 兼容。请参阅示例应用程序以获取详细信息。

高级配置

您可以在此处找到更多配置示例。

贡献

RouteComposer 正在积极开发中,我们欢迎您的贡献。

如果您想为此存储库做出贡献,请阅读贡献指南

许可证

RouteComposer 在 MIT 许可证下分发。

RouteComposer 按原样免费提供给您使用。我们不作任何保证、承诺或道歉。Caveat developer. (开发者注意)

文章

English (英语)

Russian (俄语)

作者


Evgeny Kazaev, eugene.kazaev@gmail.com. Twitter ekazaev

I am happy to answer any questions you may have. Just create a new issue. (我很乐意回答您可能有的任何问题。只需创建一个新 issue 即可。)