依赖注入(三):Swinject实战,玩转生命周期与循环依赖

248 阅读6分钟

在前两篇文章中,我们建立了DI的思想共识,并通过手写一个迷你容器揭开了DI框架的神秘面纱。我们知道了,一个好的DI容器能帮我们自动管理对象的创建和生命周期,把我们从繁琐的手动组装中解放出来。

今天,我们将进入实战环节,聚焦于iOS社区中最流行和强大的DI框架之一:Swinject。我们将学习如何优雅地在项目中使用它,并重点攻克两个核心难点:生命周期的正确使用循环依赖的解决方案

1. 为什么选择Swinject?

在众多Swift DI框架中,Swinject脱颖而出,因为它:

  • 功能强大且成熟: 支持多种生命周期、属性注入、循环依赖解决、模块化注册等高级功能。
  • 纯Swift实现: 充分利用了Swift的类型系统,提供了较好的类型安全。
  • 社区活跃: 拥有完善的文档和庞大的用户群体,遇到问题容易找到解决方案。

2. Swinject核心组件:Container, Assembly, Assembler

想象一下,如果把所有服务的注册代码都写在一个地方,那将是新的灾难。Swinject通过三个核心概念帮助我们进行模块化管理:

  • Container: 这就是我们熟悉的DI容器本身,负责注册和解析服务。
  • Assembly: 这是一个协议,我们可以创建遵循此协议的类,将相关联的依赖注册逻辑组织在一起。比如,所有网络相关的依赖可以放在NetworkAssembly里,所有ViewModel相关的可以放在ViewModelAssembly里。这极大地提高了代码的可读性和维护性。
  • Assembler: 这是一个“组装工”,它的作用是把多个Assembly实例“组装”到一个Container中。这是我们将模块化配置应用到主容器的方式。

一个典型的组织结构:

// 1. 定义模块化的 Assembly
class NetworkAssembly: Assembly {
    func assemble(container: Container) {
        container.register(NetworkServicing.self) { _ in
            NetworkService()
        }
        // ... 其他网络相关的注册
    }
}

class ViewModelAssembly: Assembly {
    func assemble(container: Container) {
        container.register(ProductDetailViewModel.self) { r in
            // Swinject自动解析依赖!
            // r 是一个解析器(Resolver),可以用来获取其他依赖
            ProductDetailViewModel(networkService: r.resolve(NetworkServicing.self)!)
        }
        // ... 其他ViewModel相关的注册
    }
}

// 2. 在应用的组合根(如AppDelegate)使用 Assembler
let assembler = Assembler([
    NetworkAssembly(),
    ViewModelAssembly()
])
let container = assembler.resolver // Assembler内部会创建一个Container,并通过resolver属性暴露出来

3. 重点精讲:Swinject的生命周期(Scope)最佳实践

正如我们在第二篇中强调的,错误地使用生命周期是DI中最常见的错误。Swinject提供了比我们手写版本更丰富的Scope选项。

  • .transient (瞬时): 默认作用域。每次resolve都会创建一个新实例。
  • .container (容器/单例): 在容器的生命周期内,只创建一个实例。后续resolve都返回这同一个实例。这等同于我们手写的singleton
  • .graph (对象图): 这是Swinject一个非常强大且独特的作用域。当resolve一个对象A时,如果其依赖链(A -> B -> D, A -> C -> D)中有多处需要同一个依赖D,.graph能保证在这一次resolve调用中,它们获取到的是同一个D的实例。但如果下次你再次resolve A,你会得到一个全新的A、B、C、D对象图。
  • .weak (弱引用单例): 类似.container,但容器只弱引用该实例。当没有其他地方强引用它时,它会被释放。下次resolve时会重新创建。

团队使用的“黄金法则”

