软件设计

SwiftUI 依赖注入:从 EnvironmentObject 到协议 DI(实战总结)

用 SwiftUI 开发应用时,光是听到“依赖注入”这个词,就很容易莫名紧张起来。

3 分钟阅读
SwiftUI 依赖注入:从 EnvironmentObject 到协议 DI(实战总结) 封面图

用 SwiftUI 开发应用时,光是听到“依赖注入”这个词,就很容易莫名紧张起来。

看起来一个 EnvironmentObject 就能解决问题,但应用稍微变大后,各处就会开始出现问题。

本文将按照我整理出的顺序,介绍 SwiftUI 依赖注入:从 EnvironmentObject 开始,一直到基于协议的 DI(Dependency Injection)。

EnvironmentObject 是在页面之间共享值的工具。如果想要真正易于替换的结构,应该用协议抽象服务后再注入。


依赖注入到底是什么?

很多文章会把依赖注入讲得很复杂,但我会这样解释。

对象不自己创建要使用的工具,而是从外部接收它们。

例如,如果 ViewModel 在内部直接 new 网络服务,那么以后测试时就必须调用真实服务器。

但如果从外部传入这个服务,测试时就可以传入一个伪服务。

就是这样。这个概念本身真的很简单。


我从 EnvironmentObject 开始

学习 SwiftUI 时,最先接触到的功能之一就是 EnvironmentObject。

从父视图注入一次后,任何子视图都能取出来使用,所以刚开始时确实非常方便。

如下所示,在根部注入一次后,就能在子视图中直接取出使用。

class UserStore: ObservableObject {
    @Published var name = ""
}
// 在根部注入
ContentView().environmentObject(UserStore())
// 从任意子视图
@EnvironmentObject var store: UserStore

对于登录状态或主题这类需要在整个应用中共享的值,没有比它更方便的工具了。

漏掉一次注入,编译时不会提示,但运行时会崩溃
漏掉一次注入,编译时不会提示,但运行时会崩溃

页面变多后,问题就出现了。忘记注入 EnvironmentObject 时,应用会在运行时直接崩溃,而编译器不会告诉你任何信息。

毕竟它是绑定在视图层级上的工具,用在视图外的纯逻辑中总觉得不太自然。


所以我转向了协议 DI

从这里开始才是真正的重点:将服务定义为协议,而不是具体类型。

下面是将实际实现和测试用伪实现绑定到同一个协议上的示例。

protocol WeatherService {
    func fetch() async -> Int
}
struct RealWeather: WeatherService {
    func fetch() async -> Int { 23 }
}
struct MockWeather: WeatherService {
    func fetch() async -> Int { 999 } // 测试用固定值
}

ViewModel 不知道 RealWeather,只知道名为 WeatherService 的约定。

ViewModel 眼中只有协议
ViewModel 眼中只有协议

在实际应用中传入 RealWeather,在测试中传入 MockWeather,就能不修改一行代码而切换行为。

改成这种结构后,我觉得编写测试代码轻松多了。

实际使用时,只需把 RealWeather 换成 MockWeather 就行
实际使用时,只需把 RealWeather 换成 MockWeather 就行

EnvironmentObject 和协议 DI 该在什么时候使用?

不是只能二选一,它们承担着不同的职责。下面是我在实际使用中总结出的判断标准。

分类 EnvironmentObject 协议 DI
主要目的 共享页面间的状态 替换逻辑依赖
与视图层级的耦合 强(绑定视图) 无(与视图无关)
测试替换 困难 非常容易
缺少注入时 运行时崩溃 编译阶段阻止

实际开发中,使用 EnvironmentObject 管理全局状态,再用协议 DI 管理需要替换的服务,是最稳妥的组合。


总结

如果项目规模还小,EnvironmentObject 就够了。但如果你认真对待测试,建议尽早养成用协议封装服务的习惯。未来的你一定会感谢现在的自己。