(翻譯) iOS架構(gòu):探索 RIBs

原創(chuàng)作者:Stan Ostrovskiy
原文鏈接:iOS Architecture: Exploring RIBs
原文翻譯:Grabin

Uber 的移動端開發(fā)架構(gòu)細(xì)節(jié)

RIBs是什么丰涉?

加入U(xiǎn)ber是我的iOS工程生涯的新篇章袁铐,所有這一切都始于稱為RIBs的新架構(gòu)辩尊。該體系結(jié)構(gòu)背后的主要思想是:應(yīng)用程序應(yīng)該由業(yè)務(wù)邏輯來驅(qū)動的,而不是視圖装获。理解這個(gè)思想的最佳方法是把它想象成一棵樹:每個(gè)RIB都是一個(gè)節(jié)點(diǎn)籍滴,并且它可以一個(gè)或多個(gè)子節(jié)點(diǎn)伙菜,也有可能一個(gè)節(jié)點(diǎn)都沒有甥角。

RIBs tree



在應(yīng)用程序的整個(gè)生命周期內(nèi)垦搬,RIBs 可以添加或者分離呼寸,創(chuàng)建子節(jié)點(diǎn),并和它交互猴贰,RIBs 代表了 “Router对雪、Interactor、Builder”米绕。

  • Router 負(fù)責(zé)相鄰 RIBs 之間的導(dǎo)航
  • Interactor 是處理RIB業(yè)務(wù)邏輯的主要組件瑟捣。它對用戶交互做出反應(yīng),與后端API溝通栅干,并準(zhǔn)備將要顯示給用戶的數(shù)據(jù)迈套。
  • Builder是一個(gè)將所有RIB片段組合在一起的構(gòu)造函數(shù)

還有一個(gè)可選的 ViewPresenter。View本身沒有任何業(yè)務(wù)邏輯非驮,它僅負(fù)責(zé)呈現(xiàn)UI交汤,并用戶觸摸傳遞給 Interactor。Interactor擁有 View劫笙,并且該 View 通過委托模式與Interactor對話芙扎。Presenter 基本上就是定義了協(xié)議,這些協(xié)議是由 View 實(shí)現(xiàn)的填大。

例如戒洼,在“View”上點(diǎn)擊 “Login” 按鈕將觸發(fā) Interactor 中的Web任務(wù),并且Interactor 將告訴 Presenter 顯示活動指示器允华。Login 調(diào)用成功后圈浇,Interactor 將告訴 Router 導(dǎo)航到下一個(gè)頁面。

這是一個(gè)簡單的概述靴寂,現(xiàn)在我們可以深入研究RIB的每個(gè)組件磷蜀,并了解它們?nèi)绾螀f(xié)同工作。

進(jìn)入RIBs

幸運(yùn)的事情是百炬,我們不必每次想使用所有組件創(chuàng)建新的RIB時(shí)都編寫樣板代碼褐隆。我們可以安裝和配置Xcode模板。要?jiǎng)?chuàng)建一個(gè)新的RIB剖踊,只需打開一個(gè)文件創(chuàng)建菜單庶弃,然后從列表中選擇RIB:

RIBs template for Xcode.png



接下來我們會新建一個(gè) RIB,把它命名為 Login德澈,檢查一下歇攻,它應(yīng)該會擁有一個(gè) View.

image.png



Xcode模板生成4個(gè)文件。我們將仔細(xì)研究它們梆造,并討論它們的功能缴守。

image.png


LoginBuilder

眾所周知,Builder負(fù)責(zé)創(chuàng)建所有 RIB 組件。
請注意斧散,以下所有代碼都是由Xcode模板自動生成的供常。

import RIBs

protocol LoginDependency: Dependency {
    // TODO: Declare the set of dependencies required by this RIB, but cannot be
    // created by this RIB.
}

final class LoginComponent: Component<LoginDependency> {

    // TODO: Declare 'fileprivate' dependencies that are only used by this RIB.
}

// MARK: - Builder
protocol LoginBuildable: Buildable {
    func build(withListener listener: LoginListener) -> LoginRouting
}

final class LoginBuilder: Builder<LoginDependency>, LoginBuildable {

    override init(dependency: LoginDependency) {
        super.init(dependency: dependency)
    }

    func build(withListener listener: LoginListener) -> LoginRouting {
        let component = LoginComponent(dependency: dependency)
        let viewController = LoginViewController()
        let interactor = LoginInteractor(presenter: viewController)
        interactor.listener = listener
        return LoginRouter(interactor: interactor, viewController: viewController)
    }
}



