在 Swift 中使用依赖注入Dependency Injection(DI)

了解如何在 Swift 开发中使用依赖注入的方法来创建类。

在 Swift 中使用依赖注入Dependency Injection(DI)

依赖注入乍听之下可能显得复杂,但其实它是一个简单的概念。可以想象成一段代码需要依赖另一段代码才能正常运行。与其在内部创建这个依赖,我们选择从外部提供它,这个过程就被称为“注入”依赖。

下面通过一个实际的例子,理解什么是依赖注入,以及为什么推荐采用依赖注入。

单一职责原则

假设我们需要实现登陆豆瓣并获取用户数据的逻辑。

这里面会涉及到两个部分:用户认证数据获取

单一职责原则推荐将认证和数据获取分开,因为他们是两个不同的关注点。

因此,我们可以创建两个类,每个类专注于自己的功能领域:

  • DoubanAuthManager():负责实现登陆流程、登陆状态、认证信息以及用户基本信息的存储。
  • DoubanManager():负责获取用户的相关数据,例如收藏的电影、数据等。

DoubanAuthManager 中创建用户登陆的方法:

@Observable
@MainActor
class DoubanAuthManager {
    var isLoggedIn: Bool = false
    var cookies: [HTTPCookie] = []

    // 添加用户信息字段
    var userId: String = ""
    var userName: String = ""
    var userAvatar: String = ""
    var userProfileUrl: String = ""

    // 保存登录状态
    func saveLoginStatus() {
        // 将 cookies 保存到用户默认值
        // 保存用户信息
    }

    // 检查登录状态 - 更严格的检测
    func checkLoginStatus() -> Bool {
        // 检查是否有登录状态标记
    }

    // 清除登录状态
    func clearLoginStatus() {
        
    }

    // 获取用户信息
    func fetchUserInfo() async {
        // 检查是否已登录
    }
}

DoubanManager 中,创建获取豆瓣数据的方法:

@Observable
@MainActor
class DoubanManager {

    // 获取用户收藏的电影
    func loadMovies(forceRefresh: Bool = false) async {
    }

    // 获取用户收藏的书籍
    func loadBooks(forceRefresh: Bool = false) async {
    }

    // 获取正在热映的电影
    private func fetchNowPlayingMovies() async {
    }
}

理解什么是代码依赖

在实际使用中,有一个问题:DoubanManager 中的 func,需要访问认证信息才能工作,但用户认证信息存放在 DoubanAuthManager 中。

也就是说,DoubanManager 需要依赖 DoubanAuthManager 中的代码才能够顺利运行。

代码依赖的三种解决方案

解决上面这个问题有三种方案:

  • 在内部创建依赖对象
  • 使用依赖注入(推荐方式)
  • 通过参数传递(无状态依赖)

在内部创建依赖对象

在 DoubanManager 内部直接创建 DoubanAuthManager 实例:

@Observable
class DoubanManager {
    // 内部创建依赖
    var authManager = DoubanAuthManager()
    
    func fetchUserFavoriteMovies() async -> [DoubanMovie] {
        // 直接使用内部创建的 authManager
        guard authManager.isLoggedIn, !authManager.userId.isEmpty else {
            return []
        }
        // ... 其余实现
    }
}

// 使用时
let manager = DoubanManager()

这种方式会造成了强耦合,难以测试。不符合 SwiftUI 的最佳实践,不推荐使用。

通过参数传递(无状态依赖)

另一种方法是让 DoubanManager 和 DoubanAuthManager 完全独立,而不是在 DoubanManager 中注入 authManager。

独立模型意味着:

@Observable class DoubanManager { ... } // 不持有 authManager 引用
@Observable class DoubanAuthManager { ... } // 独立的认证管理器

然后在视图中分别注入:

struct ContentView: View {
    @Environment(DoubanManager.self) private var doubanManager
    @Environment(DoubanAuthManager.self) private var authManager
    
    // 在需要时分别使用
}

这种方式在调用具体方法时,通过手动传递参数来解决依赖:

@Observable
class DoubanManager {
    // 不持有 authManager 引用
    
