依赖注入(二):返璞归真,亲手打造一个迷你“依赖注入容器”

154 阅读7分钟

在上一篇文章中,我们明确了依赖注入(DI)的核心思想,并达成了共识:构造函数注入是我们的首选,它让依赖关系变得清晰可见。

但随之而来的一个问题是:如果A依赖BB依赖CC又依赖DE... 那么在应用程序的启动点(我们称之为组合根, Composition Root),手动创建和组装这个复杂的对象图谱,将会是一场噩梦。

// 在 AppDelegate 或 SceneDelegate 中...
let database = DatabaseService()
let userCache = UserCache(database: database)
let apiService = APIService(cache: userCache)
let userManager = UserManager(apiService: apiService)
let profileViewModel = ProfileViewModel(userManager: userManager)
let profileVC = ProfileViewController(viewModel: profileViewModel)
// ... 这还只是一个页面的依赖链,想象一下整个App...

这段代码不仅冗长,而且脆弱。任何依赖关系的变化都可能导致这里的大规模改动。

为了优雅地解决这个问题,依赖注入容器 (DI Container) 应运而生。它就像一个智能的“对象工厂”,我们只需要告诉它“配方”(如何创建各种对象),之后每当我们需要一个对象时,直接向容器索取即可,容器会自动处理所有复杂的依赖关系。

今天,我们不直接使用任何第三方框架。我们将亲手打造一个最简化的DI容器,来揭开它神秘的面纱。目的不是重复造轮子,而是为了彻底理解轮子的构造

1. DI容器要解决的核心三要素

一个DI容器,无论多复杂,其核心职责都可以归结为三点:

  1. 注册 (Register): 向容器中“登记”一个服务(通常是一个协议)和它的具体实现。这就像告诉工厂:“以后谁要Engine(引擎),你就给他一个V8Engine的实例。”
  2. 解析 (Resolve): 当需要一个服务的实例时,向容器“索取”。容器会根据之前注册的“配方”,创建并返回一个实例。
  3. 生命周期管理 (Lifecycle Management): 这是DI容器的灵魂。它决定了解析出来的实例是每次都全新的,还是一个共享的单例。

2. Let's Code: 打造我们的迷你容器MiniContainer

我们来创建一个名为MiniContainer的类。

// MiniContainer.swift

final class MiniContainer {
    // 一个字典,用于存储我们的“配方”
    // Key: 服务的类型名称 (String)
    // Value: 一个闭包,这个闭包知道如何创建服务实例
    private var registrations = [String: () -> Any]()

    /// 注册一个服务
    func register<Service>(_ type: Service.Type, factory: @escaping () -> Service) {
        let key = String(describing: type)
        registrations[key] = factory
    }

    /// 解析一个服务
    func resolve<Service>(_ type: Service.Type) -> Service? {
        let key = String(describing: type)
        // 找到配方,执行它,然后尝试转换成我们需要的类型
        return registrations[key]?() as? Service
    }
}

就这么简单!我们已经有了一个具备基本注册和解析功能的容器了。我们用类型名称的字符串作为Key,用一个返回Any的闭包作为Value。

使用它:

// 定义协议和实现
protocol NetworkServicing { func fetchData() }
class NetworkService: NetworkServicing { 
    func fetchData() { 
        print("Fetching data...") 
    } 
}

// 1. 创建容器实例
let container = MiniContainer()

// 2. 注册服务
container.register(NetworkServicing.self) {
    // 这个闭包就是创建NetworkService的“配方”
    NetworkService()
}

// 3. 解析服务
if let networkService = container.resolve(NetworkServicing.self) {
    networkService.fetchData() // 输出: "Fetching data..."
}

3. 升级!引入灵魂要素:生命周期(Scope)

我们目前的实现,每次调用resolve,工厂闭包都会被执行一次,这意味着我们每次得到的都是一个全新的实例。这在DI术语中称为 瞬时 (Transient) 生命周期。

但很多时候,我们希望某些服务在整个App生命周期中只有一个实例,比如数据库连接、网络请求服务等。这就是 单例 (Singleton) 生命周期。

让我们来为容器增加这个至关重要的功能。

首先,定义一个Scope枚举:

