🗡️剑 (Sword)

一个用于 Swift 的编译时依赖注入库

build license


简介

Sword 是一个用于 Swift 的编译时依赖注入库,灵感来源于 Dagger

当您声明依赖关系并指定如何使用 Swift 宏 来满足这些依赖时,Sword 会在编译时自动生成依赖注入代码。Sword 会遍历您的代码并验证依赖关系图,确保每个对象的依赖关系都能得到满足,从而避免运行时错误。

安装

Xcode Package Dependency (Xcode 包依赖)

使用以下链接将 Sword 作为 Package Dependency 添加到 Xcode 项目中

https://github.com/rockname/sword

重要提示

请勿将 SwordCommand 可执行文件添加到任何目标。当要求选择包产品时,请确保选择 None (无)。

Swift Package Manager

将以下内容添加到您的 Package.swift 文件中的 dependencies

.package(url: "https://github.com/rockname/sword.git", from: "<version>")

然后,将 "Sword" 作为您目标的依赖项包含进来

.target(
  name: "<target>",
  dependencies: [
    .product(name: "Sword", package: "sword"),
  ]
),

设置

Sword 提供了一个构建工具插件来生成依赖注入代码。

构建工具插件可以在 Xcode 项目和 Swift Package 项目中使用。

Xcode 项目

注意

需要通过 Xcode Package Dependency 安装。

SwordBuildToolPlugin 添加到目标 Build Phases (构建阶段) 的 Run Build Tool Plug-ins (运行构建工具插件) 阶段。

提示

首次使用插件时,请务必信任并启用它(如果出现提示)。如果存在宏构建警告,请选择它以信任并启用宏。

Swift Package 项目

注意

需要通过 Swift Package Manager 安装。

如下所示将插件添加到应用程序根目标

.target(
    ...
    plugins: [.plugin(name: "SwordBuildToolPlugin", package: "Sword")]
),

Xcode + Swift Package 项目

如上所述添加 SwordBuildToolPlugin Xcode 项目

然后,在您的 Xcode 项目的根目录中添加一个 .sword.yml 文件,以便 Sword 读取该文件并生成考虑本地 Swift Package 的依赖关系图。

例如

local_packages:
  - path: PackageA
    targets:
      - DependencyA
      - DependencyB
  - path: PackageB
    targets:
      - DependencyC
      - DependencyD

用法

考虑一个示例 SwiftUI 应用程序,其依赖关系图如下所示。

组件 (Component)

您通常在 App 结构体(或根视图)中创建 Sword 依赖关系图,因为您希望图的实例在应用程序运行时一直存在于内存中。这样,该图就附加到应用程序的生命周期。

在 Sword 中,@Component 附加到依赖关系图。因此您可以将其称为 AppComponent。您通常将该组件的实例保存在您的自定义 App 结构体中,如下所示

// Definition of the App dependency graph
@Component
final class AppComponent {
}

// AppComponent lives in the App struct to share its lifecycle
@main
struct MyApp: App {
  let component = AppComponent()

  var body: some Scene { ... }
}

您可以从 Component 中获取您想要的依赖项,而不是在 init 中创建视图所需的依赖项。

struct LoginNavigation: View {
  let component: AppComponent

  var body: some View {
    ...
    LoginScreen(viewModel: component.loginViewModel)
    ...
  }
}

struct LoginScreen: View {
  let viewModel: LoginViewModel

  var body: some View { ... }
}

依赖 (Dependency)

Sword 需要知道所需的依赖关系才能提供 LoginViewModel。您可以使用 @Dependency / @Injected 告诉 Sword 如何初始化 LoginViewModel,如下所示

// You want Sword to provide an object of LoginViewModel from the AppComponent graph
@Dependency(registeredTo: AppComponent.self)
final class LoginViewModel {
  private let userRepository: UserRepository

  @Injected
  init(userRepository: UserRepository) {
    self.userRepository = userRepository
  }
}

让我们告诉 Sword 如何提供其余的依赖项来构建关系图

@Dependency(registeredTo: AppComponent.self)
final class UserRepository {
  private let apiClient: APIClient

  @Injected
  init(apiClient: APIClient) {
    self.apiClient = apiClient
  }
}

@Dependency(registeredTo: AppComponent.self)
final struct APIClient {
  private let urlSession: URLSession

  @Injected
  init(urlSession: URLSession) {
    self.urlSession = urlSession
  }
}

绑定 (Binding)

当您为依赖项提供接口时,请在 @Dependency 上使用 boundTo 参数。

protocol APIClient { ... }

// You want Sword to provide a DefaultAPIClient implementation for APIClient interface
@Dependency(
  registeredTo: AppComponent.self,
  boundTo: APIClient.self
)
final struct DefaultAPIClient: APIClient {
  ...
}

模块 (Module)

在本例中,APIClient 依赖于 URLSession。但是,创建 URLSession 实例的方式与您之前所做的不同。它的初始化器在 Foundation 框架中定义。

除了 @Injected 之外,还有另一种方法告诉 Sword 如何提供所需的依赖项:Sword 模块内部的信息。Sword 模块是一个附加了 @Module 的结构体。在那里,您可以使用 @Provider 定义依赖项。

// @Module informs Sword that this struct is a Sword Module registered to AppComponent
@Module(registeredTo: AppComponent.self)
struct AppModule {
  // @Provider tells Sword how to create the dependency.
  @Provider
  static func urlSession() -> URLSession {
    let configuration = URLSessionConfiguration.default
    configuration.timeoutIntervalForRequest = 30
    return URLSession(configuration: configuration)
  }
}

