SwiftUI 高级依赖注入:构建可测试、可扩展应用的基石

依赖注入(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 实现步骤

  1. 将服务类标记为 @Observable(替代 ObservableObject)。
  2. 在父视图中使用 .environment(serviceInstance) 注入。
  3. 在子视图中使用 @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 驱动应用架构可以这样分层:

  1. App 入口:创建容器或初始化主服务单例。
  2. Scene/Window 级别 :通过环境或 @StateObject 注入根视图模型。
  3. 页面视图 :接收视图模型(通过初始化器),内部通过 @ObservedObject 绑定。
  4. 复用组件 :需要共享状态时通过环境读取(iOS 17 用 @Environment,旧版用 @EnvironmentObject)。
  5. 测试支持:在 XCTest 中切换容器注册或直接构造模拟依赖。

动手尝试:将一个 SwiftUI 应用的网络层、缓存层、用户管理全部抽象为协议,分别用初始化器注入到视图模型,再用环境注入共享用户状态。编写至少 3 个单元测试,验证成功、失败和空数据三种状态。

10. 参考资源


11. 总结

依赖注入不是银弹,但绝对是构建健壮 SwiftUI 应用的必备技能。本文涵盖了五种注入模式,从最基础的初始化器注入,到 iOS 17 带来的原生环境注入,再到容器、工厂和单例。核心思想始终是依赖抽象、从外部提供。无论项目大小,你都可以从中找到平衡简洁性与可测试性的最佳实践。

记住:当你想写 var service = RealService() 时,先停一停,问问自己:"如果我在测试中需要替换它,会怎么做?" 如果答案是"稍后再改",那么现在就是运用依赖注入的最佳时机。

相关推荐
90后的晨仔1 小时前
SwiftUI 中的 Combine:响应式编程完全指南
ios
pop_xiaoli10 小时前
【iOS】RunLoop
macos·ios·objective-c·cocoa
区块block13 小时前
iOS 27 重磅开放:第三方 AI 模型自由切换,苹果生态告别封闭
人工智能·ios
人月神话Lee16 小时前
【图像处理】亮度与对比度——图像的线性变换
ios·ai编程·图像识别
bryceZh17 小时前
iOS26适配-UISplitViewController配置分栏和分屏
ios·ui kit
songgeb17 小时前
NumberFormatter 货币格式化属性详解
ios·swift
for_ever_love__20 小时前
UI学习:数据驱动ce l l
学习·ui·ios·objective-c
KillerNoBlood21 小时前
2026移动端跨平台开发面经总结
android·算法·flutter·ios·移动开发·鸿蒙·kmp
人月神话-Lee1 天前
【图像处理】颜色科学与灰度化——人眼看到的和数字记录的不一样
图像处理·人工智能·计算机视觉·ios·swift