[譯]使用MVI構建響應式安卓程式

pcdack發表於2018-01-02

Model-View-Intent是安卓最新的設計模式。它的靈感來自於於André Staltz的Cycle.js ,並且被 Hannes Dorfmann適配到安卓世界。

這篇文章描述MVI設計模式,並且像你展示如何建立一個用MVI設計模式的基礎的Hello World程式。你可能會問,為什麼要使用這個模式在一個沒有任何意義的應用程式上?還有一些其他複雜的MVI例子(在文章末尾的推薦閱讀裡),我們都能學到不同的東西。對我個人而言,一個簡單的如hello world一樣的程式,在我探索一個新東西的時候,更讓我豁然大悟的。我希望這篇文章可以幫你掌握MVI的基礎使用,並且期望你能在複雜的應用程式裡面去應用,去深入。

這篇文章的Kotlin原始碼在 Github上

使用Mosby來構建MVI

我們會使用Mosby庫來構建MVI。這個庫可以讓我們關注程式設計的藍圖,例如MVI的內容和業務邏輯,而不是處理棘手RxJava API和記憶體管理。使用Mosby庫你不需要關心也不需要寫,VIew和Presenter的旋轉和防止記憶體洩漏的程式碼。

Model-View-Intent

你可能看過Model在其他的設計模式比如MVC,MVP或者MVVP。但是MVI的Model和其他設計模式的完全不一樣:

[譯]使用MVI構建響應式安卓程式

  • Model 代表一種狀態(資料的顯示,你的控制元件的可視或者隱藏,RecyclerView的滑動位置等等)。Model在MVI中比其他的設計模式更加的形式化。你應用的一個頁面可能包含一個或多個Model物件。Model在一個Domain層被定義和管理。使其不變是很重要的--每一個請求結果都繫結一個新的Model例項。這保證了可預測的結果和可測試性。
  • View 代表一個定義一系列使用者動作的可觀察物件的介面和一個render(ViewState)方法
  • Intent 不是android.content.Intent !這個Intent 簡單的說是一種意圖,或者說一種動作,或者說一種使用者與APP互動產生的命令。對於每一個使用者動作(意圖)被View分發,被Presenter 觀察(是的,MVI也是有Presenter 的).

[譯]使用MVI構建響應式安卓程式

這個圖片記錄了這個模式的響應,迴圈,資料流動的方向。我們的Model/State(PS:圖中翻譯為狀態)是被Domain層管理的(只有一個來源),用來對使用者動作做出反應。無論如何,新的Model被建立,那麼相應的View被更新,如果,想要獲得更多細節,我推薦一個系列文章幫助你開始使用MVI模式hannesdorfmann.com/android/mos…

讓我們愉快的寫程式碼吧

在我們這個例子中,我們的UI介面有一個按鈕,當我們點選按鈕會產生Loading指示符,緊接著顯示四種語言中的其中一種語言的"Hello World"。

[譯]使用MVI構建響應式安卓程式

這裡是我們的檔案結構,好像不是很嚇人,難道不是麼(滑稽.png)?畢竟這是對MVI模式比較簡單的描述。

mosbymvi
  MainActivity
  HelloWorldView
  HelloWorldPresenter
mosbymvi.domain
  GetHelloWorldTextUseCase
  HelloWorldViewState
mosbymvi.data
  HelloWorldRepository
複製程式碼

我選擇使用以下依賴:

dependencies {
    // Mosby
    implementation "com.hannesdorfmann.mosby3:mvi:$mosbyVersion"

    // RxBinding
    implementation "com.jakewharton.rxbinding2:rxbinding-kotlin:$rxBindingVersion"
    implementation "com.jakewharton.rxbinding2:rxbinding-support-v4-kotlin:$rxBindingVersion"
    implementation "com.jakewharton.rxbinding2:rxbinding-appcompat-v7-kotlin:$rxBindingVersion"

     // RxJava and RxAndroid
     implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
     implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
}
複製程式碼

View