这就是示例中 Sword 关系图现在的样子

关系图的入口点是 LoginScreen。由于 LoginScreen 注入了 LoginViewModel,Sword 构建了一个关系图,该关系图知道如何提供 LoginViewModel 的实例,并递归地提供其依赖项的实例。Sword 知道如何做到这一点,因为依赖项的初始化器上有 @Injected

作用域 (Scope)

您可以使用 Scope 将对象的生命周期限制在其组件的生命周期内。这意味着每次需要提供该类型时,都会使用同一个依赖项实例。

要在您在 AppComponent 中请求存储库时拥有 UserRepository 的唯一实例,请将 .single 传递给 @Dependency 上的 scopedWith 参数。

@Dependency(
  registeredTo: AppComponent.self,
  scopedWith: .single
)
final class UserRepository { ... }

您也可以在 @Provider 中使用 scopedWith 参数。

@Module(registeredTo: AppComponent.self)
struct AppModule {
  @Provider(scopedWith: .single)
  static func urlSession() -> URLSession { ... }
}

子组件 (Subcomponent)

如果您的登录流程由多个视图组成,您可能希望在所有视图中重用 LoginViewModel 的同一个实例。但是,出于以下原因,您不应在 AppComponent 中使用 single 作用域

  1. LoginViewModel 的实例在登录流程结束后仍会保留在内存中。

  2. 您希望每个登录流程都有一个不同的 LoginViewModel 实例。例如,如果用户注销,您希望获得一个与用户首次登录时不同的 LoginViewModel 实例,而不是同一个实例。

要将 LoginViewModel 的作用域限定为登录流程的生命周期,您需要为登录流程创建一个新组件。

新组件必须能够访问 AppComponent 中的对象,因为 LoginViewModel 依赖于 UserRepository。告诉 Sword 您希望新组件使用另一个组件的一部分的方法是使用 Sword Subcomponent。新组件必须是包含共享资源的组件的子组件。

在示例中,您必须将 LoginComponent 定义为 AppComponent 的子组件,如下所示

// You tell Sword that LoginComponent is a subcomponent of AppComponent
@Subcomponent(of: AppComponent.self)
final class LoginComponent {
}

工厂方法 func makeLoginComponent() -> LoginComponent 将在 AppComponent 中生成。您在启动登录流程时调用此方法。

@main
struct MyApp: App {
  let component = AppComponent()

  var body: some Scene {
    ...
    LoginNavigation(component: component.makeLoginComponent())
    ...
  }
}

struct LoginNavigation: View {
  let component: LoginComponent

  var body: some View {
    ...
    LoginScreen(viewModel: component.loginViewModel)
    ...
  }
}

然后,当您在 LoginViewModel@Dependency 上将 LoginComponent.self 设置为 registeredTo,并将 .single 设置为 scopedWith 时,LoginViewModel 的实例在每个登录流程中都是唯一的。

@Dependency(
  registeredTo: LoginComponent.self,
  scopedWith: .single
)
final class LoginViewModel { ... }

这是具有新子组件的 Sword 关系图的样子。带有白点的类 (UserRepositoryURLSessionLoginViewModel) 是作用域限定到其各自组件的唯一实例的类。

组件参数 (Component Arguments)

您可以将一些参数作为依赖项传递给组件。

例如,您可以将环境变量 EnvVars 注入到 AppComponent,如下所示

struct EnvVars {
  let baseURL: URL
}

@Component(arguments: EnvVars.self)
final class AppComponent {
}

然后,@Component 宏会生成一个接收 EnvVars 作为参数的初始化器。

let component = AppComponent(
  envVars: EnvVars(baseURL: URL(string: "https://example.com")!)
)

现在您可以通过 AppComponent 解析 EnvVars 依赖项。

@Dependency(
  registeredTo: AppComponent.self,
  boundTo: APIClient.self,
  scopedWith: .single
)
final class DefaultAPIClient: APIClient {
  private let baseURL: URL

  @Injected
  init(envVars: EnvVars) {
    self.baseURL = envVars.baseURL
  }
}

辅助注入 (Assisted Injection)

辅助注入是一种依赖注入 (DI) 模式,用于构造一个对象,其中一些参数可能由 DI 框架提供,而另一些参数必须在创建时(也称为“辅助”)由用户传入。

要使用 Sword 的辅助注入,请使用 @Assisted 注释任何辅助参数,如下所示

@Dependency(registeredTo: AppComponent.self)
class UserDetailViewModel {
  ...

  @Injected
  init(
    @Assisted userID: User.ID,
    userRepository: UserRepository
  ) {
    self.userID = userID
    self.userRepository = userRepository
  }
}

然后,您可以在使用依赖项时传递辅助参数,如下所示。

struct UserNavigation: View {
  let component: AppComponent

  var body: some View {
    ...
    UserDetailScreen(viewModel: component.userDetailViewModel(userID: userID))
    ...
  }
}

功能 (Features)

功能 (Feature) 支持状态 (Support Status)
子组件 (Subcomponent) ✅ 支持 (Supported)
组件参数 (Component Arguments) ✅ 支持 (Supported)
单例作用域 (Single Scope) ✅ 支持 (Supported)
弱引用作用域 (Weak Reference Scope) ✅ 支持 (Supported)
辅助注入 (Assisted Injection) ✅ 支持 (Supported)
缺少依赖错误 (Missing Dependency Error) ✅ 支持 (Supported)
重复依赖错误 (Duplicate Dependency Error) ✅ 支持 (Supported)
循环依赖错误 (Cycle Dependency Error) 🚧 即将推出 (TBD)

许可证 (License)

本库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE