在前两篇文章中,我们建立了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的实例。但如果下次你再次resolveA,你会得到一个全新的A、B、C、D对象图。.weak(弱引用单例): 类似.container,但容器只弱引用该实例。当没有其他地方强引用它时,它会被释放。下次resolve时会重新创建。
团队使用的“黄金法则”
为了避免混淆和误用,我们可以建立一个简单的团队规范:
-
无状态或需全局共享的服务(
APIService,DatabaseService,UserSession)=> 使用.containercontainer.register(NetworkServicing.self) { _ in NetworkService() }.inObjectScope(.container) // 明确指定为容器作用域 -
ViewModel 或任何与特定UI/场景绑定的对象 => 永远使用
.transientcontainer.register(ProductDetailViewModel.self) { r in ProductDetailViewModel(networkService: r.resolve(NetworkServicing.self)!) }.inObjectScope(.transient) // 或者不写,因为这是默认值切记: 将ViewModel注册为
.container是导致页面状态混乱的罪魁祸首! -
一次性业务流中的共享依赖 => 谨慎评估使用
.graph- 场景: 假设我们有一个“创建订单”的流程,这个流程的
Coordinator被resolve出来。这个Coordinator创建了Step1ViewModel和Step2ViewModel。如果这两者都需要一个临时的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 这个回调来完美解决此问题。它将注入过程分为两步:
- 实例化: 先调用
init方法创建对象,此时有循环关系的属性暂时为空。 - 属性注入: 在对象创建完成后,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的依赖注入又有哪些新的玩法和思考。
敬请期待!