可能你先會注意到的點(diǎn)就是,里面大部分的結(jié)構(gòu)都是協(xié)議鸡捐,而不是具體的類栈暇。這是RIB的主要功能之一,我們將在本文后面討論箍镜。

LoginDependency 用于將依賴項(xiàng)從其父項(xiàng)注入 RIB源祈。例如,我們有一個(gè)webService用于執(zhí)行登錄Web請求色迂。我們創(chuàng)建一個(gè)我們要注入的 WebServicing 協(xié)議:

protocol WebServicing: class {
    func login(userName: String, password: String, handler: (Result<String, Error>) -> Void)
}



現(xiàn)在我們可以更新 LoginDependency 協(xié)議香缺,為 builder 提供對其依賴項(xiàng):

protocol LoginDependency: Dependency {
    var webService: WebServicing { get }
}



這里的下一個(gè)組成部分是 LoginComponent。我們可以聲明一些我們只能在當(dāng)前Builder 中使用的局部變量歇僧,例如配置或者 AdMob ID等图张。在我們的示例中,我們將保留此類诈悍,因?yàn)槲覀儾恍枰魏嗡接幸蕾図?xiàng)祸轮。

下一個(gè)協(xié)議是 LoginBuildable,它只有一個(gè)方法 build(with listener:)侥钳。這里會將父listener作為參數(shù)的注入進(jìn)來使用适袜。我們可以自由地向此構(gòu)建方法添加更多參數(shù),如果符合邏輯的話舷夺。

LoginBuilder 類實(shí)現(xiàn)了 LoginBuildable苦酱,它是此處的主要組成部分。它使用LoginDependency 創(chuàng)建一個(gè) LoginComponent给猾。
LoginComponent 現(xiàn)在封裝了我們?yōu)榇薘IB需要的所有依賴項(xiàng)疫萤。該 Builder 還創(chuàng)建一個(gè) LoginViewController LoginInteractor,用于創(chuàng)建和返回 LoginRouter.

這是另外一行重要的代碼:

interactor.listener = listener


這就是我們將父 Interactor 與子 Interactor 連接的方式敢伸。例如给僵,我們有一個(gè)與 RootRIB 連接的 LoginRIB。在這種情況下详拙,RootInteractor將必須實(shí)現(xiàn)LoginInteractor listener 聲明的方法。如果 LoginInteractor 調(diào)用 dismissLogin蔓同,則 RootRIB 將實(shí)現(xiàn)此方法饶辙,分離 Login 這個(gè) flow 并顯示一個(gè)頁面。



等我們需要使用 Router 的依賴的時(shí)候斑粱, 我們將 return Router弃揽,現(xiàn)在我們轉(zhuǎn)到下一個(gè)組件 Interactor

LoginInteractor

想再次說明一下,下面所有的代碼都是由Xcode模板自動生成的矿微。

import RIBs
import RxSwift

protocol LoginRouting: ViewableRouting {
    // TODO: Declare methods the interactor can invoke to manage sub-tree via the router.
}

protocol LoginPresentable: Presentable {
    var listener: LoginPresentableListener? { get set }
    // TODO: Declare methods the interactor can invoke the presenter to present data.
}

protocol LoginListener: class {
    // TODO: Declare methods the interactor can invoke to communicate with other RIBs.
}

final class LoginInteractor: PresentableInteractor<LoginPresentable>, LoginInteractable, LoginPresentableListener {

    weak var router: LoginRouting?
    weak var listener: LoginListener?

    // TODO: Add additional dependencies to constructor. Do not perform any logic
    // in constructor.
    override init(presenter: LoginPresentable) {
        super.init(presenter: presenter)
        presenter.listener = self
    }

    override func didBecomeActive() {
        super.didBecomeActive()
        // TODO: Implement business logic here.
    }

    override func willResignActive() {
        super.willResignActive()
        // TODO: Pause any business logic.
    }
}



LoginRouting 是我們用來從 Login RIB 導(dǎo)航到后續(xù) RIB 的協(xié)議痕慢。 假設(shè)我們希望能夠?qū)Ш降?CreateAccount 頁面:

protocol LoginRouting: ViewableRouting {
    func routeToCreateAccount()
}



LoginPresentable 用于響應(yīng)在 Interactor 中執(zhí)行的業(yè)務(wù)邏輯來更新 Login 視圖。 如果打開 LoginViewController涌矢,您會注意到它實(shí)現(xiàn)了此協(xié)議掖举。

