RouterService

struct SwiftRocksFeature: Feature {

    @Dependency var client: HTTPClientProtocol
    @Dependency var persistence: PersistenceProtocol
    @Dependency var routerService: RouterServiceProtocol

    func build(fromRoute route: Route?) -> UIViewController {
        return SwiftRocksViewController(
            client: client,
            persistence: persistence,
            routerService: routerService,
        )
    }
}

RouterService 是一个类型安全的导航/依赖注入框架,专注于使模块化的 Swift 应用拥有非常快的构建速度基于 AirBnB 在 BA:Swiftable 2019 上展示的系统。

RouterService 旨在用作 模块化应用(其中每个目标包含一个额外的“接口”模块)的依赖注入器。 链接的文章包含更多关于这方面的信息,但作为总结,这部分源于一个原则,即功能模块永远不应直接依赖于具体的模块。 相反,出于构建性能的原因,一个功能只能访问另一个功能的接口,其中包含协议和其他不太可能更改的内容。 为了将所有内容连接在一起,RouterService 负责在引用这些协议之一时注入必要的具体依赖项。

最终结果是

有关此架构的更多信息,请查看相关的 SwiftRocks 文章。

RouterService 的工作原理

(有关完整示例,请查看此仓库的示例应用。)

RouterService 通过 Features 的概念工作 -- Featuresstructs,可以在被赋予访问执行此操作所需的任何依赖项后创建 ViewControllers。 这是一个关于我们如何使用这种格式创建一个“用户个人资料功能”的示例。

此功能需要访问 HTTP 客户端,因此我们首先定义它。 由于具有接口目标的模块化应用应将协议与实现分离,因此我们的第一个模块将是 HTTPClientInterface,它公开客户端协议

// Module 1: HTTPClientInterface

public protocol HTTPClientProtocol: AnyObject { /* Client stuff */ }

从接口开始,现在让我们创建一个具体HTTPClient 模块来实现它

// Module 2: HTTPClient

import HTTPClientInterface

private class HTTPClient: HTTPClientProtocol { /* Implementation of the client stuff */ }

我们现在准备好定义我们的 Profile RouterService 功能。 在一个新的 Profile 模块中,我们可以创建一个 Feature 结构,该结构具有客户端的协议作为依赖项。 为了能够访问协议,Profile 模块将导入依赖项的接口。

// Module 3: Profile

import HTTPClientInterface
import RouterServiceInterface

struct ProfileFeature: Feature {

    @Dependency var client: HTTPClientProtocol

    func build(fromRoute route: Route?) -> UIViewController {
        return ProfileViewController(client: client)
    }
}

由于 Profile 功能不导入具体的 HTTPClient 模块,因此对它们所做的更改不会重新编译 Profile 模块。 相反,RouterService 将在运行时注入具体的对象。 如果您将此乘以数百个协议和数十个功能,您将在您的应用中获得巨大的构建时间改进!

在这种情况下,只有当接口协议本身发生更改时,Profile 功能才会被外部更改重新编译 -- 这应该比更改其具体对应项要罕见得多。

现在让我们看看如何告诉 RouterService 推送 ProfileFeature 的 ViewController。

路由 (Routes)

在 RouterService 中,导航不是通过直接创建 Features 的 ViewControllers 实例来完成的,而是完全通过 Routes 完成的。 就其本身而言,Routes 只是 Codable 结构,可以保存关于操作的上下文信息(例如触发此路由的先前屏幕,用于分析目的)。 然而,神奇之处在于它们的使用方式:RoutesRouteHandlers 配对:RouteHandlers 是类,用于定义受支持的 Routes 列表以及在执行它们时应推送的 Features

例如,为了将上面显示的 ProfileFeature 暴露给应用的其余部分,假设的 Profile 目标应首先在单独的 ProfileInterface 目标中暴露一个路由。

// Module 4: ProfileInterface

import RouterServiceInterface

struct ProfileRoute: Route {
    static let identifier: String = "profile_mainroute"
    let someAnalyticsContext: String
}

现在,在具体的 Profile 目标中,我们可以定义一个 ProfileRouteHandler,将其连接到 ProfileFeature

import ProfileInterface
import RouterServiceInterface

public final class ProfileRouteHandler: RouteHandler {
    public var routes: [Route.Type] {
        return [ProfileRoute.self]
    }

    public func destination(
        forRoute route: Route,
        fromViewController viewController: UIViewController
    ) -> Feature.Type {
        guard route is ProfileRoute else {
            preconditionFailure() // unexpected route sent to this handler
        }
        return ProfileFeature.self
    }
}

RouteHandlers 旨在处理多个 Routes。 如果一个特定的功能目标包含多个 ViewControllers,您可以在该目标中拥有一个单独的 RouteHandler 来处理所有可能的 Routes

为了推送一个新的 FeatureFeature 所要做的就是导入包含所需 Route 的模块,并调用 RouterServiceProtocolnavigate(_:) 方法。 RouterServiceProtocol,RouterService 框架的接口协议,可以作为功能的依赖项添加,用于此目的。

假设我们也在 ProfileFeature 的同时创建了一些假设的 LoginFeature,以下是我们如何从 ProfileFeature 推送 LoginFeature 的 ViewController 的方法

import LoginInterface
import RouterServiceInterface
import HTTPClientInterface
import UIKit

final class ProfileViewController: UIViewController {
    let client: HTTPClientProtocol
    let routerService: RouterServiceProtocol

    init(client: HTTPClientProtocol, routerService: RouterServiceProtocol) {
        self.client = client
        self.routerService = routerService
        super.init(nibName: nil, bundle: nil)
    }

