用 Swift 建造自己的輕量級 Dependency Injection 與 Inversion Of Control!
你聽過依賴注入嗎?身為 iOS 的開發者,是否對於依賴注入 (Dependency Injection) 與反轉控制 (Inversion Of Control) 的設計模式感到心動呢?接下來就讓我們手把手,不依賴第三方類別庫,打造屬於自己的輕量級 DI 與 IoC,增加程式碼的可讀性與可測試性,也一併提升可維護性與彈性。這篇文章建議大家搭配源碼閱讀。
什麼是依賴注入與反轉控制?
我知道大家都很期待如何在 Swift 中實現這些設計模式,不過別急,讓我們先來了解這些設計模式與使用的好處。
反轉控制 (IoC, Inversion Of Control)
這邊常常聽著就有點繞舌,大家可以稍微記住一個概念,所謂控制是對於整個程式碼執行的流程與順序;而反轉代表沒有使用框架之前,是由工程師自己控制整個程式碼的執行。使用框架之後,執行流程可以由框架來控制,控制權由工程師「反轉」到了框架上。
當然,框架也是其他工程師寫出來的,所以只要確認目前你需不需要自己控制流程細節,或者框架幫你決定,就可以判斷出是不是有 IoC 的設計。
依賴注入 (DI, Dependency Injection)
依賴注入是屬於一種非常具體的工程技巧。我們不通過新增實例的方式在依賴者內部建立依賴對象,而是將依賴對象在外部建立好之後,通過構造函數、函數參數等方式傳遞(或注入)給依賴者來使用。
Swift 中要如何實現?
所謂萬事起頭難,實踐的精華在於思路,我們就先來思考一下,要實現一個輕量級的 Swift DI Design,我們需要做哪些步驟?
首先,為了這些需要被依賴的底層組件(如 Services 層),我們肯定需要實現一個容器,來存放與管理這些被依賴類別實例的生命週期。
第二,有了管理底層組件生命週期的功能後,我們要在依賴者中,設計一種模式讓需要的組件可以注入進去。
第三,有了上述的條件之後,我們就可以提昇單元測試程式碼的可測試性,我們可以替換這些依賴類別,調整讓單元測試不用依賴外部服務或 API,用一種非侵入性的方式來撰寫我們的 Unit Test!
實踐:實現 Dependencies
首先,讓我們先建立一個 enum
類別,取名為 Dependencies
:
enum Dependencies { }
接下來我們建立一個名為 NameSpace
的 struct
,作為依賴注入的命名空間:
enum Dependencies { struct NameSpace: Equatable { let rawValue: String static let {{EJS0}} = NameSpace(rawValue: "__default_name_space__") static func === (lhs: NameSpace, rhs: NameSpace) -> Bool { lhs.rawValue == rhs.rawValue } } }
在程式碼中,我們實現了預設的命名空間 default
,並且做了相等的運算式,讓我們等下可以查找容器池裡面的實例。
接著,我們實現容器類別:
final class Container { private var dependencies: [(key: Dependencies.NameSpace, value: Any)] = [] static let {{EJS1}} = Container() func register(_ dependency: Any, for key: Dependencies.NameSpace = .default) { dependencies.append((key: key, value: dependency)) } func unRegisterAll() { dependencies.removeAll() } func resolve<T>(_ key: Dependencies.NameSpace = .default) -> T { return (dependencies .filter { (dependencyTuple) -> Bool in return dependencyTuple.key === key && dependencyTuple.value is T } .first)?.value as! T // swiftlint:disable:this force_cast } }
其中包含了容器池裡面的命名空間、預設的容器池 (default)、註冊依賴 (register)、註銷依賴 (unRegisterAll)、獲取依賴 (resolve) 等方法。
再來我們新增一種注入型別 InjectObject
,並使用 @propertyWrapper
關鍵字來封裝容器、命名空間與查找實例的過程。
@propertyWrapper struct InjectObject<T> { private let dNameSpace: NameSpace private let container: Container var wrappedValue: T { get { container.resolve(dNameSpace) } } init(_ dNameSpace: NameSpace = .default, on container: Container = .default) { self.dNameSpace = dNameSpace self.container = container } }
透過 wrappedValue
,我們可以取得已經註冊在容器池中的依賴,並加以使用!
現在,你的 Dependencies
應該會是下面這樣:
enum Dependencies { struct NameSpace: Equatable { let rawValue: String static let {{EJS2}} = NameSpace(rawValue: "__default_name_space__") static func === (lhs: NameSpace, rhs: NameSpace) -> Bool { lhs.rawValue == rhs.rawValue } } final class Container { private var dependencies: [(key: Dependencies.NameSpace, value: Any)] = [] static let {{EJS3}} = Container() func register(_ dependency: Any, for key: Dependencies.NameSpace = .default) { dependencies.append((key: key, value: dependency)) } func unRegisterAll() { dependencies.removeAll() } func resolve<T>(_ key: Dependencies.NameSpace = .default) -> T { return (dependencies .filter { (dependencyTuple) -> Bool in return dependencyTuple.key === key && dependencyTuple.value is T } .first)?.value as! T // swiftlint:disable:this force_cast } } @propertyWrapper struct InjectObject<T> { private let dNameSpace: NameSpace private let container: Container var wrappedValue: T { get { container.resolve(dNameSpace) } } init(_ dNameSpace: NameSpace = .default, on container: Container = .default) { self.dNameSpace = dNameSpace self.container = container } } }
實踐:註冊依賴
依賴注入框架所需要的基礎設施層已經完成了,接下來讓我們使用它來註冊依賴吧!
在 AppDelegate
中撰寫:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. // Set up DI if (NSClassFromString("XCTest") == nil) { Dependencies.Container.default.register(AccountsAPIServices()) } return true }
在這裡,我們註冊了 API 服務 AccountsAPIServices
。如果你想了解更多有關 AccountsAPIServices
的資訊,可以回顧這一篇文章。
如此一來,我們就成功注入了 API Services
服務。
NSClassFromString("XCTest") == nil
是用來判對是不是進行單元測試中,如果是的話就不注入,由單元測試來注入相對依賴。實踐:使用依賴
現在讓我們回到源碼中的 ContentView
,這是一個示範用的 UI
,fetchBySingleton
代表使用單例模式呼叫 API Services
。fetchByDI
則是使用依賴注入的方式使用 API Services
。
struct ContentView: View { @Dependencies.InjectObject() private var accountsAPIServices: AccountsAPIServices var body: some View { Button(action: { // self.fetchBySingleton() _ = self.fetchByDI() }) { Text("login") } } func fetchBySingleton() -> Promise<Void> { return Promise<Void>.init { (resolver) in _ = AccountsAPIServices.shared.login(email: "[email protected]", password: "14581234").done({ token in print("----- success login -----") resolver.fulfill(()) }).catch({ error in print("----- \(error) -----") resolver.reject(error) }) } } func fetchByDI() -> Promise<Void> { return Promise<Void>.init { (resolver) in _ = accountsAPIServices.login(email: "[email protected]", password: "14581234").done({ token in print("----- success login -----") resolver.fulfill(()) }).catch({ error in print("----- \(error) -----") resolver.reject(error) }) } } }
在上面的程式碼中,我們可以看到 @Dependencies.InjectObject() private var accountsAPIServices: AccountsAPIServices
,我們利用了 Swift
的新特性成功注入了依賴 AccountsAPIServices
。
這個方式不限於 SwiftUI
或 ViewController
使用。是不是很簡單呢,到目前為止,你已經完成了屬於你自己的輕量級 DI
框架了!
實戰:DI 單元測試
接下來,是屬於實戰中比較常見的問題:有了 DI
單元測試怎麼撰寫呢?
首先,我們設定好情境,以 ContentView
為單元測試目標,測試 fetchByDI
是否符合邏輯與正常執行。
謁我們先撰寫一個 Fake 的 API Services
AccountsAPIServicesFake()
:
class AccountsAPIServicesFake: AccountsAPIServices { override func login(email: String, password: String) -> Promise<String> { // 返回 Promise return Promise<String>.init(resolver: { (resolver) in print("---------- success use fake token ----------") resolver.fulfill("----- fake token -----") }) } }
可以看到 AccountsAPIServicesFake
不涉及網路請求,而是主動回應了一個假的 API Token
。
接下來,讓我們直接查看源碼中的單元測試範例。
class swift_native_dependency_injectionTests: QuickSpec { override func spec() { // Set up DI Dependencies.Container.default.register(AccountsAPIServicesFake()) describe("Authorization API Servies") { context("User Sign in") { it("App Post Sign Data Use API") { waitUntil(timeout: 10, action: { (done) in let page = ContentView() _ = page.fetchByDI().done({ _ in done() }).catch({ _ in expect { () -> Void in fatalError() }.to(throwAssertion()) }) }) } } } } }
在程式碼中,可以看到我們先注入了假類別 AccountsAPIServicesFake
,
並且生成了 ContentView
來測試它。
如此一來,我們實現了單元測試不依靠其他請求為原則,撰寫了單元測試,可以將依賴注入對象自由依照業務需求邏輯做替換,符合單元測試的準則!
此外,我們也可以思考以前用單例、不使用依賴注入的情況下,是不是很難針對這種情況做單元測試呢?今天開始,你可以使用這個輕量的 DI
框架,打造你更高效、易讀與易維護的 iOS App
了!
有了單元測試之後,甚至還可以解放你的生產力加入更多自動化,如果想了解更多,可以參考我的另外一篇文章。
總結
大功告成!到目前為止,我們就完成了自製的輕量級 DI
框架,實現並不難,比較需要思考的是實踐的邏輯與思路,做架構的時候常常需要這種思考模式,先摸清思路與目的。也可以試著常常練習,下次拿到需求時,不妨先在腦中或紙上模擬一番,再動手實現你的設計,或許會體會到更不一樣的工作方式呦!祝你有個美好的 Coding 夜晚,我們下次見,記得源碼在這裡!