LoginPresentable 還擁有一個(gè) LoginPresentableListener。 這是LoginViewController與 Interactor 進(jìn)行通信并調(diào)用業(yè)務(wù)邏輯的一種方式娜庇。 換句話說塔次,這是 Interactor 和 ViewController 相互通信的方式

image.png



如上所述,我們希望 ViewController 在執(zhí)行Web任務(wù)時(shí)顯示活動指示器名秀。 為了實(shí)現(xiàn)這一點(diǎn)励负,我們向 LoginPresentable 添加了一個(gè)新方法 showActivityIndicator

protocol LoginPresentable: Presentable {
    var listener: LoginPresentableListener? { get set }
    func showActivityIndicator(_ isLoading: Bool)
}



最終,我們有一個(gè) LoginListener匕得。 還記得 LoginBuilder 中的這一行代碼嗎继榆?

interactor.listener = listener



這是 Root RIB 將要去實(shí)現(xiàn)的 listener。是子級RIB與父級進(jìn)行通信的一種方式汁掠。登錄完成后略吨,我們需要通知Root RIB,可以去取消登錄流程了:

protocol LoginListener: class {
    func dismissLoginFlow()
}



現(xiàn)在我們看一下 LoginInteractor 類调塌。 它有兩個(gè)弱變量晋南,routerlistener。 這就是 Interactor 分別連接到其 router 和 父Interactor 的方式羔砾。 可以看到负间,該 Interactor 還擁有一個(gè) presenter。

我們應(yīng)該記得姜凄,RIB 背后的核心思想是該應(yīng)用程序應(yīng)由業(yè)務(wù)邏輯驅(qū)動政溃。 Interactor 是此業(yè)務(wù)邏輯所在的地方。



這是我們使用 interactor 控制應(yīng)用程序流程的方式:

  • 調(diào)用 presenter 的方法來更新登錄UI(示例中有showActivityIndicator)
  • 調(diào)用 router 的方法導(dǎo)航到子RIB(示例中有routeToCreateAccount)
  • 調(diào)用 listener 的方法與父RIB對話(示例中有dismissLoginFlow)

接下來态秧,我們看到一些生命周期管理的方法 didBecomeActivewillResignActive董虱。 這些方法是不言自明的,我們不會直接調(diào)用它們申鱼。 例如愤诱,我們可以在 didBecomeActive 中執(zhí)行Web任務(wù)以獲取所需的數(shù)據(jù),或者根據(jù)我們的業(yè)務(wù)邏輯進(jìn)行初始視圖設(shè)置捐友。

稍后我們將會回到 interactor淫半,現(xiàn)在讓我們結(jié)束其余的組成部分 —— router, view, 和 presenter。

LoginRouter

同樣匣砖,Xcode模板會自動為您生成以下所有代碼科吭。

import RIBs

protocol LoginInteractable: Interactable {
    var router: LoginRouting? { get set }
    var listener: LoginListener? { get set }
}

