一个用于 Swift 的编译时依赖注入库
Sword 是一个用于 Swift 的编译时依赖注入库,灵感来源于 Dagger。
当您声明依赖关系并指定如何使用 Swift 宏 来满足这些依赖时,Sword 会在编译时自动生成依赖注入代码。Sword 会遍历您的代码并验证依赖关系图,确保每个对象的依赖关系都能得到满足,从而避免运行时错误。
使用以下链接将 Sword 作为 Package Dependency 添加到 Xcode 项目中
https://github.com/rockname/sword
重要提示
请勿将 SwordCommand
可执行文件添加到任何目标。当要求选择包产品时,请确保选择 None
(无)。
将以下内容添加到您的 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 Package Dependency 安装。
将 SwordBuildToolPlugin
添加到目标 Build Phases
(构建阶段) 的 Run Build Tool Plug-ins
(运行构建工具插件) 阶段。
提示
首次使用插件时,请务必信任并启用它(如果出现提示)。如果存在宏构建警告,请选择它以信任并启用宏。
注意
需要通过 Swift Package Manager 安装。
如下所示将插件添加到应用程序根目标
.target(
...
plugins: [.plugin(name: "SwordBuildToolPlugin", package: "Sword")]
),
如上所述添加 SwordBuildToolPlugin
Xcode 项目。
然后,在您的 Xcode 项目的根目录中添加一个 .sword.yml
文件,以便 Sword 读取该文件并生成考虑本地 Swift Package 的依赖关系图。
例如
local_packages:
- path: PackageA
targets:
- DependencyA
- DependencyB
- path: PackageB
targets:
- DependencyC
- DependencyD
考虑一个示例 SwiftUI 应用程序,其依赖关系图如下所示。
您通常在 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 { ... }
}
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
}
}
当您为依赖项提供接口时,请在 @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 {
...
}
在本例中,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
将对象的生命周期限制在其组件的生命周期内。这意味着每次需要提供该类型时,都会使用同一个依赖项实例。
要在您在 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 { ... }
}
如果您的登录流程由多个视图组成,您可能希望在所有视图中重用 LoginViewModel
的同一个实例。但是,出于以下原因,您不应在 AppComponent
中使用 single
作用域
LoginViewModel
的实例在登录流程结束后仍会保留在内存中。
您希望每个登录流程都有一个不同的 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 关系图的样子。带有白点的类 (UserRepository
、URLSession
和 LoginViewModel
) 是作用域限定到其各自组件的唯一实例的类。
您可以将一些参数作为依赖项传递给组件。
例如,您可以将环境变量 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
}
}
辅助注入是一种依赖注入 (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))
...
}
}
功能 (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) |
本库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE。