用 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 {

}

接下來我們建立一個名為 NameSpacestruct,作為依賴注入的命名空間:

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,這是一個示範用的 UIfetchBySingleton 代表使用單例模式呼叫 API ServicesfetchByDI 則是使用依賴注入的方式使用 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

這個方式不限於 SwiftUIViewController 使用。是不是很簡單呢,到目前為止,你已經完成了屬於你自己的輕量級 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 夜晚,我們下次見,記得源碼在這裡!


自認為終身學習者,對多領域都有濃厚興趣,喜歡探討各種事物。目前專職軟體開發,系統架構設計,企業解決方案。最喜歡 iOS with Swift。

blog comments powered by Disqus
Shares
Share This