protocol LoginViewControllable: ViewControllable {
    // TODO: Declare methods the router invokes to manipulate the view hierarchy.
}

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {
    
    // TODO: Constructor inject child builder protocols to allow building children.
    override init(interactor: LoginInteractable, viewController: LoginViewControllable) {
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
}



LoginInteractable是此處的主要協(xié)議昏滴,包含兩個(gè)組成部分,LoginRoutingLoginListener对人。 我們在 Interactor 中都創(chuàng)建了它們谣殊。

LoginViewControllable 用于操縱視圖層次結(jié)構(gòu)。 因此牺弄,當(dāng)Interactor告訴 router 使用 LoginRouting導(dǎo)航到 CreateAccount 時(shí)姻几,router 最終需要顯示CreateAccount屏幕。 我們需要添加以下方法:

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {
    
    override init(interactor: LoginInteractable, viewController: LoginViewControllable) {
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
    
    func routeToCreateAccount() {
        
    }
}



在展示其viewController之前猖闪,我們需要擁有 CreateAccount RIB鲜棠。 所以這個(gè)時(shí)候我們先創(chuàng)建另一個(gè)RIB。

image.png


我們不會在此RIB中進(jìn)行任何更改培慌,因此只需將其保留并返回 LoginRouter 即可豁陆。

要構(gòu)建CreateAccount RIB,LoginRouter 需要具有 CreateAccountBuilder吵护。 聲明一個(gè)類型為 CreateAccountBuildable 的私有變量盒音,并更新 LoginRouter init,注入 CreateAccountBuildable馅而。

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {
    
    private let createAccountBuilder: CreateAccountBuildable
    
    init(
        interactor: LoginInteractable,
        viewController: LoginViewControllable,
        createAccountBuilder: CreateAccountBuildable
    ) {
        self.createAccountBuilder = createAccountBuilder
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
    
    func routeToCreateAccount() {
        
    }
}


記住祥诽,我們沒有使用具體的類型CreateAccountBuilder。 相反瓮恭,我們使用協(xié)議CreateAccountBuildable



現(xiàn)在我們可以完成routeToCreateAccount方法雄坪。

func routeToCreateAccount() {
    let router = createAccountBuilder.build(withListener: interactor)
    attachChild(router)
    viewController.present(router.viewControllable)
}


  1. 使用createAccountBuilder構(gòu)建一個(gè)createAccountRouter。 我們需要在build方法中將當(dāng)前的 interactor 作為 listener 傳遞屯蹦。
  2. 將createAccountRouter作為子級附加到當(dāng)前 Router维哈。這就是我們構(gòu)建RIB樹的方式。
  3. 我們調(diào)用LoginViewControllable方法來呈現(xiàn)CreateAccount視圖控制器登澜。

在這里會注意到的第一件事是以下編譯器錯(cuò)誤:

Argument type ‘LoginInteractable’ does not conform to expected type ‘CreateAccountListener’

要解決此問題阔挠,我們需要確保LoginInteractable實(shí)現(xiàn)CreateAccountListener協(xié)議:

protocol LoginInteractable: Interactable, CreateAccountListener {
    var router: LoginRouting? { get set }
    var listener: LoginListener? { get set }
}



另一個(gè)要記住的是:我們使用 attachChild 方法附加 createAccountRouter。仔細(xì)想想脑蠕,最終將需要另一種方法來關(guān)閉 CreateAccount 屏幕购撼。 取消子屏幕后,我們必須將其 Router 與當(dāng)前樹分離谴仙。

當(dāng)viewController不再可用迂求,但相應(yīng)的RIB仍在樹中時(shí),我們不想看到程序處于這種狀態(tài)晃跺。 這最終可能導(dǎo)致內(nèi)存泄漏和意外行為揩局。

為了避免這種情況,我們將保留對CreateAccountRouter的引用哼审。 在LoginRouter中創(chuàng)建一個(gè)變量:

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {
    
    private let createAccountBuilder: CreateAccountBuildable
    private let createAccountRouter: CreateAccountRouting?
    
    // ...
}



現(xiàn)在谐腰,讓我們更新 routeToCreateAccount方法。 我們需要將 createAccountRouter 保存到本地變量涩盾。 另外十气,如果已經(jīng)創(chuàng)建了子 Router,我們就可以防止自己創(chuàng)建 Router 和 present子視圖控制器:

func routeToCreateAccount() {
    guard createAccountRouter == nil else { return }
    
    let router = createAccountBuilder.build(withListener: interactor)
    createAccountRouter = router
    attachChild(router)
    viewController.present(router.viewControllable)
}



最后春霍,當(dāng)我們要dismiss CreateAccount屏幕時(shí)砸西,在使用視圖層次結(jié)構(gòu)進(jìn)行操作后,我們必須分離其Router:

func detachCreateAccount() {
    guard let createAccountRouter = createAccountRouter else { return }
    createAccountRouter.viewControllable.uiviewController.dismiss(animated: true, completion: nil)
    detachChild(createAccountRouter)
    self.createAccountRouter = nil
}



Xcode將顯示另一個(gè)編譯器錯(cuò)誤址儒,因此我們需要更新 LoginBuilder 并將 CreateAccountBuilder 傳遞給 router init芹枷。 我們使用LoginBuilder創(chuàng)建并注入一個(gè)子 builder:

final class LoginBuilder: Builder<LoginDependency>, LoginBuildable {
    
    override init(dependency: LoginDependency) {
        super.init(dependency: dependency)
    }
    
    func build(withListener listener: LoginListener) -> LoginRouting {
        let component = LoginComponent(dependency: dependency)
        let viewController = LoginViewController()
        let interactor = LoginInteractor(presenter: viewController)
        interactor.listener = listener
        
        let createAccountBuilder = CreateAccountBuilder(dependency: component.dependency)
        
        return LoginRouter(
            interactor: interactor,
            viewController: viewController,
            createAccountBuilder: createAccountBuilder
        )
    }
}



請注意,我們使用component.dependency 作為 CreateAccountBuilder依賴項(xiàng)莲趣。 為此鸳慈,我們需要LoginDependency來實(shí)現(xiàn)CreateAccountDependency協(xié)議。 這是我們將依賴關(guān)系從父RIB連接到子RIB的方式:

protocol LoginDependency: CreateAccountDependency {
    var webService: WebServicing { get }
}



在我們的示例中喧伞,CreateAccountDependency沒有任何變量走芋。 如果是這樣,我們不得不在某些時(shí)候提供它們潘鲫。 在根組件中創(chuàng)建并保留所有依賴項(xiàng)翁逞,然后使用此協(xié)議繼承傳遞它們,這很方便溉仑。 我們將在本文結(jié)尾處進(jìn)行此操作挖函。

在這一點(diǎn)上,該應(yīng)用程序應(yīng)該編譯沒有任何錯(cuò)誤浊竟。

LoginPresenter/LoginViewController

import RIBs
import RxSwift
import UIKit

protocol LoginPresentableListener: class {
    // TODO: Declare properties and methods that the view controller can invoke to perform
    // business logic, such as signIn(). This protocol is implemented by the corresponding
    // interactor class.
}

final class LoginViewController: UIViewController, LoginPresentable, LoginViewControllable {
    
    weak var listener: LoginPresentableListener?
}



LoginPresentableListener具有良好的自動生成的文檔怨喘。 我們只需要知道我們要在此ViewController上執(zhí)行哪些操作即可。 我們將向LoginPresentableListener添加兩個(gè)方法:

protocol LoginPresentableListener: class {
    func didTapLogin(username: String, password: String)
    func didTapCreateAccount()
}



我們不會專注于UI逐沙,但是如果您希望在實(shí)際操作中看到它哲思,則可以繼續(xù)創(chuàng)建一個(gè)簡單的UI。 確保按鈕觸發(fā)正確的 listener 方法吩案。
LoginViewController類實(shí)現(xiàn)了我們之前配置的LoginPresentable協(xié)議(以便 interactor 可以與viewController通信)棚赔。 這意味著LoginViewController必須實(shí)現(xiàn)showActivityIndicator方法:

final class LoginViewController: UIViewController, LoginPresentable, LoginViewControllable {
    
    weak var listener: LoginPresentableListener?
    
    // MARK: - LoginPresentable
    
    func showActivityIndicator(_ isLoading: Bool) {
        
    }
}



viewController實(shí)現(xiàn)的下一個(gè)協(xié)議是LoginViewControllable(以便 router 可以修改視圖層次結(jié)構(gòu))。 為了符合要求徘郭,LoginViewController必須實(shí)現(xiàn)當(dāng)前方法:

final class LoginViewController: UIViewController, LoginPresentable, LoginViewControllable {
    
    weak var listener: LoginPresentableListener?
    
    // MARK: - LoginPresentable
    
    func showActivityIndicator(_ isLoading: Bool) {
        
    }
    
    // MARK: - LoginViewControllable
    
    func present(_ viewController: ViewControllable) {
        present(viewController.uiviewController, completion: nil)
    }
}


現(xiàn)在靠益,這是我們在LoginViewController中需要做的所有事情。 同樣残揉,您可以添加缺少的UI按鈕胧后,文本字段和 loading狀態(tài)的控件。

因?yàn)槲覀兿騆oginPresentableListener添加了一些方法抱环,并且LoginInteractor實(shí)現(xiàn)了此協(xié)議壳快,所以我們需要向 interactor 添加缺少的方法:

final class LoginInteractor: PresentableInteractor<LoginPresentable>, LoginInteractable, LoginPresentableListener {
    
    // ...
    
    // MARK: - LoginPresentableListener
    
    func didTapLogin(username: String, password: String) {
        
    }
    
    func didTapCreateAccount() {
        
    }
}



didTapCreateAccount必須路由到CreateAccount RIB纸巷,所以我們只需要調(diào)用現(xiàn)有的LoginRouting方法:

func didTapCreateAccount() {
    router?.routeToCreateAccount()
}



要調(diào)用登錄Web任務(wù),我們需要訪問我們之前創(chuàng)建的WebServicing登錄方法眶痰。 我們將把WerServicing傳遞給LoginInteractor init:

final class LoginInteractor: PresentableInteractor<LoginPresentable>, LoginInteractable, LoginPresentableListener {
    
    // ...
    
    private let webService: WebServicing
    
    init(presenter: LoginPresentable, webService: WebServicing) {
        self.webService = webService
        super.init(presenter: presenter)
        presenter.listener = self
    }
    
    // ...
}



在 interactor 中具有WebServicing瘤旨,我們可以完成登錄方法:

func didTapLogin(username: String, password: String) {
    presenter.showActivityIndicator(true)
    webService.login(userName: username, password: password) { [weak self] result in
        self?.presenter.showActivityIndicator(false)
        switch result {
        case let .success(userID):
            // do something with userID if needed
            self?.listener?.dismissLoginFlow()
        case let .failure(error):
            // log error
        }
    }
}



在此方法內(nèi),我們實(shí)現(xiàn)所有登錄業(yè)務(wù)邏輯竖伯,顯示和隱藏loading狀態(tài)的控件存哲,在登錄成功時(shí)關(guān)閉LoginFlow,并在登錄失敗的情況下記錄錯(cuò)誤七婴。 我們還添加另一個(gè)LoginPresentable方法showErrorAlert祟偷,如果登錄失敗,該方法將通知用戶:

protocol LoginPresentable: Presentable {
    var listener: LoginPresentableListener? { get set }
    func showActivityIndicator(_ isLoading: Bool)
    func showErrorAlert()
}



編譯器將確保您已在LoginViewController中實(shí)現(xiàn)此方法打厘。 從登錄失敗的情況下調(diào)用此方法:


webService.login(userName: username, password: password) { [weak self] result in
    self?.presenter.showActivityIndicator(false)
    switch result {
    case let .success(userID):
        // do something with userID if needed
        self?.listener?.dismissLoginFlow()
    case let .failure(error):
        // log error
        self?.presenter.showErrorAlert()
    }
}



最后修肠,我們必須更新LoginBuilder并將WebServicing依賴項(xiàng)傳遞到LoginInteractor中:

final class LoginBuilder: Builder<LoginDependency>, LoginBuildable {
    
    override init(dependency: LoginDependency) {
        super.init(dependency: dependency)
    }
    
    func build(withListener listener: LoginListener) -> LoginRouting {
        let component = LoginComponent(dependency: dependency)
        let viewController = LoginViewController()
        let interactor = LoginInteractor(presenter: viewController, webService: component.dependency.webService)
        interactor.listener = listener
        
        let createAccountBuilder = CreateAccountBuilder(dependency: component.dependency)
        
        return LoginRouter(
            interactor: interactor,
            viewController: viewController,
            createAccountBuilder: createAccountBuilder
        )
    }
}


Top-level RIB

現(xiàn)在,我們?yōu)閼?yīng)用程序提供了完整的登錄模塊婚惫。 如果您想查看全部內(nèi)容氛赐,則必須添加一些缺失的部分。

創(chuàng)建一個(gè)Root RIB先舷,它將成為Login RIB的父級(您應(yīng)該能夠使用上面提供的相同步驟將登錄名連接到root艰管。一些區(qū)別將在RootRouter和RootBuilder中,因?yàn)樗且粋€(gè)頂級RIB蒋川, 沒有父類牲芋。

除了創(chuàng)建RootRouting,我們還需要?jiǎng)?chuàng)建LaunchRouting(為頂級RIB設(shè)計(jì)的特定RIB組件):

import RIBs

protocol RootDependency: Dependency {
}

final class RootComponent: Component<RootDependency> {
    
    private let rootViewController: RootViewController
    
    init(dependency: RootDependency,
         rootViewController: RootViewController) {
        self.rootViewController = rootViewController
        super.init(dependency: dependency)
    }
}

// MARK: - Builder

protocol RootBuildable: Buildable {
    func build() -> LaunchRouting
}

final class RootBuilder: Builder<RootDependency>, RootBuildable {
    
    override init(dependency: RootDependency) {
        super.init(dependency: dependency)
    }
    
    func build() -> LaunchRouting {
        let viewController = RootViewController()
        let component = RootComponent(
            dependency: dependency,
            rootViewController: viewController
        )
        
        let interactor = RootInteractor(presenter: viewController)
        
        return RootRouter(
            interactor: interactor,
            viewController: viewController
        )
    }
}



