將你的ViewModel建模成爲函數

注意:這篇文章假設你有RxSwift的基礎而且對MVVM有大致的理解。若是你沒有,網上有數不清的入門教程先了解一下。ios

另注:我最近很關注Brandon WilliamsStephen Celis提出的Point-Free若是你尚未看過能夠看一下。他們提出的內容對於Swift開發者來講絕對是必不可少的,訂閱它們3多是您今年最有價值的投資。git

求大佬們點個關注,會按期寫原創和翻譯國外最新文章,跟大佬們一塊兒學習進步,有問題或者建議歡迎加微信ruiwendelll,拉你們進技術交流羣,一塊兒探討學習,謝謝了!github

介紹

關於如何結合RxSwift和MVVM已經有了太多文章和討論。在Grailed,咱們一直熱衷於與社區一塊兒創新,並有動力改進咱們的代碼並創造更好的代碼,爲咱們的客戶提供更可靠的產品。基於這個目標,咱們已經一直在使用一種MVVM的模式,它尋求函數式編程和RxSwift,以提供可靠性,可測試性和穩定性。咱們喜好它的不少房買你,可是咱們遇到了不少MVVM開發者很熟悉的一系列問題。編程

問題

有時候如何組建你的代碼會變得很不清晰。在網上的幾十種MVVM架構變種中,彷佛每個對View層如何與ViewModel進行交互都不相同。缺少明確的模式可能會使MVVm中的開發感到特殊和不一致,這將致使可維護性問題。swift

ViewModel可能會變得難以置信的囉嗦。這都是因爲MVVM中結構的缺少,有些選擇將更明確的輸入輸出合約寫在ViewModel裏面,但根據傳統這是以大量樣板爲代價來實現的。設計模式

同時,有些版本不阻止ViewModel的消費者錯誤使用它們的API。從你的View層訂閱ViewModel的輸入衆所周知是違反設計模式的,可是我看到不一樣的生產代碼庫都存在這一現象。理想狀況下,編譯器會防止咱們犯這個錯誤。安全

因爲Swift類和結構體的初始化規則ViewModel可能很難去設置。好比,當你不得不在你的類或者結構體中設置輸出Observable爲屬性時,那些輸出取決於Subject的輸入。你有時候會遇到你不能引用self直到屬性初始化完成以前的狀況,可是你不呢個初始化你的屬性,由於你須要引用self。bash

忘記將你的ViewModel的輸入和輸出綁定而編譯器也不會在咱們出錯的時候幫助咱們也是很常見的。在它幫助咱們不讓咱們犯這麼愚蠢的錯誤時,編譯是是咱們的朋友。微信

解決方案

如今咱們已經瞭解了使用RxSwift進行MVVM模式編程的一些難點。讓咱們經過用一種流行的MVVM方式寫的簡單例子來看看咱們如何能夠作得更好。網絡

全部開發人員都知道編寫純函數能夠是代碼更具備可測試性和理解性,不然會變得難以理解,咱們要實現它若是不是不可能。問題是咱們寫的太多類型的代碼不能徹底適合純函數,所以咱們獨立將代碼儘量多的部分建模成爲純函數。這種看法促使不少MVVM開發人員建立他們的ViewModel,以便擁有一組明確的輸入和輸出,從而咱們可能將它們看做更相似於函數的東西。

函數的輸入叫作Subject,輸出是callback,變量或者Observable。View的業務邏輯在ViewModel中被建模爲輸入和輸出 的轉換,多數狀況下在ViewModel的初始化器中。Kickstarter open source app多是第一個也是最著名的迭代。即便它使用RactiveSwift寫的,可是這些想法很是類似。這些開源的應用程序打開了許多開發人員的眼界,關於如何使用MVVM來實現APP的可測試性和穩定性。

首先讓咱們來建立一個經典的RxSwift例子,一個簡單的具備姓名和密碼輸入框的登錄界面,固然還有一個登錄按鈕。咱們想讓姓名和密碼輸入框都被填充的時候登錄按鈕纔可用,當用戶點擊按鈕時咱們但願彈出相似登錄成功的提示(咱們將在這裏進行硬編碼,並在之後的文章中討論如何處理網絡請求)。這是一個咱們遵循Kickstarter 應用程序中列出的模式編寫此代碼的示例。