    func fetchUserFavoriteMovies(isLoggedIn: Bool, userId: String) async -> [DoubanMovie] {
        guard isLoggedIn, !userId.isEmpty else {
            return []
        }
        // 使用传入的参数
    }
}

优势是完全解耦,非常易于测试。但方法参数可能变得冗长。

使用依赖注入(推荐方式)

依赖注入(Dependency Injection,简称DI)是一种软件设计模式,它的核心思想是:一个类不应该自己创建它所依赖的对象,而应该从外部接收这些依赖。

简单来说,依赖注入就是将一个对象所需要的其他对象(依赖)从外部传入,而不是在对象内部创建这些依赖

@Observable
class DoubanManager {
    // 从外部接收依赖
    let authManager: DoubanAuthManager
    
    // 构造函数注入
    init(authManager: DoubanAuthManager) {
        self.authManager = authManager
    }
    
    func fetchUserFavoriteMovies() async -> [DoubanMovie] {
        guard authManager.isLoggedIn, !authManager.userId.isEmpty else {
            return []
        }
        // ... 其余实现
    }
}

// 使用时
let authManager = DoubanAuthManager()
let manager = DoubanManager(authManager: authManager)

提高代码的可测试性

不使用依赖注入:

// 测试困难,无法模拟不同的认证状态
func testFetchUserFavorites() {
    let manager = DoubanManager()
    // 无法控制内部的 authManager 状态
    // ...
}

使用依赖注入:

func testFetchUserFavorites() {
    // 创建模拟的认证管理器
    let mockAuth = MockDoubanAuthManager()
    mockAuth.isLoggedIn = true
    mockAuth.userId = "test123"
    
    // 使用模拟对象
    let manager = DoubanManager(authManager: mockAuth)
    
    // 现在可以控制认证状态并测试不同情况
    let movies = await manager.fetchUserFavoriteMovies()
    XCTAssertEqual(movies.count, 5)
}

降低组件间的耦合度

不使用依赖注入:

  • DoubanManager 与具体的 DoubanAuthManager 实现紧密耦合
  • 无法替换为其他认证实现,比如测试版本或不同的认证方式

使用依赖注入:

// 可以定义认证接口
protocol AuthenticationProvider {
    var isLoggedIn: Bool { get }
    var userId: String { get }
    // ...其他必要方法
}

// DoubanManager 依赖于接口而非具体实现
class DoubanManager {
    let authProvider: AuthenticationProvider
    
    init(authProvider: AuthenticationProvider) {
        self.authProvider = authProvider
    }
}

// 可以轻松替换不同的认证实现
let manager1 = DoubanManager(authProvider: DoubanAuthManager())
let manager2 = DoubanManager(authProvider: MockAuthProvider())
let manager3 = DoubanManager(authProvider: AppleAuthProvider()) 

支持单一实例共享(单例模式的更好替代)

@main
struct FilmoApp: App {
    // 在应用入口创建共享实例
    @State private var authManager = DoubanAuthManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                // 通过环境注入共享实例
                .environment(authManager)
                .environment(DoubanManager(authManager: authManager))
        }
    }
}

SwiftUI 中的依赖注入

Swift 中有多重依赖注入的实现方式,例如环境注入、构造函数注入、属性注入和组合对象模式等。

其中最常用的是环境注入和构造函数注入。

构造函数注入(又名初始化器注入)

构造函数注入(Constructor Injection)就是在初始化器(init)中注入依赖,因此又叫做初始化器注入(Initializer Injection)。

典型的实现方式如下:

@Observable
class DoubanManager {
    // authManager 作为依赖
    private let authManager: DoubanAuthManager
    
    // 构造函数注入
    init(authManager: DoubanAuthManager) {
        self.authManager = authManager
    }
    
    // 其他方法可以使用这个依赖
}

// 使用时必须提供依赖
let authManager = DoubanAuthManager()
let doubanManager = DoubanManager(authManager: authManager)

这种方式就是构造函数注入(Constructor Injection),是依赖注入的最常用形式。

环境注入(Environment Injection)

@Observable
class DoubanManager {
    func fetchUserFavoriteMovies(authManager: DoubanAuthManager) async -> [DoubanMovie] {
        guard authManager.isLoggedIn else { return [] }
        // ... 其他代码
    }
}