为了避免混淆和误用,我们可以建立一个简单的团队规范:

  1. 无状态或需全局共享的服务(APIService, DatabaseService, UserSession)=> 使用 .container

    container.register(NetworkServicing.self) { _ in
        NetworkService()
    }.inObjectScope(.container) // 明确指定为容器作用域
    
  2. ViewModel 或任何与特定UI/场景绑定的对象 => 永远使用 .transient

    container.register(ProductDetailViewModel.self) { r in
        ProductDetailViewModel(networkService: r.resolve(NetworkServicing.self)!)
    }.inObjectScope(.transient) // 或者不写,因为这是默认值
    

    切记: 将ViewModel注册为.container是导致页面状态混乱的罪魁祸首!

  3. 一次性业务流中的共享依赖 => 谨慎评估使用 .graph

    • 场景: 假设我们有一个“创建订单”的流程,这个流程的Coordinatorresolve出来。这个Coordinator创建了Step1ViewModelStep2ViewModel。如果这两者都需要一个临时的OrderDraft对象来共享草稿数据,那么将OrderDraft注册为.graph作用域是最合适的。这样,在整个“创建订单”流程中,数据得以共享,而当流程结束后,下次再发起创建时,会是一个全新的、干净的OrderDraft

4. 重点攻坚:优雅地解决循环依赖

A依赖B,同时B又依赖A时,构造函数注入会失败,因为在创建A时需要一个完整的B,而在创建B时又需要一个完整的A,这会形成一个死循环。

一个真实的业务例子:

  • AuthenticationManager:负责用户的登录、登出流程。
  • APIService:负责所有网络请求。它需要AuthenticationManager来获取token,并添加到请求头中。
  • 循环点: 当登录失败(如token过期)时,APIService需要通知AuthenticationManager执行登出或刷新token的操作。

这样就形成了 AuthenticationManager -> APIService -> AuthenticationManager 的循环。

Swinject 提供了 initCompleted 这个回调来完美解决此问题。它将注入过程分为两步:

  1. 实例化: 先调用init方法创建对象,此时有循环关系的属性暂时为空。
  2. 属性注入: 在对象创建完成后,Swinject调用initCompleted闭包,此时再去解析并设置那个导致循环的属性。

代码实践:

class CircularDependenciesAssembly: Assembly {
    func assemble(container: Container) {
        
        // 注册 APIService
        container.register(APIService.self) { r in
            APIService(authManager: r.resolve(AuthenticationManager.self)!)
        }.inObjectScope(.container)
        
        
        // 注册 AuthenticationManager,并解决循环依赖
        container.register(AuthenticationManager.self) { _ in
            AuthenticationManager()
        }
        .inObjectScope(.container)
        .initCompleted { r, authManager in
            // 在 authManager 初始化完成后,
            // 再将 APIService 实例注入给它。
            // 此时 APIService 已经可以被正常 resolve 出来了。
            authManager.apiService = r.resolve(APIService.self)!
        }
    }
}

// 对应的类定义
class AuthenticationManager {
    // 使用属性注入来打破循环
    var apiService: APIService!
    
    func login() { /* ... */ }
}

class APIService {
    // 使用构造函数注入
    private let authManager: AuthenticationManager
    
    init(authManager: AuthenticationManager) {
        self.authManager = authManager
    }
}

通过这种方式,我们将强依赖关系(构造函数注入)和弱一些的、可延迟设置的依赖关系(属性注入)结合起来,优雅地打破了初始化时的死循环。

总结与避坑

今天我们掌握了Swinject的核心用法。请记住:

  • 使用Assembly来模块化你的依赖注册。
  • 严格遵守生命周期使用法则,特别是ViewModel必须是.transient
  • 利用initCompleted来解决循环依赖问题。

最后,一个重要的避坑指南: 不要把DI容器本身当作依赖注入到你的业务类中! 如果你发现自己正在写 MyViewModel(container: container),然后在其内部调用container.resolve(XXX.self),那么你又回到了我们第一篇中批评的“服务发现”模式的老路上了。这会再次隐藏依赖,让代码变得难以理解和测试。DI容器应该只存在于应用的“组合根”和少数负责对象创建的“工厂”或“Coordinator”中。

在下一篇中,我们将拓宽视野,看看在声明式的UI世界里,特别是对比Flutter的Riverpod,SwiftUI的依赖注入又有哪些新的玩法和思考。

敬请期待!