// ************** View Model **************
protocol LoginViewModelInputs {
    func usernameChanged(_ username: String)
    func passwordChanged(_ password: String)
    func loginTapped()
}

protocol LoginViewModelOutputs {
    var loginButtonEnabled: Observable<Bool> { get }
    var showSuccessMessage: Observable<String> { get }
}

protocol LoginViewModelType {
    var inputs: LoginViewModelInputs { get }
    var outputs: LoginViewModelOutputs { get }
}

class LoginViewModel: LoginViewModelInputs, LoginViewModelOutputs, LoginViewModelType {

    let loginButtonEnabled: Observable<Bool>
    let showSuccessMessage: Observable<String>

    init() {
        self.loginButtonEnabled = Observable.combineLatest(
            _usernameChanged,
            _passwordChanged
        ) { username, password in
            !username.isEmpty && !password.isEmpty
        }

        self.showSuccessMessage = _loginTapped
            .map { "Login Successful" }
    }

    private let _usernameChanged = PublishRelay<String>()
    func usernameChanged(_ username: String) {
        _usernameChanged.accept(username)
    }

    private let _passwordChanged = PublishRelay<String>()
    func passwordChanged(_ password: String) {
        _passwordChanged.accept(password)
    }

    private let _loginTapped = PublishRelay<Void>()
    func loginTapped() {
        _loginTapped.accept(())
    }

    var inputs: LoginViewModelInputs { return self }
    var outputs: LoginViewModelOutputs { return self }
}

// ************** View Controller **************
class LoginViewController: UIViewController {
    private let usernameTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton()

    private let viewModel: LoginViewModelType
    private let disposeBag = DisposeBag()

    init() {
        self.viewModel = LoginViewModel()
        super.init(nibName: nil, bundle: nil)

        disposeBag.insert(
            // Inputs
            loginButton.rx.tap.asObservable()
                .subscribe(onNext: viewModel.inputs.loginTapped),

            usernameTextField.rx.text.orEmpty
                .subscribe(onNext: viewModel.inputs.usernameChanged),

            passwordTextField.rx.text.orEmpty
                .subscribe(onNext: viewModel.inputs.passwordChanged),

            // Outputs
            viewModel.outputs.loginButtonEnabled
                .bind(to: loginButton.rx.isEnabled),

            viewModel.outputs.showSuccessMessage
                .subscribe(onNext: { message in
                    print(message)
                    // Show some alert here
                })
        )
    }
}
複製代碼

對於如此小的屏幕,這裏有不少內容,因此花一點時間來消化它。我真的很喜歡這種風格:

  1. ViewModel建立了一個很是明確的規定,關於它的功能以及應該如何使用它
  2. 不多有方法能夠錯誤地使用ViewModel的API
  3. ViewModel沒有反作用
  4. 從外部查看此ViewModel並瞭解如何測試它很是容易

總的來講,我認爲這種權衡取捨是值得的。咱們在一個小的額外樣板上花了點時間並在咱們的ViewModel中有些雜亂,從而獲得了一個很清楚的關於ViweModel能的功能,它很是明確而且容易推理。這種易於推理使咱們可以將ViewModel視爲純函數的抽象形式,咱們傳遞輸入和輸出。

由於這些輸入一般是用戶操做,而輸出是反作用。這對於你爲一系列用戶行爲並確保它們產生的反作用能像你指望的那用觸發而寫出更高級的測試用例有好處。這容許您編寫更普遍的高級「功能」測試集,測試應用程序除了更傳統的單元測試以外用戶將如何使用它。

革命

如今我怕們提高了代碼的可測試性,併爲咱們的ViewModel提供了安全且容易理解的API,可是咱們還有不少想作的。咱們是否能夠消除這些樣板而不用犧牲明確性和安全性。

答案是yes!爲了獲得這個解決方案,回過頭去看一下第一原則是值得的。咱們但願想對待純函數同樣對待咱們的ViewModel。咱們但願有明確的輸入和輸出來方便測試。Swift中有一個簡單又沒什麼代價的方式去接收輸入返回輸出:functions。因此咱們可使用函數做爲咱們的ViewModel而不是一個類或結構體。想一想咱們應該怎麼作。

