软件设计

Swift DI 库 Factory 总结:它和 Swinject 有什么不同?

Factory 是一个能在编译时发现依赖注册遗漏的 Swift DI 库。本文以 2026 年 7 月的 3.3.1 为基准,整理基本用法、作用域声明、测试与预览注入,以及它与 Swinject 的区别。

5 分钟阅读
Swift DI 库 Factory 总结:它和 Swinject 有什么不同? 封面图

上一篇我们介绍了 SOLID(面向对象五大设计原则)的最后一个字母 DIP(依赖倒置原则)。理解这一原则后,下一个问题自然会出现:“那么,在实际应用中,这些依赖由谁来组装,又该怎么组装?”

项目较小时,通过初始化器直接传入依赖的手动 DI(Dependency Injection,依赖注入)就够用了。但当页面增加到几十个、依赖图变得更深时,组装代码本身也会开始成为负担。今天介绍适合在这个阶段使用的 Swift DI 库 Factory。本文以 2026 年 7 月的最新版本 3.3.1 为准。


手动 DI 能撑到什么程度?

遵循 DIP 的代码,大致是下面这种形态。

protocol NetworkProviding {
    func fetch(_ url: URL) async throws -> Data
}

final class OrderViewModel {
    private let network: NetworkProviding

    init(network: NetworkProviding) {
        self.network = network
    }
}

代码依赖协议,具体实现从外部注入。到这里还不需要库。问题出在负责组装的一方。

// 某处的组装点(Composition Root)
let network = NetworkProvider()
let repository = OrderRepository(network: network)
let analytics = AnalyticsService(network: network)
let viewModel = OrderViewModel(repository: repository, analytics: analytics)

当依赖深入到三层、四层时,这种初始化代码会在每个页面中重复。一旦给中间层增加一个依赖,就必须修改所有经过它的初始化代码。这正是人们想逃到单例的时刻,但你应该已经知道单例会怎样阻碍测试。DI 容器正是用来接管组装问题的工具。

Factory 的不同之处:编译时安全

提到 Swift DI 库,Swinject 长期以来几乎一直被当作标准方案。Swinject 基于字符串或类型注册,并通过 resolve() 获取,因此遗漏注册时编译仍能通过,运行时才会爆出 nil。也就是说,必须运行应用才能发现错误。

Factory 颠覆了这一点。由于依赖被定义为 Container 的计算属性,引用不存在的依赖时根本无法编译。拼写错误和注册遗漏都会在构建阶段被发现。

此外,它还是一个可执行代码不到 1,000 行的轻量库,只用纯 Swift 运行,无需编译时代码生成脚本。不像 Needle 那样需要把额外工具接入构建流水线。

3 分钟掌握 Factory 基本用法

使用 SPM(Swift Package Manager)安装。包地址是 https://github.com/hmlongco/Factory,从 3.x 开始的导入方式是 FactoryKit

通过在 Container 扩展中添加计算属性来注册依赖。

import FactoryKit

extension Container {
    var networkService: Factory<NetworkProviding> {
        self { NetworkProvider() }
    }
    var orderRepository: Factory<OrderRepositoryType> {
        self { OrderRepository(network: self.networkService()) }
    }
}

获取依赖有三种方式。

// 1. 属性包装器注入
final class OrderViewModel {
    @Injected(\.orderRepository) private var repository
}

// 2. 直接调用
let repository = Container.shared.orderRepository()

// 3. 初始化器注入 — 只把组装交给容器
extension Container {
    var orderViewModel: Factory<OrderViewModel> {
        self { OrderViewModel(repository: self.orderRepository()) }
    }
}

第 3 种方式值得关注。类本身保持完全不知道库存在的纯初始化器注入形式,只有组装代码交给容器负责。这与 DIP 篇中介绍的“具体实现的组装属于外部职责”这一原则完全一致。

在 SwiftUI 中,可以通过 @InjectedObservable 直接获取 Observable 视图模型。

struct OrderView: View {
    @InjectedObservable(\.orderViewModel) var viewModel
}
容器像传送带一样组装依赖模块并将其送入应用页面的插图
只需在注册表中声明,组装就交给容器处理

作用域:通过声明定义实例生命周期

使用 DI 容器的另一个原因是管理实例生命周期。在 Factory 中,只需给注册表添加一个修饰符。

extension Container {
    var networkService: Factory<NetworkProviding> {
        self { NetworkProvider() }.singleton
    }
    var imageCache: Factory<ImageCaching> {
        self { ImageCache() }.cached
    }
}
  • unique — 默认值。每次请求都会创建新实例。
  • singleton — 整个应用共享一个实例。
  • cached — 在重置缓存前返回同一个实例。
  • shared — 只要有人持有强引用就会保持;没有人使用时释放。

与直接创建全局单例对象的区别在于,生命周期不再像 static let 一样散落在代码各处,而是集中声明在一个注册表中。之后想把 singleton 改成 cached 时,只需修改一个修饰符。

测试和预览才是它真正发挥价值的地方

引入 DI 最实用的理由最终还是测试。Factory 可以直接覆盖原有注册。

import FactoryTesting

@Suite(.container)  // 隔离每个测试的容器
struct OrderViewModelTests {
    @Test func loadsOrders() async {
        Container.shared.orderRepository { MockOrderRepository() }
        let viewModel = Container.shared.orderViewModel()
        await viewModel.load()
        #expect(viewModel.orders.count == 3)
    }
}

在 Swift Testing 中,为 FactoryTesting target 添加 .container trait 后,容器状态就不会在测试之间混用。在 SwiftUI 预览中也可以用同样的方式注入 mock。

#Preview {
    Container.shared.orderRepository { MockOrderRepository() }
    return OrderView()
}

还有一种上下文修饰符,只在特定运行环境中自动替换实现。如果只想在 Debug 构建中使用 stub 分析,可以这样做。

container.analytics.onDebug { StubAnalyticsEngine() }

同样,你还可以在注册表中声明测试、预览和模拟器环境各自的覆盖实现。

将机器插槽中的实际模块替换为 mock 模块的测试注入插图
测试时使用 mock 替代真实实现

Factory 3.x 的变化

下面为从 2.x 升级的用户整理主要变化。

  • 导入名称变更import Factory 改为 import FactoryKit。迁移的大部分工作就是完成这一替换。
  • 完整支持 Swift 6 Strict Concurrency — 注册 @MainActor 视图模型时,2.x 需要在闭包中重复写入 @MainActor in,而 3.x 只需在工厂声明处添加注解即可。
  • 仅支持 SPM — 已停止支持 CocoaPods。CocoaPods 项目必须停留在 Factory 2.5.3,或直接嵌入源代码。
  • 支持 Swift Testing — 正式加入了测试隔离支持,包括上面提到的 .container trait。
Factory 架构图:应用代码、测试和预览共享 Container 注册表
应用代码、测试和预览共同使用一个注册表

总结

DIP 告诉我们要依赖抽象而不是具体实现,而 Factory 则降低了在真实代码库中坚持这一方向的成本。编译时安全会将注册遗漏变成构建错误,作用域和 mock 替换也只需几行声明。

没必要重写已经通过 Swinject 正常运行的项目,但如果是新项目,可以把 Factory 作为默认选择。它轻量、引入成本低;如果后来不喜欢,也可以保留初始化器注入结构,只移除容器。

如果手动 DI 已经让你难以为继,组装代码开始拖后腿,不妨试试看。


参考资料

延伸阅读