你聽過依賴注入嗎?身為 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 `default` = 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 `default` = 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 `default` = 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 `default` = 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 夜晚,我們下次見,記得源碼在這裡!