enum Scope {
    case transient // 瞬时:每次都创建新实例
    case singleton // 单例:在容器生命周期内共享同一实例
}

然后,我们需要改造MiniContainer来支持它。

// MiniContainer.swift (升级版)

final class MiniContainer {
    // 用于保存单例实例的缓存
    private var singletons = [String: Any]()

    // “配方”现在需要包含生命周期信息
    private typealias Factory = (scope: Scope, factory: () -> Any)
    private var registrations = [String: Factory]()

    /// 注册服务,并指定生命周期
    func register<Service>(_ type: Service.Type, scope: Scope = .transient, factory: @escaping () -> Service) {
        let key = String(describing: type)
        registrations[key] = (scope: scope, factory: factory)
    }

    /// 解析服务
    func resolve<Service>(_ type: Service.Type) -> Service? {
        let key = String(describing: type)
        
        guard let registration = registrations[key] else {
            // 没有注册过这个服务
            return nil
        }

        switch registration.scope {
        case .transient:
            // 瞬时作用域:直接执行工厂闭包,返回新实例
            return registration.factory() as? Service
            
        case .singleton:
            // 单例作用域:
            // 1. 先检查缓存里有没有
            if let instance = singletons[key] as? Service {
                return instance // 有缓存,直接返回
            }
            // 2. 缓存里没有,就创建一个新的
            let newInstance = registration.factory()
            singletons[key] = newInstance // 存入缓存
            return newInstance as? Service
        }
    }
}

4. 场景辨析与常见混淆

现在我们的容器强大多了。但什么时候该用.singleton,什么时候该用.transient呢?这是一个极易混淆且至关重要的点。用错了会导致App行为异常甚至崩溃。

  • 什么时候用 .singleton (单例) ?

    • 无状态服务:NetworkServiceAPIServiceDatabaseManager这类对象,它们本身不存储可变的状态,只是提供方法。让它们成为单例可以节省内存,避免重复创建。
    • 需要全局共享状态的对象: 比如一个UserSessionShoppingCart,你希望在App的任何地方访问到的都是同一个用户会话或购物车。
  • 什么时候用 .transient (瞬时) ?

    • 持有页面或场景相关状态的对象。 最重要的例子就是 ViewModel!
    • 关键案例分析: 想象一个ProductDetailViewModel。我们有两个不同的商品详情页,如果ProductDetailViewModel被注册为.singleton,那么两个页面将共享同一个ViewModel实例!当你在一个页面上选择商品数量时,另一个页面的数量也会跟着变。这绝对是灾难。因此,ViewModel几乎总是应该是.transient的。
  • 警惕“伪单例”陷阱: 我们实现的.singleton,其生命周期是与container实例绑定的。如果container被销毁,那么它缓存的所有单例实例也会随之被销毁。这被称为容器作用域 (Container Scope)。这与我们过去常用的static let shared这种全局静态单例不同,后者会一直存活到App进程结束。容器作用域的单例给了我们更灵活的控制,比如在用户登出后,我们可以销毁旧的容器(以及里面的用户相关单例),再创建一个新的干净容器。

5. 反思与展望:我们手搓容器的局限性

我们亲手打造的MiniContainer已经能很好地说明DI容器的原理了,但它离一个生产级的框架还差得很远:

  • 类型安全问题: 我们用了大量的StringAny类型转换,这在编译时是不安全的。
  • 依赖的依赖: 我们的resolve方法无法自动解析依赖的依赖。例如,注册A时,如果A需要B,我们必须手动写成container.register(A.self) { A(b: container.resolve(B.self)!) }。专业框架能自动完成这个过程。
  • 循环依赖: 如果A依赖BB又依赖A,我们的容器在解析时会陷入无限递归,导致栈溢出崩溃。
  • 线程安全: 我们的registrationssingletons字典在多线程环境下读写是不安全的。
  • 更复杂的生命周期: 比如前面提到的.graph(对象图作用域)。

正是为了解决这些复杂且棘手的问题,我们才需要在真实项目中引入像Swinject这样成熟、稳定、功能强大的开源框架。

通过今天的实践,我们已经完全理解了DI容器的内核。在下一篇文章中,我们将带着这些底层原理的知识,毫无压力地去学习和使用Swinject,并探讨它在项目中的最佳实践。

敬请期待!