iOS 依賴注入/控制反轉 + 實際專案的運用

語言: CN / TW / HK

一、背景:

近來在給deepLink功能新增單元測試,發現程式碼好些地方耦合嚴重,沒辦法寫單元測試,通過學習發現可以使用依賴注入/控制反轉的方式,把關鍵程式碼通過外部注入,從而進行單元測試。

二、依賴注入(Dependency Injection) / 控制反轉(Inversion of Control)

用電腦和CPU的關係來說明一下:電腦的能力由CPU決定,電腦 依賴 CPU

非依賴注入: 可理解為電腦和CPU是耦合在一起的,建立電腦的時候,就已經決定了使用何種CPU,也就是說電腦的效能已經不可改變。

依賴注入: 可理解為電腦為CPU提供了個介面,可以通過介面更換CPU,從而提升電腦的效能。電腦和CPU不再耦合在一起了。可以根據效能需求,更替不同的CPU。

  • 非依賴注入 ``` class CPU {}

    class Computer { let cpu: CPU = CPU() }

    //VC let compture = Computer() ```

  • 依賴注入 ``` class CPU {}

    class Computer { var cpu: CPU?

    init(cpu: CPU) {
        self.cpu = cpu
    }
    

    }

    //VC let cpu = CPU() let compture = Computer(cpu: cpu) ```

依賴注入: 電腦和CPU不再是強依賴關係。CPU是由外部給予電腦的,電腦和CPU有依賴,但是這個依賴是外部給予,因此我們可以說CPU是由外部注入給他的。

控制反轉: 而反過來說,電腦搭配何種CPU,具備何種效能,不是他內部自身控制的,而是由外部控制的,外部來決定電腦該具備什麼效能,所以CPU的控制權被由自身控制反轉為外部控制。

通過這個簡單的例子,可以看出其實 依賴注入 和 控制反轉 說的是同一件事情,只是站的角度不同而已。

三、非依賴注入和依賴注入某些場合下的對比:

  • 哪天調整了CPU類的初始化方法,需要傳個品牌名稱: class CPU { var name: String init(name: String) { self.name = name } }

    • 非依賴注入:需要修改Computer中的cpu變數。 ``` class Computer { let cpu: CPU = CPU(name: "Intel") }

    let compture = Computer() - 依賴注入:只需要在VC中,建立Computer物件時,注入CPU物件即可。 class Computer { var cpu: CPU?

    init(cpu: CPU) {
        self.cpu = cpu
    }
    

    }

    let cpu = CPU(name: "Intel") let compture = Computer(cpu: cpu)) ```

  • 想在電腦上使用不同的品牌的CPU: class CPU1: CPU {}

    • 非依賴注入:又要修改Computer類內部的cpu變數 ``` class Computer { let cpu: CPU1 = CPU1(name: "AMD") }

    let compture = Computer() - 依賴注入:無需修改Computer類,只需要在VC中修改一下即可 class Computer { var cpu: CPU?

    init(cpu: CPU) {
        self.cpu = cpu
    }
    

    }

    let cpu = CPU1(name: "AMD") let compture = Computer(cpu: cpu) ```

  • 核心優點:利於自動化測試。

    給Computer類新增introduction()方法,並根據不同的CPU品牌去測試該方法:

    • 非依賴注入:改不了Computer裡的cpu變數,只能測當前1種品牌。做不到自動化測試。 ``` class Computer { let cpu: CPU = CPU(name: "Intel") func introduction() -> String { "I use (cpu.name) cpu" } }

    func testIntelCPU() { let computer = Computer() XCTAssertEqual(computer.introduction(), "I use Intel cpu") } - 依賴注入:傳入不同品牌的CPU,即可自動化測試所有品牌 class Computer { var cpu: CPU?

    init(cpu: CPU) {
        self.cpu = cpu
    }
    
    func introduction() -> String {
        "I use \(cpu.name) cpu"
    }
    

    }

    func testIntelCPU() { let cpu = CPU(name: "Intel") let computer = Computer(cpu: cpu) XCTAssertEqual(computer.introduction(), "I use Intel cpu") }

    func testAMDCPU() { let cpu = CPU(name: "AMD") let computer = Computer(cpu: cpu) XCTAssertEqual(computer.introduction(), "I use AMD cpu") } ``` Computer依賴CPU,假如CPU中又有其他物件,即CPU依賴其他類,而其他類又可能有各自的依賴,這樣的話,使用依賴注入就相當有必要了。

    四、實際開發中,使用依賴注入的例子:

    • 開啟MainViewController頁面時,預設顯示LoadingView,此時發起網路請求,根據請求結果顯示相應的頁面:
    • 預設顯示LoadingView
    • 網路請求成功,顯示SuccessView
    • 網路請求失敗,顯示FailureView

    ``` final class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad()

        view = LoadingView()
    
        //網路請求
        client.fetchSomething(.cacheFirst)
            .deliverOnUIQueue()
            .onComplete { result in
                switch result {
                case .success:
                    view = SuccessView()
                case .failure(let error):
                    view = FailureView()
                }
            }
    }
    

    } - 為了測試3種狀態下的頁面顯示情況,所以需要將網路請求部分作為依賴注入,所以建立一個協議`MainPageProvider`,原始碼修改為: protocol MainPageProvider: AnyObject { func loadData(completion: @escaping (Result<(), Error>) -> Void) }

    final class MainViewController: UIViewController { lazy var mainPageProvider: MainPageProvider = self

    override func viewDidLoad() {
        super.viewDidLoad()
    
        view = LoadingView()
    
        //網路請求
        mainPageProvider.loadData { result in
            switch result {
            case .success:
                view = SuccessView()
            case .failure(let error):
                view = FailureView()
            }
        }
    }
    

    }

    extension MainViewController: MainPageProvider { func loadData(completion: @escaping (Result<(), Error>) -> Void) { client.fetchSomething(.cacheFirst) .deliverOnUIQueue() .onComplete { result in switch result { case .success: completion(.success(())) case .failure(let error): completion(.failure(error)) } } } } ```

  • 在單元測試中,建立一個Mock類MockMainPageProvider遵循MainPageProvider協議,從而自定義協議方法,將網路請求部分作為依賴注入到MainViewController中,這樣就可以自動化測試3種view的顯示情況了。 ``` final class MainViewControllerTests: XOTestCase { var mockMainPageProvider: MockMainPageProvider! var mainViewController: MainViewController!

    override func setUp() {
        super.setUp()
        mockMainPageProvider = MockMainPageProvider()
        mainViewController.mainPageProvider = mockMainPageProvider
    }
    
    override func tearDown() {
        mockMainPageProvider = nil
        mainViewController = nil
        super.tearDown()
    }
    
    func testMainPageLoadingView() {
        mockMainPageProvider.state = .loading
        mainViewController.viewDidLoad()
        XCTAssertTrue(mainViewController.view is LoadingView)
    }
    
    func testMainPageSuccessView() {
        mockMainPageProvider.state = .success
        mainViewController.viewDidLoad()
        XCTAssertTrue(mainViewController.view is SuccessView)
    }
    
    func testMainPageSuccessView() {
        mockMainPageProvider.state = .failure
        mainViewController.viewDidLoad()
        XCTAssertTrue(mainViewController.view is FailureView)
    }
    

    }

    private class MockMainPageProvider: MainPageProvider { enum State { case loading, success, failure } var state: State = .loading func loadData(completion: (Result<(), Error>) -> Void) { switch state { case .loading: break case .success: completion(.success(())) case .failure: completion(.failure(NSError())) } } } ```

五、參考文章: