依赖注入(Dependency Injection,简称:DI)是一门古老的设计艺术,但在 SwiftUI 中它焕发出全新的生命力。SwiftUI 推崇值类型视图与声明式描述,这恰好与"从外部注入依赖"的模式一拍即合。掌握好 DI,你的代码将像乐高积木一样灵活------随时替换网络层、切换存储实现、隔离副作用,甚至在不启动完整服务的情况下预览和测试界面。
本文将覆盖 SwiftUI 中五种主流依赖注入方式,从经典的初始化器注入到 iOS 17 带来的新环境注入,一应俱全。每种方式都配有可直接运行的代码与适用场景分析,帮你找到最适合自己项目的那把钥匙。
1. 依赖注入概述
1.1 什么是依赖注入
简单讲,依赖注入就是不让你自己去找依赖,而是让外部把依赖"送"进门。传统写法可能在一个类型内部直接实例化其依赖:
swift
class OrderService {
let database = CoreDataStack() // 硬依赖,无法替换
}
而依赖注入则改为从初始化器传入:
swift
class OrderService {
let database: DatabaseProtocol
init(database: DatabaseProtocol) {
self.database = database
}
}
这样一来,OrderService 依赖抽象的 DatabaseProtocol,而不是具体的 CoreDataStack。在测试中,你可以轻松注入一个模拟数据库,这就是 DI 的魅力。
1.2 为什么 SwiftUI 尤其需要依赖注入
SwiftUI 视图是纯结构体(值类型),每次状态变更都会重新生成 body。如果视图内部直接持有服务实例,不仅会破坏结构体的轻量特性,还会让单元测试和预览变得极其困难。将依赖提升到外部并使用 DI,能够:
- 保持视图纯粹:视图只描述如何将状态转化为 UI,不关心业务逻辑。
- 提升可测试性:注入模拟服务后,视图模型和视图都可单独测试。
- 支持 Preview 独立开发:无需完整环境即可预览特定视图,注入假数据即可。
- 降低耦合:高层模块依赖抽象协议,底层实现可自由更换。
SwiftUI 自身也提供了几种天然的注入通道:@StateObject、@EnvironmentObject、@Environment 以及 iOS 17 的增强环境系统,让依赖传递更加顺畅。
2. 初始化器注入
2.1 原理与优势
初始化器注入是最基本、最符合 SOLID 原则的方式。所有依赖都通过构造器传入,对象的生命周期由调用者管理。这对视图模型尤其有效:你可以将一个遵循 ObservableObject 协议的 ViewModel 通过 @StateObject 注入到视图中。
其最大优势是依赖关系一目了然,没有隐式全局状态,拷贝代码或测试时不会产生意外副作用。
2.2 实战示例
定义一个数据服务协议及其真实实现:
swift
protocol DataServiceProtocol {
func fetchItems() async throws -> [String]
func save(item: String) async throws
}
class RealDataService: DataServiceProtocol {
func fetchItems() async throws -> [String] {
try await Task.sleep(for: .seconds(1))
return ["Apple", "Banana", "Cherry"]
}
func save(item: String) async throws {
// 实际持久化逻辑
print("Saved: \(item)")
}
}
视图模型通过初始化器接收依赖:
swift
@MainActor
class ItemsViewModel: ObservableObject {
@Published var items: [String] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let dataService: DataServiceProtocol
init(dataService: DataServiceProtocol) {
self.dataService = dataService
}
func load() async {
isLoading = true
errorMessage = nil
do {
items = try await dataService.fetchItems()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
视图在初始化时注入依赖,并用 @StateObject 持有视图模型:
swift
struct ItemsView: View {
@StateObject private var viewModel: ItemsViewModel
init(dataService: DataServiceProtocol = RealDataService()) {
_viewModel = StateObject(wrappedValue: ItemsViewModel(dataService: dataService))
}
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.errorMessage {
Text(error).foregroundColor(.red)
} else {
List(viewModel.items, id: \.self) { item in
Text(item)
}
}
Button("Refresh") {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
.task { await viewModel.load() }
}
}
测试时的妙用 :只需创建一个 MockDataService 并注入,即可在不涉及网络或数据库的情况下测试视图模型与视图的表现。
2.3 适用场景
- 视图模型依赖明确、数量不多。
- 需要单元测试时轻松隔离依赖。
- 页面级别的依赖管理。
当依赖需要在多个视图层级之间共享时,单纯靠初始化器注入会导致"属性传递 hell",此时应结合环境注入或单例模式。
3. 环境注入(iOS 17+)
3.1 iOS 17 新特性:@Environment + @Observable
在 iOS 17 之前,跨视图共享依赖主要靠 @EnvironmentObject,它要求显式传递且类型固定。iOS 17 引入了 @Observable 宏和增强的环境系统,允许你将任何 @Observable 类型直接放入环境,子视图通过 @Environment(MyType.self) 获取,无需额外定义环境键。这对于主题、认证状态、共享工具类等横切关注点极为方便。
3.2 实现步骤
- 将服务类标记为
@Observable(替代ObservableObject)。 - 在父视图中使用
.environment(serviceInstance)注入。 - 在子视图中使用
@Environment(ServiceType.self)读取。
完整代码示例
swift
import SwiftUI
import Observation
// 主题服务协议及实现
protocol ThemeServiceProtocol {
var primaryColor: Color { get set }
var isDarkMode: Bool { get set }
}
@Observable
class ThemeService: ThemeServiceProtocol {
var primaryColor: Color = .blue
var isDarkMode: Bool = false
}
// 父视图注入环境
struct EnvironmentInjectionDemo: View {
@State private var themeService = ThemeService()
var body: some View {
VStack {
ThemeSettingsView()
Divider()
ThemePreviewView()
}
.environment(themeService) // 注入 @Observable 实例
}
}
// 子视图读取环境
struct ThemeSettingsView: View {
@Environment(ThemeService.self) private var themeService
var body: some View {
VStack {
Toggle("Dark Mode", isOn: $themeService.isDarkMode)
ColorPicker("Primary Color", selection: $themeService.primaryColor)
}
.padding()
}
}
struct ThemePreviewView: View {
@Environment(ThemeService.self) private var themeService
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(themeService.primaryColor)
.frame(height: 100)
.overlay(Text("Preview"))
}
}
注意 :@Environment(MyType.self) 适用于 @Observable 类型;旧式的 ObservableObject 仍需使用 @EnvironmentObject 或自定义 EnvironmentKey。
3.3 适用场景
- iOS 17+ 的新项目。
- 全局状态(用户会话、主题、权限)需要跨多个无直接关联的视图共享。
- 希望避免手动传递闭包或绑定链的场景。
对于需要支持 iOS 16 及更早版本的应用,可改用 @EnvironmentObject 结合 ObservableObject,或使用自定义环境键(EnvironmentKey)配合手动值传递。
4. 单例模式注入
4.1 单例:爱的反面是恨
单例模式通过 static let shared 提供全局唯一实例,是最简单的依赖注入形式。但同时也是最容易被滥用的。当一个服务本身不需要被替换,且其状态确实需要全局一致性时(如日志器、用户管理器),单例不失为务实之选。但过度使用会导致隐式依赖、难以测试和竞态条件。
4.2 合理的单例设计
确保单例类遵循协议,并在初始化器中锁定创建:
swift
protocol UserManagerProtocol {
var currentUser: User? { get }
func login(username: String, password: String) async throws
func logout()
}
final class UserManager: UserManagerProtocol, @unchecked Sendable {
static let shared = UserManager()
private init() {}
@Published var currentUser: User?
// ... 实现
}
视图中可通过 @StateObject private var userManager = UserManager.shared 或直接访问单例,但为了可测试性,应通过协议抽象并在视图模型中注入协议类型。
4.3 单例与测试和解
即使存在单例,依然可以用初始化器注入在测试中替换为模拟对象。但这要求代码不直接调用 UserManager.shared,而是通过依赖协议调用。因此我建议:即使使用单例,也通过 DI 容器或初始化器将其作为协议实例注入 ,不要到处硬编码 .shared。
5. 工厂模式注入
5.1 根据条件动态选择实现
当同一协议有多个实现,且需要根据运行时条件(如用户设置、Feature Flag)动态切换时,工厂模式可以封装创建逻辑。例如,通知服务可能根据用户选择在本地推送或远程推送之间切换。
swift
protocol NotificationServiceProtocol {
func send(title: String, message: String)
func retrieve() -> [NotificationItem]
}
class LocalNotificationService: NotificationServiceProtocol { ... }
class RemoteNotificationService: NotificationServiceProtocol { ... }
enum NotificationServiceType {
case local, remote
}
struct NotificationServiceFactory {
static func make(_ type: NotificationServiceType) -> NotificationServiceProtocol {
switch type {
case .local: return LocalNotificationService()
case .remote: return RemoteNotificationService()
}
}
}
视图模型通过工厂创建服务,并随状态切换重建:
swift
class NotificationsViewModel: ObservableObject {
@Published var service: NotificationServiceProtocol
init(type: NotificationServiceType = .local) {
service = NotificationServiceFactory.make(type)
}
func changeType(_ newType: NotificationServiceType) {
service = NotificationServiceFactory.make(newType)
}
}
5.2 注意事项
- 工厂应无副作用,只负责创建。
- 避免工厂本身成为新的全局依赖:可将工厂作为协议传入需要的地方。
- 对于复杂创建逻辑,可引入 Builder 模式配合配置参数。
6. 容器依赖注入
6.1 集中管理依赖
当项目规模扩大,依赖关系错综复杂时,手工在初始化器中传递所有依赖变得繁琐且易错。依赖注入容器提供一种集中注册和解析的机制,实现类似服务定位器的功能。与单例不同,容器通常支持按协议类型解析,且可配置生命周期(如每次解析返回新实例或共享实例)。
6.2 实现一个轻量 DI 容器
swift
protocol DIContainerProtocol {
func register<Service>(_ type: Service.Type, factory: @escaping () -> Service)
func resolve<Service>(_ type: Service.Type) -> Service?
}
final class DIContainer: DIContainerProtocol {
private var factories: [String: () -> Any] = [:]
func register<Service>(_ type: Service.Type, factory: @escaping () -> Service) {
let key = String(describing: type)
factories[key] = factory
}
func resolve<Service>(_ type: Service.Type) -> Service? {
let key = String(describing: type)
return factories[key]?() as? Service
}
}
在应用启动时注册依赖:
swift
let container = DIContainer()
container.register(NetworkServiceProtocol.self) { NetworkService() }
container.register(CacheServiceProtocol.self) { CacheService() }
视图模型从容器解析:
swift
class CompositeViewModel: ObservableObject {
let network: NetworkServiceProtocol
let cache: CacheServiceProtocol
init(container: DIContainerProtocol = AppContainer.shared) {
self.network = container.resolve(NetworkServiceProtocol.self)!
self.cache = container.resolve(CacheServiceProtocol.self)!
}
}
6.3 容器注入的优势与陷阱
优势:
- 依赖配置集中,便于替换(比如为 UI 测试注册模拟服务)。
- 避免长初始化参数列表。
陷阱:
- 容易退化为"服务定位器反模式",使依赖变得不透明。
- 类型安全弱于初始化器注入(本文的简单容器通过字符串 key 实现,可改用泛型安全 Key)。
- 可能产生隐含的全局状态。
建议 :容器应作为基础设施,在少数地方使用(如 App 入口、SwiftUI App 的 init),大多数视图仍通过初始化器或环境接收具体依赖。
7. 依赖注入与测试
DI 最大价值之一就是为测试而生。通过注入模拟对象,你可以精确控制依赖的行为,验证调用参数,以及注入错误场景。
swift
class MockDataService: DataServiceProtocol {
var stubItems: [String] = []
var shouldThrowError = false
func fetchItems() async throws -> [String] {
if shouldThrowError {
throw NSError(domain: "Test", code: 1)
}
return stubItems
}
func save(item: String) async throws { }
}
测试视图模型时:
swift
func testLoadItemsSuccess() async {
let mock = MockDataService()
mock.stubItems = ["One", "Two"]
let vm = ItemsViewModel(dataService: mock)
await vm.load()
XCTAssertEqual(vm.items, ["One", "Two"])
XCTAssertFalse(vm.isLoading)
}
因为依赖被抽象成协议,测试完全无需访问数据库、网络或文件系统,速度飞快且结果稳定。
8. 依赖注入最佳实践
8.1 选择合适的注入方式
| 方式 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| 初始化器注入 | 依赖明确,类型安全,测试友好 | 参数过多时繁琐 | 视图模型、服务层 |
| 环境注入 (iOS 17) | 跨视图自动共享,简洁 | 仅限 iOS 17+,可能隐式化 | 全局主题、用户状态 |
| 单例 | 简单易用 | 隐式全局状态,难以替换 | 无状态工具类、日志 |
| 工厂模式 | 运行时灵活切换实现 | 增加抽象层 | 多实现动态选择 |
| 容器 | 集中配置,批量替换 | 易滥用,模糊依赖关系 | 大型应用基础设施 |
8.2 设计原则
- 依赖抽象:所有依赖对外暴露协议,而非具体类。
- 不要过度注入:只有真正需要替换的才注入,简单的数据对象可直接创建。
- 组合优于继承:通过注入不同的实现组合行为,而不是子类化。
- 单一职责:每个服务只做一件事,依赖关系自然清晰。
8.3 避免循环依赖
如果 A 依赖 B,B 又依赖 A,就会造成循环依赖。这通常意味着设计需要重构:提取共同依赖 C,或者通过代理/闭包打破循环。DI 容器在解析时若处理不当,还可能导致运行时栈溢出。
8.4 性能考量
- 创建依赖成本高时,容器应支持单例生命周期,避免每次都新建。
- SwiftUI 视图重建频繁,避免在 body 内解析依赖,应提前注入到 ViewModel 或使用
@StateObject。 - 对于轻量服务,直接注入即可,不要过度设计。
9. 实践项目:构建一个 DI 驱动的 SwiftUI 应用
一个典型的 DI 驱动应用架构可以这样分层:
- App 入口:创建容器或初始化主服务单例。
- Scene/Window 级别 :通过环境或
@StateObject注入根视图模型。 - 页面视图 :接收视图模型(通过初始化器),内部通过
@ObservedObject绑定。 - 复用组件 :需要共享状态时通过环境读取(iOS 17 用
@Environment,旧版用@EnvironmentObject)。 - 测试支持:在 XCTest 中切换容器注册或直接构造模拟依赖。
动手尝试:将一个 SwiftUI 应用的网络层、缓存层、用户管理全部抽象为协议,分别用初始化器注入到视图模型,再用环境注入共享用户状态。编写至少 3 个单元测试,验证成功、失败和空数据三种状态。
10. 参考资源
- Swift with Majid -- Dependency Injection in Swift
- Swift with Majid -- Dependency Injection in SwiftUI
- Apple Developer Documentation -- @Environment
- WWDC23 -- Discover Observation in SwiftUI
- Hacking with Swift -- Dependency Injection in SwiftUI
11. 总结
依赖注入不是银弹,但绝对是构建健壮 SwiftUI 应用的必备技能。本文涵盖了五种注入模式,从最基础的初始化器注入,到 iOS 17 带来的原生环境注入,再到容器、工厂和单例。核心思想始终是依赖抽象、从外部提供。无论项目大小,你都可以从中找到平衡简洁性与可测试性的最佳实践。
记住:当你想写 var service = RealService() 时,先停一停,问问自己:"如果我在测试中需要替换它,会怎么做?" 如果答案是"稍后再改",那么现在就是运用依赖注入的最佳时机。