用 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 的约定。
在实际应用中传入 RealWeather,在测试中传入 MockWeather,就能不修改一行代码而切换行为。
改成这种结构后,我觉得编写测试代码轻松多了。
EnvironmentObject 和协议 DI 该在什么时候使用?
不是只能二选一,它们承担着不同的职责。下面是我在实际使用中总结出的判断标准。
| 分类 | EnvironmentObject | 协议 DI |
|---|---|---|
| 主要目的 | 共享页面间的状态 | 替换逻辑依赖 |
| 与视图层级的耦合 | 强(绑定视图) | 无(与视图无关) |
| 测试替换 | 困难 | 非常容易 |
| 缺少注入时 | 运行时崩溃 | 编译阶段阻止 |
实际开发中,使用 EnvironmentObject 管理全局状态,再用协议 DI 管理需要替换的服务,是最稳妥的组合。
总结
如果项目规模还小,EnvironmentObject 就够了。但如果你认真对待测试,建议尽早养成用协议封装服务的习惯。未来的你一定会感谢现在的自己。