這是一個(gè)非常具體的案例捺球。

RootRouter還將繼承自LaunchRouting而不是ViewableRouter缸浦,后者是特定于啟動的Router協(xié)議:

final class RootRouter: LaunchRouter<RootInteractable, RootViewControllable>, RootRouting {
    override init(interactor: RootInteractable, viewController: RootViewControllable) {
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
}



我們還需要?jiǎng)?chuàng)建一個(gè)AppComponent,它使用具有EmptyDependency的組件氮兵。 該組件將具有我們要使用依賴協(xié)議傳遞的大多數(shù)依賴裂逐。 您可以創(chuàng)建一個(gè)繼承自WebServicing協(xié)議的WebService類,并將其保留為AppComponent中的變量:

final class AppComponent: Component<EmptyDependency>, RootDependency {
    
}



在AppDelegate中泣栈,我們需要使用此AppComponent創(chuàng)建一個(gè)RootRouter卜高,并在當(dāng)前窗口中對其進(jìn)行啟動:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    private var launchRouter: LaunchRouting?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        let window = UIWindow(frame: UIScreen.main.bounds)
        self.window = window
        
        let launchRouter = RootBuilder(dependency: AppComponent()).build()
        self.launchRouter = launchRouter
        launchRouter.launch(from: window)
        
        return true
    }
}



在這一點(diǎn)上,我們應(yīng)該能夠編譯并啟動該應(yīng)用程序南片。 如果添加缺少的UI掺涛,則可以看到它的實(shí)際效果。

高級 RIBs

Mock 生成實(shí)體類

我在本文開頭提到疼进,在RIBs中薪缆,我們不使用具體類型,而是在大多數(shù)組件和依賴項(xiàng)中使用協(xié)議伞广。 當(dāng)我們想用單元測試覆蓋我們的代碼時(shí)拣帽,這非常方便疼电。 由于RIBs中的所有業(yè)務(wù)邏輯都存在于Interactor中,因此我們嘗試達(dá)到對 Interactor 和 Router 100%的測試覆蓋率减拭。 協(xié)議允許我們模擬我們使用的大多數(shù)類型澜沟,從而可以在不暴露實(shí)際類型的情況下對其進(jìn)行測試。

但是同時(shí)峡谊,模擬協(xié)議是繁瑣的工作,需要大量樣板代碼刊苍。 幸運(yùn)的是既们,有多種工具可讓我們生成協(xié)議的所有模擬。 其中之一是稱為Mockolo的工具正什。 您可以單擊提供的鏈接并安裝依賴項(xiàng)啥纸,當(dāng)然也可以隨時(shí)使用其他模擬生成工具。 使用Mockolo婴氮,您要做的就是用///@ mockable注釋標(biāo)記協(xié)議并運(yùn)行模擬生成斯棒。

例如,我們有一個(gè)要在測試中使用的WebServicing協(xié)議主经。 讓我們?yōu)榇朔?wù)生成模擬:

class WebServicingMock: WebServicing {
    init() { }
    
    var loginCallCount = 0
    var loginHandler: ((String, String, (Result<String, Error>) -> Void) -> ())?
    func login(username: String, password: String, handler: (Result<String, Error>) -> Void) {
        loginCallCount += 1
        if let loginHandler = loginHandler {
            loginHandler(username, password, handler)
        }
        
    }
}



這個(gè) mock類 具有一個(gè)loginCallCount和loginHandler荣暮,我們將使用它們來測試是否調(diào)用了Login方法,以及它是否使用了正確的參數(shù)和結(jié)果罩驻。
我們可以為我們所有的RIB協(xié)議和依賴項(xiàng)生成模擬穗酥,并為廣泛的單元測試范圍打開它。

UnitTests

我將提供一個(gè)示例惠遏,說明如何使用mock生成通過測試覆蓋LoginInteractor砾跃。

讓我們看一下LoginInteractor中的 didTapLogin(:,:) 方法。 這是我們要測試的多種想法:

  • 讓presenter顯示 loading狀態(tài)的控件
  • 讓webService登錄Web任務(wù)
  • 如果登錄任務(wù)成功节吮,則偵聽器應(yīng)調(diào)用dismissLoginFlow方法
  • 如果登錄任務(wù)失敗抽高,則演示者應(yīng)調(diào)用showErrorAlert方法
  • Web任務(wù)完成時(shí),presenter 隱藏 loading狀態(tài)的控件

這是將所有測試組件連接在一起的LoginInteractorTests的初始設(shè)置(模擬是由Mockolo生成的):

final class LoginInteractorTests: XCTestCase {
    private var interactor: LoginInteractor!
    private var presenter = LoginPresentableMock()
    private var listener = LoginListenerMock()
    private var router = LoginRoutingMock()
    private let webService = WebServicingMock()
    
    override func setUp() {
        super.setUp()
        
        interactor = LoginInteractor(presenter: presenter, webService: webService)
        router.viewControllable = ViewControllableMock()
        router.interactable = InteractableMock()
        
        interactor.router = router
        interactor.listener = listener
    }
}



讓我們?yōu)閐idTapLogin方法編寫測試透绩。

func test_didTapLogin_triggersLoginWebTask_andEnableActivityIndicator() {
    presenter.showActivityIndicatorHandler = { isLoading in
        XCTAssertTrue(isLoading)
    }
    
    interactor.didTapLogin(username: "username", password: "password")
    
    XCTAssertEqual(webService.loginCallCount, 1)
    XCTAssertEqual(presenter.showActivityIndicatorCallCount, 1)
}

func test_loginSucceeded_invokesListenerDismissLoginFlow() {
    
    webService.loginHandler = { username, login, handler in
        return handler(.success("userID"))
    }
    
    interactor.didTapLogin(username: "username", password: "password")
    
    XCTAssertEqual(listener.dismissLoginFlowCallCount, 1)
    XCTAssertEqual(presenter.showActivityIndicatorCallCount, 2)
}

func test_loginFailed_invokesPresenterShowErrorAlert() {
    
    webService.loginHandler = { username, login, handler in
        return handler(.failure(WebServiceError.generic))
    }
    
    interactor.didTapLogin(username: "username", password: "password")
    
    XCTAssertEqual(presenter.showErrorAlertCallCount, 1)
    XCTAssertEqual(presenter.showActivityIndicatorCallCount, 2)
}


同樣翘骂,我們可以涵蓋其余的Interactor方法,包括使用我們的didBecome active方法渺贤。 router 可以用相同的方式進(jìn)行測試雏胃。因?yàn)樵赗IB中,我們將大多數(shù)組件作為協(xié)議志鞍,而不是具體類型瞭亮。 此外,router 和 Interactor 都大多包含實(shí)現(xiàn)其他協(xié)議的方法固棚。 使用mock生成统翩,我們無需編寫任何其他代碼即可使用單元測試覆蓋所有應(yīng)用業(yè)務(wù)邏輯仙蚜。

依賴注入

在示例項(xiàng)目中,我們使用Dependency和Component處理依賴關(guān)系厂汗,并且必須從AppComponent一直傳遞下去委粉。 擁有協(xié)議繼承可以使之清晰明了,井井有條娶桦,但是連接所有依賴項(xiàng)仍然很繁瑣贾节。

我們使用了另一個(gè)開源的Uber工具:Needle Dependency Injection

在這里衷畦,我不會詳細(xì)解釋Needle栗涂,但是上面的鏈接提供了很好的解釋,并提供了有關(guān)如何集成和使用它的示例祈争。

總結(jié)

在本文中斤程,我介紹了RIB體系結(jié)構(gòu)的要點(diǎn),解釋了一些極端情況菩混,并提供了其大多數(shù)組件的提示和示例忿墅。

對于這個(gè)小項(xiàng)目,RIB看起來像是過分殺傷沮峡,就像我們在示例中使用的那樣疚脐。 但是,如果您了解這些基礎(chǔ)知識邢疙,那么采用這種架構(gòu)不會花費(fèi)太多時(shí)間或精力亮曹。 而且,如果將其與依賴項(xiàng)注入和模擬生成相結(jié)合秘症,將為大多數(shù)應(yīng)用程序用例提供一個(gè)大膽的解決方案照卦。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市乡摹,隨后出現(xiàn)的幾起案子役耕,更是在濱河造成了極大的恐慌,老刑警劉巖聪廉,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件瞬痘,死亡現(xiàn)場離奇詭異,居然都是意外死亡板熊,警方通過查閱死者的電腦和手機(jī)框全,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來干签,“玉大人津辩,你說我怎么就攤上這事。” “怎么了喘沿?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵闸度,是天一觀的道長。 經(jīng)常有香客問我蚜印,道長莺禁,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任窄赋,我火速辦了婚禮哟冬,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘忆绰。我一直安慰自己柒傻,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布较木。 她就那樣靜靜地躺著,像睡著了一般青柄。 火紅的嫁衣襯著肌膚如雪伐债。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天致开,我揣著相機(jī)與錄音峰锁,去河邊找鬼。 笑死双戳,一個(gè)胖子當(dāng)著我的面吹牛虹蒋,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播飒货,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼魄衅,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了塘辅?” 一聲冷哼從身側(cè)響起晃虫,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎扣墩,沒想到半個(gè)月后哲银,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡呻惕,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年荆责,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片亚脆。...
    茶點(diǎn)故事閱讀 38,100評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡做院,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情山憨,我是刑警寧澤查乒,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站郁竟,受9級特大地震影響玛迄,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜棚亩,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一蓖议、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧讥蟆,春花似錦勒虾、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至质况,卻和暖如春愕宋,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背结榄。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工中贝, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人臼朗。 一個(gè)月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓邻寿,卻偏偏與公主長得像,于是被迫代替她去往敵國和親视哑。 傳聞我的和親對象是個(gè)殘疾皇子绣否,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,834評論 2 345