// 在视图中使用
struct MovieListView: View {
    @Environment(DoubanManager.self) private var doubanManager
    @Environment(DoubanAuthManager.self) private var authManager
    
    var body: some View {
        List {
            // 调用时传入 authManager
            await doubanManager.fetchUserFavoriteMovies(authManager: authManager)
        }
    }
}

如何选择?

构造函数注入和环境注入两种方式都很常用,适用于不同的场景。例如,

  • 构造函数注入:适合服务类之间的依赖(如 DoubanManager 依赖 DoubanAuthManager)。
  • 环境注入:适用于视图层访问服务。

依赖注入的几种初始化方式

在 SwiftUI 应用中,通常会在应用入口处创建这些实例并通过环境传递。

错误方式一

避免使用下面这种方式,这是一种错误的方式:

@main
struct FilmoApp: App {
    // 先创建 authManager
    @State private var authManager = DoubanAuthManager()
    // 使用 authManager 创建 doubanManager
    @State private var doubanManager = DoubanManager(authManager: DoubanAuthManager())
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(authManager)
                .environment(doubanManager)
        }
    }
}

这里的重大问题是:创建了两个不同的 DoubanAuthManager 实例。

// 先创建 authManager
@State private var authManager = DoubanAuthManager()
// 使用 authManager 创建 doubanManager - 但实际上没有使用上面的实例!
@State private var doubanManager = DoubanManager(authManager: DoubanAuthManager())
  • 第一行创建了一个 authManager 实例
  • 第二行又创建了一个全新的 DoubanAuthManager 实例作为参数传入。

错误方式二

另一种常见的错误方式:

@main
struct FilmoApp: App {
    // 先创建 authManager
    @State private var authManager = DoubanAuthManager()
    // 延迟初始化 doubanManager,使用已有的 authManager
    @State private var doubanManager: DoubanManager
    
    init() {
        // 正确引用上面创建的 authManager
        _doubanManager = State(initialValue: DoubanManager(authManager: authManager))
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(authManager)
                .environment(doubanManager)
        }
    }
}

在初始化器中,Xcode 会提示:

'self' used before all stored properties are initialized

这个错误是因为在初始化过程中,尝试访问还未完全初始化的 authManager 属性。

在 Swift 中,使用 @State 修饰的属性实际上是一个属性包装器 (Property Wrapper),其完整初始化是在结构体所有其他存储属性初始化之后才完成的。

正确方式一:使用临时变量(推荐)

@main
struct FilmoApp: App {
    @State private var authManager: DoubanAuthManager
    @State private var doubanManager: DoubanManager
    
    init() {
        // 1. 首先创建临时的认证管理器
        let tempAuthManager = DoubanAuthManager()
        
        // 2. 使用临时变量初始化 State 属性
        _authManager = State(initialValue: tempAuthManager)
        
        // 3. 使用同一个临时变量创建 doubanManager
        _doubanManager = State(initialValue: DoubanManager(authManager: tempAuthManager))
        
        // 现在所有属性都已初始化,可以安全访问 self
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(authManager)
                .environment(doubanManager)
        }
    }
}

正确方式二:使用服务容器

服务容器模式的好处是,不用创建中间变量。

2025 年 3 月 17 日记录:这种方式虽然可以正常工作,但是似乎不符合 Swift 语言的风格,也没有在 Apple 的示例代码中看到类似的实现。

因此,我决定先暂时不在我的项目中使用这种设计模式。

常见疑问

如何访问或更新被依赖项?

可以直接通过环境访问 authManager (推荐)

在使用依赖注入的架构中,通常 DoubanAuthManager 和 DoubanManager 会同时通过环境注入到视图中,因此,被依赖项也是一个单独的实例,我们可以直接访问它:

struct LoginView: View {
    // 直接访问认证管理器
    @Environment(DoubanAuthManager.self) private var authManager
    
    var body: some View {
        Button("登录") {
            // 直接更新认证状态
            Task {
                await performLogin()
                // 登录成功后直接更新状态
                authManager.isLoggedIn = true
                authManager.userName = "获取到的用户名"
                authManager.saveLoginStatus()
            }
        }
    }
}

这种方式符合职责分离原则,登录相关操作直接与认证管理器交互。