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() 时,先停一停,问问自己:"如果我在测试中需要替换它,会怎么做?" 如果答案是"稍后再改",那么现在就是运用依赖注入的最佳时机。

相关推荐
HouWan1 小时前
SwiftUI 小技巧:如何读取 SwiftUI Font 的详细信息
ios·swiftui·apple
李游Leo1 小时前
HarmonyOS 7 实战开发 05:把页面做到可上线状态
ios·harmonyos
终端安全笔记7 小时前
描述文件、企业证书、MDM 不是三个名字,是三层
android·ios·智能手机
传奇开心果编程7 小时前
【SwiftUI入门练中学】第1课 从零开始
学习·ui·ios·swiftui·swift
丿Wayne13 小时前
iOS Token 刷新实战(Actor 避免并发重复刷新)
ios·swift·token·actor·swift并发
终端安全笔记1 天前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
大熊猫侯佩1 天前
独立 App 首发完成 iPhone Duo 展开态适配
ios·swiftui·swift
光影少年1 天前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架
2501_916008891 天前
SSE 和 gRPC 流式接口如何抓包,调试 SSE 与 gRPC 流式接口
网络协议·计算机网络·网络安全·ios·adb·https·udp
HouWan2 天前
Flutter: MediaQuery.of(context) 为什么可能拖慢页面?
android·flutter·ios