HelloWorldView是我們的View介面,這個介面定義了兩個方法-所有的方法,我們都需要在這個例子中展示:

  1. sayHelloWorldIntent() 將觸發我們的 意圖(intent)/動作(action)/命令(command) 去顯示 HelloWorld 文字。
  2. render(state:HelloWorldViewState) 將被用來渲染最新的狀態
interface HelloWorldView : MvpView {
    /**
     * Emits button clicks as Observables
     */
    fun sayHelloWorldIntent(): Observable<Unit>

    /**
     * Render the state in the UI
     */
    fun render(state: HelloWorldViewState)
}
複製程式碼

簡單起見,我們在Activity中實現上面的View介面:

class MainActivity : MviActivity<HelloWorldView, HelloWorldPresenter>(), HelloWorldView {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun createPresenter() = HelloWorldPresenter()

    override fun sayHelloWorldIntent() = helloWorldButton.clicks()

    override fun render(state: HelloWorldViewState) {
        when(state) {
            is HelloWorldViewState.LoadingState -> renderLoadingState()
            is HelloWorldViewState.DataState -> renderDataState(state)
            is HelloWorldViewState.ErrorState -> renderErrorState(state)
        }
    }

    private fun renderLoadingState() {
        loadingIndicator.visibility = View.VISIBLE
        helloWorldTextview.visibility = View.GONE
    }

    private fun renderDataState(dataState: HelloWorldViewState.DataState) {
        loadingIndicator.visibility = View.GONE
        helloWorldTextview.apply {
            visibility = View.VISIBLE
            text = dataState.greeting
        }
    }

    private fun renderErrorState(errorState: HelloWorldViewState.ErrorState) {
        loadingIndicator.visibility = View.GONE
        helloWorldTextview.visibility = View.GONE
        Toast.makeText(this, "error ${errorState.error}", Toast.LENGTH_LONG).show()
    }
}
複製程式碼

那麼,我們來看一下sayHelloWorldIntent()方法

 override fun sayHelloWorldIntent() = sayHelloWorldButton.clicks()
複製程式碼

這個按鈕的點選被轉化成了Observale<Unit> 因此我們的Presenter 可以觀察,並做出反應。我們用 Jake Wharton 的RxBinding 來實現。

注意,render(state: HelloWorldViewState) 方法被我們的Presenter 呼叫去渲染最新的UI。

至於呼叫renderLoadingState(),renderDataState(),renderErrorState()中的哪一個,主要是取決於ViewState。每個函式都是自包含的程式碼塊,它們將更改UI,來展示最新狀態。不需要從Presenter到View反覆多次呼叫來反應當前狀態。

override fun render(state: HelloWorldViewState) {
        when(state) {
            is HelloWorldViewState.LoadingState -> renderLoadingState()
            is HelloWorldViewState.DataState -> renderDataState(state)
            is HelloWorldViewState.ErrorState -> renderErrorState(state)
        }
    }
複製程式碼

Presenter

這個地方就展現出Mosby這個庫,閃光的地方!Presenter中的很多程式碼都已經幫我們整合好了。我們只需要做的是

a).通過業務邏輯,來定義如何處理Presenter

b).訂閱View到ViewState觀察流,我們可以根據最新的ViewState渲染不同的UI狀態:

class HelloWorldPresenter : MviBasePresenter<HelloWorldView, HelloWorldViewState>() {
    override fun bindIntents() {
        val helloWorldState: Observable<HelloWorldViewState> = intent(HelloWorldView::sayHelloWorldIntent)
                .subscribeOn(Schedulers.io())
                .debounce(400, TimeUnit.MILLISECONDS)
                .switchMap { GetHelloWorldTextUseCase.getHelloWorldText() }
                .doOnNext { Timber.d("Received new state: " + it) }
                .observeOn(AndroidSchedulers.mainThread())

        subscribeViewState(helloWorldState, HelloWorldView::render)
    }
}
複製程式碼

我們使用Mosby的bindIntents()方法去:

  1. 在MVI設計模式中觀察UI事件也被稱作Intent/動作(action)/命令(commands)。注意我們用使用RxJava中 debounce() 操作 來避免快速點選按鈕的事件處理。
  2. 將這些意圖(intents)對映到Domain層。在這種情況下,sayHelloWorldIntent() 將被GetHelloWorldTextUseCase.getHelloWorldText() 呼叫。內部的,Mosby用PublishSubject 去處理意圖(intents)並且放置記憶體洩露。
  3. 渲染髮射狀態(載入狀態,資料返回狀態或者是錯誤狀態)到UI上來。與View通訊,subscribeViewState()BehaviorSubject 來發出最近觀察到的ViewState,以及後續的ViewState。它可以讓View知道自己的最新狀態,就算是裝置旋轉。

注意:因為Mosby內部使用RxJava,這裡對我們來說不需要clear() ViewState的Disposables-Mosby可以自動的在檢視分離的時候銷燬掉。

注意:在這個小例子中我不利用Cycle.js/Redux/MVI中的核心內容裡的 State reducers 。反饋多個使用者意圖(Intents)來讓系統生成當前狀態。我們可以使用RxJava中的merge()scan() 操作來實現它。你可以在這裡學習更多關於reducing 狀態

Domain 層

Domain層包含了根據使用者操作生成的各種檢視狀態(View State)生成的Case/Interactor。

View State

Kotlin的封閉類十分的適合ViewState,但是可以用不同的方式來生成ViewState。

package com.jshvarts.mosbymvi.domain

sealed class HelloWorldViewState {
    object LoadingState : HelloWorldViewState()
    data class DataState(val greeting: String) : HelloWorldViewState()
    data class ErrorState(val error: Throwable) : HelloWorldViewState()
}
複製程式碼

使用Case/Interactor

簡單的說,我們用唯一的GetHelloWorldTextUseCase 。在生產環境中,你需要注入Case到你的Presenter層——更多細節請閱讀有關Clean Architecture的內容

Domain/業務邏輯層對於MVP和MVVM是十分重要的,但是對於MVI,更進一步。這是我們定義和生成不同的Models/View狀態的地方。Models是一成不變的——每一次業務邏輯的請求產生一個或多個新的Model物件,它可以渲染UI。

/**
 * In a Production app, inject your Use Case into your Presenter instead.
 */
object GetHelloWorldTextUseCase {
    fun getHelloWorldText(): Observable<HelloWorldViewState> {
        return HelloWorldRepository.loadHelloWorldText()
                .map<HelloWorldViewState> { HelloWorldViewState.DataState(it) }
                .startWith(HelloWorldViewState.LoadingState)
                .onErrorReturn { HelloWorldViewState.ErrorState(it) }
    }
}
複製程式碼

我們一行一行的看GetHelloWorldTextUseCase#getHelloWorldText()

// 呼叫儲存庫來獲取資料
HelloWorldRepository.loadHelloWorldText()
//建立DataState並將其轉換為HelloWorldViewState
.map<HelloWorldViewState> { HelloWorldViewState.DataState(it) }
// 在發射資料之前發射LoadingState值
.startWith(HelloWorldViewState.LoadingState())
// 不要丟擲錯誤 - 改為發出錯誤狀態
.onErrorReturn { HelloWorldViewState.ErrorState(it) }
複製程式碼

我們新增了log 在Presenter的doOnNext()函式,當按鈕點選,我們如果可以看到下面的內容那麼我們成功了

Received new state: HelloWorldViewState$LoadingState
Received new state: DataState(greeting=Hello World)
複製程式碼

如果產生了錯誤(IllegalStateException),這個Log將包含下面內容:

Received new state: HelloWorldViewState$LoadingState
Received new state: ErrorState(error=java.lang.IllegalArgumentException)
複製程式碼

Data層

簡單地說,我們的Data層僅僅包含了一個子類,HelloWorldRepository ,這個子類每次呼叫生成一個隨機產生HelloWorld的可觀察流。

object HelloWorldRepository {

    fun loadHelloWorldText(): Observable<String> = Observable.just(getRandomMessage())

    private fun getRandomMessage(): String {
        val messages = listOf("Hello World", "Hola Mundo", "Hallo Welt", "Bonjour le monde")
        return messages[Random().nextInt(messages.size)]
    }
}
複製程式碼

推薦閱讀(文章幾乎都要翻牆)

相關文章