    func goToLogin() {
        let loginRoute = SomeLoginRouteFromTheLoginFeatureInterface()
        routerService.navigate(
            toRoute: loginRoute,
            fromView: self,
            presentationStyle: Push(),
            animated: true
        )
    }
}

总结

如果所有功能都是隔离的,您如何启动应用?

虽然功能与其他具体目标隔离,但您应该有一个“主”目标,它导入所有内容和所有人(例如,您的 AppDelegate)。 这应该是唯一能够导入具体目标的目标。

从那里,您可以创建 RouterService 的具体实例,注册每个人的 RouteHandlers 和依赖项,并通过调用 RouterServicenavigationController(_:) 方法(如果您需要导航),或者通过手动调用 Featurebuild(_:) 方法来启动导航过程循环。

import HTTPClient
import Profile
import Login

class AppDelegate {

   let routerService = RouterService()

   func didFinishLaunchingWith(...) {

       // Register Dependencies

       routerService.register(dependencyFactory: { 
           return HTTPClient() 
       }, forType: HTTPClientProtocol.self)

       // Register RouteHandlers

       routerService.register(routeHandler: ProfileRouteHandler())
       routerService.register(routeHandler: LoginRouteHandler())

       // Setup Window

       let window = UIWindow()
       window.makeKeyAndVisible()

       // Start RouterService

       window.rootViewController = routerService.navigationController(
        withInitialFeature: ProfileFeature.self
       )

       return true
   }
}

有关更多信息和示例,请查看此仓库内提供的示例应用。 它包含一个具有两个功能目标和一个虚假依赖项目标的应用。

依赖项的内存管理

依赖项通过闭包(称为“依赖项工厂”)注册,以允许 RouterService 按需生成它们的实例并在不再有需要它们的功能处于活动状态时释放它们。 这是通过拥有一个内部存储来弱持有这些值来完成的。 闭包本身在应用的整个生命周期中都保存在内存中,但其影响应小于持有实例本身。

为功能提供后备方案

如果您需要控制功能的可用性,无论是由于功能标志还是因为它具有最低 iOS 版本要求,都可以通过 FeatureisEnabled() 方法来处理它。 此方法向 RouterService 提供有关功能可用性的信息。 我们真的建议您将您的切换控件(功能标志提供程序、远程配置提供程序、用户默认设置等)作为功能的 @Dependency,以便您可以轻松地使用它们来实现它并在以后正确地对其进行单元测试。 如果 Feature 可以被禁用,您需要通过实现 fallback(_:) 方法来提供后备方案,以允许 RouterService 接收并呈现有效的上下文。 例如

public struct FooFeature: Feature {

    @Dependency var httpClient: HTTPClientProtocol
    @Dependency var routerService: RouterServiceProtocol
    @Dependency var featureFlag: FeatureFlagProtocol

    public init() {}
    
    public func isEnabled() -> Bool {
        return featureFlag.isEnabled()
    }
    
    public func fallback(forRoute route: Route?) -> Feature.Type? {
        return MyFallbackFeature.self
    }

    public func build(fromRoute route: Route?) -> UIViewController {
        return MainViewController(
            httpClient: httpClient,
            routerService: routerService
        )
    }
}

如果禁用的功能尝试在没有后备方案的情况下呈现,您的应用将崩溃。 默认情况下,所有功能都已启用且没有后备方案。

AnyRoute

所有 Routes 都是 Codable,但是如果后端可以返回多个路由怎么办?

为此,RouterServiceInterface 提供了一个类型擦除的 AnyRoute,它可以从特定的字符串格式解码任何已注册的 Route。 这允许您的后端决定如何在应用内部处理导航。 很酷,对吧?

要使用它,请将 AnyRoute(它是 Decodable)添加到您的后端的响应模型中

struct ProfileResponse: Decodable {
    let title: String
    let anyRoute: AnyRoute
}

在解码 ProfileResponse 之前,将您的 RouterService 实例注入到 JSONDecoder 中:(确定应解码哪个 Route 是必需的)

let decoder = JSONDecoder()

routerService.injectContext(toDecoder: decoder)

decoder.decode(ProfileResponse.self, from: data)

您现在可以解码 ProfileResponse。 如果注入的 RouterService 包含后端返回的 Route,则 AnyRoute 将成功解码为它。

let response = decoder.decode(ProfileResponse.self, from: data)
print(response.anyRoute.route) // Route

框架期望的字符串格式是 route_identifier|parameters_json_string 格式的字符串。 例如,要解码本 README 开头显示的 ProfileRouteProfileResponse 应如下所示

{
    "title": "Profile Screen",
    "anyRoute": "profile_mainroute|{\"analyticsContext\": \"Home\"}"
}

安装

安装 RouterService 时,接口模块 RouterServiceInterface 也将被安装。

Swift Package Manager

.package(url: "https://github.com/rockbruno/RouterService", .upToNextMinor(from: "1.1.0"))

CocoaPods

pod 'RouterService'

示例项目

ExampleProject 使用 XcodeGen 来生成其 Xcode 项目。

您可以选择使用 Homebrew 安装 XcodeGen 二进制文件,或者利用 Swift Package Manager,它将克隆、构建然后运行 XcodeGen。 下面概述了生成 RouterServiceExampleApp.xcodeproj 的各个步骤。

Homebrew
# 1. Install XcodeGen using Homebrew
brew install xcodegen

# 2. Run xcodegen from within the ExampleProject directory
cd ExampleProject && xcodegen generate
Swift Package Manager
# 1. Run xcodegen using SPM from within the ExampleProject directory
cd ExampleProject && swift run xcodegen