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和其他設計模式的完全不一樣:
- Model 代表一種狀態(資料的顯示,你的控制元件的可視或者隱藏,RecyclerView的滑動位置等等)。Model在MVI中比其他的設計模式更加的形式化。你應用的一個頁面可能包含一個或多個Model物件。Model在一個Domain層被定義和管理。使其不變是很重要的--每一個請求結果都繫結一個新的Model例項。這保證了可預測的結果和可測試性。
- View 代表一個定義一系列使用者動作的可觀察物件的介面和一個
render(ViewState)
方法 - Intent 不是
android.content.Intent
!這個Intent
簡單的說是一種意圖,或者說一種動作,或者說一種使用者與APP互動產生的命令。對於每一個使用者動作(意圖)被View分發,被Presenter
觀察(是的,MVI也是有Presenter
的).
這個圖片記錄了這個模式的響應,迴圈,資料流動的方向。我們的Model/State(PS:圖中翻譯為狀態)是被Domain層管理的(只有一個來源),用來對使用者動作做出反應。無論如何,新的Model被建立,那麼相應的View被更新,如果,想要獲得更多細節,我推薦一個系列文章幫助你開始使用MVI模式hannesdorfmann.com/android/mos…
讓我們愉快的寫程式碼吧
在我們這個例子中,我們的UI介面有一個按鈕,當我們點選按鈕會產生Loading指示符,緊接著顯示四種語言中的其中一種語言的"Hello World"。
這裡是我們的檔案結構,好像不是很嚇人,難道不是麼(滑稽.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介面,這個介面定義了兩個方法-所有的方法,我們都需要在這個例子中展示:
sayHelloWorldIntent()
將觸發我們的 意圖(intent)/動作(action)/命令(command) 去顯示 HelloWorld 文字。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()
方法去:
- 在MVI設計模式中觀察UI事件也被稱作Intent/動作(action)/命令(commands)。注意我們用使用RxJava中
debounce()
操作 來避免快速點選按鈕的事件處理。 - 將這些意圖(intents)對映到Domain層。在這種情況下,
sayHelloWorldIntent()
將被GetHelloWorldTextUseCase.getHelloWorldText()
呼叫。內部的,Mosby用PublishSubject
去處理意圖(intents)並且放置記憶體洩露。 - 渲染髮射狀態(載入狀態,資料返回狀態或者是錯誤狀態)到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)]
}
}
複製程式碼