不是一個LoginViewModelInputs代理,咱們能夠把輸入做爲一個函數的參數進行傳遞。一樣,不用LoginViewModelOutputs代理,咱們能夠從函數獲得返回的輸出Observable。這聽起來很奇怪,讓咱們來看下例子。

// ************** View Model **************
func loginViewModel(
    usernameChanged: Observable<String>,
    passwordChanged: Observable<String>,
    loginTapped: Observable<Void>
) -> (
    loginButtonEnabled: Observable<Bool>,
    showSuccessMessage: Observable<String>
) {
    let loginButtonEnabled = Observable.combineLatest(
        usernameChanged,
        passwordChanged
    ) { username, password in
        !username.isEmpty && !password.isEmpty
    }

    let showSuccessMessage = loginTapped
        .map { "Login Successful!" }

    return (
        loginButtonEnabled,
        showSuccessMessage
    )
}

// ************** View Controller **************
class LoginViewController: UIViewController {
    private let usernameTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton()

    private let disposeBag = DisposeBag()

    init() {
        super.init(nibName: nil, bundle: nil)

        let (
            loginButtonEnabled,
            showSuccessMessage
        ) = loginViewModel(
            usernameChanged: usernameTextField.rx.text.orEmpty.asObservable(),
            passwordChanged: passwordTextField.rx.text.orEmpty.asObservable(),
            loginTapped: loginButton.rx.tap.asObservable()
        )

        disposeBag.insert(
            loginButtonEnabled
                .bind(to: loginButton.rx.isEnabled),

            showSuccessMessage
                .subscribe(onNext: { message in
                    print(message)
                    // Show some alert here
                })
        )
    }
}
複製代碼

咱們的ViewModel神奇的轉換成了函數,並且ViewController不用再調LoginViewModel.init(),咱們調用loginViewModel(usernameChanged:passwordChanged:loginTapped:)。咱們看看作了什麼修改。

  1. 咱們徹底消除了輸入輸出代理(還有不少這樣的模板代碼)有利於函數參數做爲輸入而命名參數元組做爲輸出。
  2. 咱們再也不須要把輸入橋接爲Observable由於View層已經給出了Observable甚至消除了樣板。
  3. 咱們在ViewController中作了更少的綁定/訂閱操做,顯著提升了信噪比。
  4. 若是咱們忘記將輸入傳遞到ViweModel咱們會獲得一個編譯器報錯由於Swift中函數必須提供全部的參數。
  5. 若是咱們忘記使用輸出,因爲結構了咱們的輸出元組,咱們會接收到編譯器unused variable的警告。
  6. 若是在不一樣的界面中重用一個ViewModel,做爲一個開發者我能夠明確地選擇使用_來無視一個確切的輸出。這讓review的人和將來的維護者明白這是故意的遺漏,而不是咱們忘記綁定。
  7. 咱們不用再處理類和結構體的初始化規則由於咱們再也不使用它們了。
  8. 咱們將總體實現縮減了約30%,咱們的ViewModel類代碼量減小了約50%。

一旦咱們克服了不適用對象的陌生感。基本上對於每種可衡量的方式這都是一個巨大的進步。在咱們犯錯時讓編譯器提醒咱們是一個重大的進步,而且還消除了大部分模板代碼。

結論

把ViewModel建模成爲函數一開始看起來可能很瘋狂,可是咱們在應用程序中始終將業務邏輯做爲函數進行編寫,若是不是一大塊業務邏輯,還有什麼是ViewModel呢?當我拋棄對ViewModel的傳統觀點並擁抱新朋友--函數時,咱們爲編寫反應式MVVM的許多困境發現了一個簡單而優雅的解決方案。

經過代理實現的這一編碼風格來自於Kickstarter pagination的邏輯,已經在生產中存在了三年多,而且已經在其代碼庫的近幾十個地方重複使用。像上面示例所展現的同樣,這種方法不是特定域ViewModel,而是在大規模生產代碼庫中通過實戰測試,而不只僅是像咱們的登錄界面這樣的demo

Swift和其餘開發社區都都存在一種共同模式,即建立一個封裝一些數據並提供該數據訪問器的對象。不管什麼時候咱們東可使用相同的轉換,像把ViewModel轉換爲函數同樣。

相關文章
相關標籤/搜索