軟體設計

SwiftUI 相依性注入:從 EnvironmentObject 到協定 DI(實戰總結)

使用 SwiftUI 開發應用程式時,光是聽到「相依性注入」這個詞,常常就會不自覺緊張起來。

閱讀 3 分鐘
SwiftUI 相依性注入:從 EnvironmentObject 到協定 DI(實戰總結) 封面圖

使用 SwiftUI 開發應用程式時,光是聽到「相依性注入」這個詞,常常就會不自覺緊張起來。

看似只要一個 EnvironmentObject 就夠了,但應用程式稍微變大後,各處就會開始出問題。

本文會依照我整理出的順序,從 EnvironmentObject 開始,一路介紹 SwiftUI 相依性注入到協定導向 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 就足夠了;但若你認真看待測試,建議提早養成用協定包裝服務的習慣。未來的你一定會感謝現在的自己。