本文圖片(除gif)來自Hannes Dorfmann大神博客REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 - 7,已征得作者同意。轉載請注明出處。
MVI模式由AndréMedeiros(Staltz)大神 在他寫的一個JavaScript框架cycle.js中提出,如果你感興趣可以看下他在JSConf Budapest in May 2015中的關于MVI的演講(youtube鏈接)。 雖然該模式使用js實現,但是模式思想與平臺無關,本文章主要參考Hannes Dorfmann大神對該模式在android上的實現,本文章的demo(一個簡單的增刪改記賬app) 基于他的開源庫mosby開發。示例demo使用kotlin編寫(用kotlin寫android項目真太* * 爽了)。
什么是MVI (Model-View-Intent)
MVI是單向流(unidirectional flow),不可變的(immutability),響應式的,接收用戶輸入,通過函數轉換為特定Model(狀態),將其結果反饋給用戶(渲染界面)。我們把MVI抽象為model(), view(), intent()三個方法,描述如下:
- intent():中文意思為意圖,接收用戶的輸入(即UI事件,如點擊事件)將其轉換為特定的參數,傳遞給model()方法。意圖可以是一個簡單的字符串,或者是一個復雜的數據結構。
- model(): model()方法輸出Model(我們把Model理解為State更好,即狀態,一個Model對應一種State),這個Model是不可變的(關于不可變Model的優缺點網上已經很多,可自行百度或者查看該文章)。model()方法把intent()方法的輸出作為輸入來創建Model,注意,我們只能通過model()方法來創建新Model,確保Model不變性。
- view(): view()方法把model()方法的輸出的Model作為輸入,根據Model的結果來展示界面。
如何實現響應式
通過上面的示意圖,我們可以看出,這是一個View->Intent->Model->View的單向循環的“流”,通過RxJava(如果你沒接觸過RxJava需要先了解下基礎)把model(),view(),intent()以“流”的方式串起來,來實現“響應式”。用戶的輸入(intent())就是一個“流”(Observable
),通過model()輸出Model(狀態)進行“響應”,最終展示相應的界面。
下面是示例demo中匯總頁面(SummaryActivity
)的效果:上半部分是一個曲線圖,下半部分是一個根據標簽類型匯總的列表,默認顯示6個月的數據,最后一個有數據的月份被選中,當點擊曲線圖的點時,切換對應月份的標簽匯總。
我們可以認為一個Model是對應一種狀態的描述,是一個界面的描述。定義的Model如下:
sealed class SummaryViewState : MviViewState {
/**
* 默認顯示曲線圖和標簽匯總狀態(首次進入頁面)
*/
data class SummaryDataViewState(
val points: List<Pair<Int, Float>>, // 曲線圖點
val months: List<Pair<String, Date>>, // 曲線圖月份
val values: List<String>, // 曲線圖數值文本
val selectedIndex: Int, // 曲線圖選中月份索引
val summaryItemList: List<SummaryListItem> // 當月標簽匯總列表
) : SummaryViewState()
/**
* 切換月份時標簽匯總狀態
*/
data class SummaryGroupingTagViewState(
val summaryItemList: List<SummaryListItem> // 當月標簽匯總列表
) : SummaryViewState()
}
在demo中,Model都以“ViewState”結尾,如果用SummaryModel
或者SummaryViewModel
來命名會跟MVVM
有點混淆。前面說過一個Model對應一個狀態的描述,所以我認為這樣的命名更清晰。
如何把Model展示到界面上呢?我們在View層提供一個render(model)
方法,同時View層還要對用戶事件做出反應,這就是前面所說的意圖(Intent)。再看匯總頁面,這里我們只定義2種意圖:
- 首次進入匯總頁面顯示曲線圖和標簽匯總列表(給我先加載頁面, 展示6個月的匯總,而且選中最后一個有數據月份展示標簽匯總)。
- 切換月份改變標簽匯總列表(給我看不同月份的標簽匯總)。
與MVP
類似,我們用接口來定義View層:
interface MviView<in VS : MviViewState>: MvpView {
fun render(viewState: VS)
}
interface SummaryView : MviView<SummaryViewState> {
/**
* 首次加載頁面
*/
fun loadDataIntent(): Observable<Boolean>
/**
* 切換月份
*/
fun monthClickedIntent(): Observable<Date>
}
意圖(用戶輸入)我們以“Intent”作為后綴,每個“Intent”都是一個“流”,可以看到每個“Intent”都返回Observable
。
下面是SummaryActivity
的實現:
class SummaryActivity : BaseMviActivity<SummaryView, SummaryPresenter>(), SummaryView {
...
override fun loadDataIntent(): Observable<Boolean> {
return Observable.just(true)
}
override fun monthClickedIntent(): Observable<Date> {
// 點擊曲線圖的點
return cv_summary_chart.getMonthClickedObservable()
}
override fun render(viewState: SummaryViewState) {
// 根據不同的State來展示界面
when(viewState) {
is SummaryViewState.SummaryDataViewState -> renderDataState(viewState)
is SummaryViewState.SummaryGroupingTagViewState -> renderGroupingTagState(viewState)
}
}
private fun renderGroupingTagState(vs: SummaryViewState.SummaryGroupingTagViewState) {
summaryListController.setData(vs.summaryItemList)
}
private fun renderDataState(vs: SummaryViewState.SummaryDataViewState) {
// 曲線圖賦值
cv_summary_chart.points = vs.points
cv_summary_chart.months = vs.months
cv_summary_chart.values = vs.values
cv_summary_chart.selectedIndex = vs.selectedIndex
cv_summary_chart.postInvalidate()
// 標簽匯總列表賦值
summaryListController.setData(vs.summaryItemList)
}
...
}
怎么把View層的intent和業務邏輯關聯起來呢?我們引入Presenter,連接“Intent”與業務邏輯。
class SummaryPresenter @Inject constructor(
private val applicationContext: Context,
private val accountingDao: AccountingDao
) : MviBasePresenter<SummaryView, SummaryViewState>() {
...
override fun bindIntents() {
val summaryPeriodChangeIntent: Observable<SummaryViewState> =
intent { it.loadDataIntent() }
.doOnNext { Timber.d("summaryPeriodChangeIntent") }
.flatMap {
// 只顯示6個月的匯總數據
accountingDao.getMonthTotalAmount(6)
.toObservable()
.map { createDataState(it) }
.subscribeOn(Schedulers.io())
}
val monthClickedIntent: Observable<SummaryViewState> =
intent { it.monthClickedIntent() }
.doOnNext { Timber.d("monthClickedIntent") }
.map { Calendar.getInstance().apply { time = it } }
.flatMap { selectedCalendar ->
val year: Int = selectedCalendar.get(Calendar.YEAR)
val month: Int = selectedCalendar.get(Calendar.MONTH) + 1
accountingDao.getGroupingMonthTotalAmountObservable(
year.toString(),
ensureNum2Length(month))
.toObservable()
.map { createSummaryListItems(it) }
.map { SummaryViewState.SummaryGroupingTagViewState(it) }
.subscribeOn(Schedulers.io())
}
// 把2個intent合并為一個流
val allIntents =
Observable.merge(monthClickedIntent, summaryPeriodChangeIntent)
.observeOn(AndroidSchedulers.mainThread())
subscribeViewState(
allIntents,
SummaryView::render)
}
private fun createDataState(list: List<MonthTotal>): SummaryViewState {
...
}
...
}
其中MviBasePresenter為mosby里的類,intent()
,subscribeViewState()
為MviBasePresenter
中的方法。通過MviBasePresenter#intent()
獲取View層的“Intent”,使用Rxjava操作符(map()
, flatMap()
等)處理業務邏輯最終輸出Model,通過MviBasePresenter#subscribeViewState()
方法來把這個“流”串起來。
在mosby中MviBasePresenter
會在Activity#onStart()
時,調用MviBasePresenter#attachView(view)
,把View和Presenter關聯起來,然后執行MviBasePresenter#bindIntent()
方法。MviBasePresenter#intent()
方法創建一個PublishSubject
對象作為“中繼”,在內部維護這個PublishSubject
訂閱/取消訂閱,當屏幕旋轉,退到后臺等操作,把View與Presenter分離,但此時只會把PublishSubject
對象取消訂閱,當View “reattach”時(觸發Activity#onStart()
),對PublishSubject
重新訂閱。同時,內部還創建一個BehaviorSubject
對象作為業務邏輯和View層的“中繼”,當調用MviBasePresenter#subscribeViewState()
方法,如上,讓allIntents
訂閱這個BehaviorSubject
對象,再對BehaviorSubject
對象內部進行訂閱,調用SummaryView#render
。當訂閱BehaviorSubject
對象,會發射最后一個值,即當View “reattach”時,會發射最后的Model,那么我們就可以默認展示上一次的界面。上面說的可能有點難理解(這里只是粗略的描述,想了解內部實現請查看mosby源碼),下面給出圖解:
注:為了讓demo示例代碼易于理解,上面SummaryPresenter
把業務邏輯都堆在了Presenter中,但在實際項目中,業務邏輯一般都會比較復雜,這樣會導致Presenter越來越臃腫,可讀性,可維護性,可測試性價低,我們應該分離并提供創建Model的方法,如提供“Interactor”類:
// SummaryInteractor
class SummaryInteractor {
fun loadData(): Observable<SummaryViewState> {
...
}
fun monthClicked(): Observable<SummaryViewState> {
...
}
}
// SummaryPresenter
override fun bindIntents() {
val summaryPeriodChangeIntent: Observable<SummaryViewState> =
intent { it.loadDataIntent() }
.doOnNext { Timber.d("summaryPeriodChangeIntent") }
.flatMap { summaryInteractor.loadData() }
val monthClickedIntent: Observable<SummaryViewState> =
intent { it.monthClickedIntent() }
.doOnNext { Timber.d("monthClickedIntent") }
.flatMap { summaryInteractor.monthClicked() }
// 把2個intent合并為一個流
val allIntents = Observable.merge(monthClickedIntent, summaryPeriodChangeIntent)
.observeOn(AndroidSchedulers.mainThread())
subscribeViewState(
allIntents,
SummaryView::render)
}
State Reducer
這個詞我把他翻譯成 狀態縮減:多個狀態(Model)縮減成一個。在說明State Reducer作用之前我們先看看demo中首頁(MainActivity
)的效果:
首次進入首頁以一頁15條數據來加載第一頁數據,列表滑動到最后一個item時,根據最后一條數據的時間,加載下一頁的15條數據,并且長按可以刪除數據,添加,修改(這里我們忽略添加跟修改的實現)。
看下View和Model的定義:
data class MainViewState(
val lastDate: Date? = null, // 最后一條數據的創建時間,用于查詢下一頁數據
val accountingDetailList: List<MainAccountingDetail> = listOf(), // 列表展示
val error: String? = null, // 錯誤信息
val isLoading: Boolean = false, // 是否正在loading
val isNoData: Boolean = false, // 是否數據庫中沒有數據
val isNoMoreData: Boolean = false // 是否還可以加載更多
) : MviViewState
interface MainView: MviView<MainViewState> {
/**
* 加載第一頁
*/
fun loadFirstPageIntent(): Observable<Boolean>
/**
* 加載下一頁
*/
fun loadNextPageIntent(): Observable<Date>
/**
* 刪除某一項記錄
*/
fun deleteAccountingIntent(): Observable<Int>
}
MainView
定義的Intent比較清晰,這里就不貼MainActivity
對MainView
的實現了。參照上面SummaryPresenter
的實現,下面只貼出MainPresenter#bindIntents()
方法實現的偽代碼:
...
private val preDetailList: MutableList<Accounting> = mutableListOf()
override fun bindIntents() {
val loadFirstPageIntent: Observable<MainViewState> =
intent(MainView::loadFirstPageIntent)
.doOnNext { Timber.d("loadFirstPageIntent")}
.flatMap {
accountingDao.queryPreviousAccounting(NOW.time, 15)
.toObservable()
.doOnNext { preDetailList.addAll(it) }
...
}
val loadNextPageIntent: Observable<MainViewState> =
intent(MainView::loadNextPageIntent)
.doOnNext{ Timber.d("loadNextPageIntent") }
.flatMap { lastDate: Date ->
accountingDao.queryPreviousAccounting(lastDate, 15)
.toObservable()
.doOnNext { preDetailList.addAll(it) }
...
}
val deleteAccountingIntent: Observable<MainViewState> =
intent(MainView::deleteAccountingIntent)
...
val allIntent = Observable.merge(
loadFirstPageIntent,
loadNextPageIntent,
deleteAccountingIntent)
subscribeViewState(allIntent, MainView::render)
}
...
雖然省略了很多代碼,但是原理是一樣的。accountingDao.queryPreviousAccounting(lasteDate: Date, limit: Long)
方法為數據庫查詢方法,查詢lastDate
時間之前的limit
條數據,大家可以思考一下這里的實現,我們加載第一頁的時候出問題不大,但加載下一頁的時候,上一頁的數據從哪里來?一個比較普遍簡單的方法就是把之前加載過的數據用一個全局變量記錄下來,如上面示例preDetailList
,但是我們還有loading狀態,是否有數據,是否還能加載更多等狀態,在實際項目中需要維護的狀態可能更多,如果我們都用全局變量來記錄的話,會造成狀態混亂,而且不能確保這些變量什么時候被改變,在哪里會被改變,出現問題時就很難去定位,當業務復雜的時候這種問題尤為明顯,所以我們引入了State Reducer。
State Reducer是函數式編程的概念,以上一個狀態作為輸入并輸出新的狀態。代碼描述如下:
fun reduce(preState: State, foo: Foo): State {
val newState: State
...
return newState
}
我們用Foo
來表示當前相對于上一次狀態的變化(如loading,加載下一頁),通過reduce()
方法,結合上一次的狀態preState
的值創建一個新的狀態并返回。這樣在加載下一頁數據時就可以獲取到上一頁的數據了,demo中我們引入一個過渡的Model來表示當前的變化:
sealed class MainPartialStateChanges {
/**
* 錯誤信息
*/
data class ErrorPartialState(val error: String?) : MainPartialStateChanges()
/**
* 加載第一頁的結果
*/
data class LoadFirstPagePartialState(
val accountingList: List<Accounting>) : MainPartialStateChanges()
/**
* 加載下一頁的結果
*/
data class LoadNextPagePartialState(
val lastDate: Date,
val accountingList: List<Accounting>) : MainPartialStateChanges()
/**
* 增/更新
*/
data class AddOrUpdatePartialState(val accounting: Accounting) : MainPartialStateChanges()
/**
* 刪除某一項的結果
*/
data class DeleteAccountingPartialState(val deletedId: Int) : MainPartialStateChanges()
/**
* loading狀態
*/
object LoadingPartialState: MainPartialStateChanges()
}
我們如何實現這個"reduce"呢?我們創建Model時先返回當前的變化(MainPartialStateChanges
),然后通過viewStateReducer
方法輸出最終的狀態(MainViewState
),下面是修改后的實現:
class MainPresenter @Inject constructor(
private var applicationContext: Context,
private var accountingDao: AccountingDao,
private var addOrUpdateObservable: PublishSubject<Accounting>) :
MviBasePresenter<MainView, MainViewState>() {
...
override fun bindIntents() {
val loadDataIntent: Observable<MainPartialStateChanges> =
intent(MainView::loadFirstPageIntent)
.doOnNext { Timber.d("loadFirstPageIntent")}
.flatMap {
accountingDao.queryPreviousAccounting(NOW.time, 15)
.toObservable()
.map<MainPartialStateChanges> {
MainPartialStateChanges.LoadFirstPagePartialState(it)
}
.onErrorReturn { MainPartialStateChanges.ErrorPartialState(it.message) }
.subscribeOn(Schedulers.io())
}
val loadNextPageIntent: Observable<MainPartialStateChanges> =
intent(MainView::loadNextPageIntent)
.doOnNext{ Timber.d("loadNextPageIntent") }
.flatMap { lastDate: Date ->
accountingDao.queryPreviousAccounting(lastDate, 15)
.toObservable()
.map<MainPartialStateChanges> {
MainPartialStateChanges.LoadNextPagePartialState(lastDate, it)
}
.delay(2, TimeUnit.SECONDS) // 特意延時2秒,作為demo使加載效果更明顯
.startWith(MainPartialStateChanges.LoadingPartialState)
.subscribeOn(Schedulers.io())
}
val addOrUpdateIntent: Observable<MainPartialStateChanges> =
addOrUpdateObservable
.doOnNext { Timber.d("addOrUpdateIntent") }
.map { MainPartialStateChanges.AddOrUpdatePartialState(it) }
val deleteAccountingIntent: Observable<MainPartialStateChanges> =
intent(MainView::deleteAccountingIntent)
.doOnNext { Timber.d("deleteAccountingIntent") }
.flatMap { deletedId ->
Observable.fromCallable { accountingDao.deleteAccountingById(deletedId) }
.map {
MainPartialStateChanges.DeleteAccountingPartialState(deletedId)
}
.subscribeOn(Schedulers.io())
}
val allIntent = Observable.merge(
loadDataIntent,
loadNextPageIntent,
addOrUpdateIntent,
deleteAccountingIntent)
val stateIntents: Observable<MainViewState> =
allIntent.distinctUntilChanged()
.scan(MainViewState(lastDate = NOW.time, isLoading = true), this::viewStateReducer)
.observeOn(AndroidSchedulers.mainThread())
subscribeViewState(stateIntents, MainView::render)
}
private fun viewStateReducer(
preViewState: MainViewState,
partialChanges: MainPartialStateChanges): MainViewState {
return when (partialChanges) {
is MainPartialStateChanges.LoadFirstPagePartialState -> {
createLoadFirstPageState(preViewState, partialChanges)
}
is MainPartialStateChanges.LoadNextPagePartialState -> {
createLoadNextPageState(preViewState, partialChanges)
}
is MainPartialStateChanges.AddOrUpdatePartialState -> {
createAddOrUpdateState(preViewState, partialChanges)
}
is MainPartialStateChanges.DeleteAccountingPartialState -> {
createDeleteAccountingState(preViewState, partialChanges)
}
is MainPartialStateChanges.LoadingPartialState -> {
preViewState.copy(error = null, isLoading = true)
}
is MainPartialStateChanges.ErrorPartialState -> {
preViewState.copy(error = partialChanges.error)
}
}
}
private fun createLoadFirstPageState(
preViewState: MainViewState,
partialChanges: MainPartialStateChanges.LoadFirstPagePartialState
): MainViewState {
...
return preViewState.copy(
lastDate = lastDate,
accountingDetailList = createFirstPageList(partialChanges.accountingList),
error = null,
isNoMoreData = accountingList.size < 15,
isLoading = false)
}
private fun createAddOrUpdateState(
preViewState: MainViewState,
partialChanges: MainPartialStateChanges.AddOrUpdatePartialState
): MainViewState {
...
return preViewState.copy(
lastDate = (newAccountingList.last() as MainAccountingDetailContent).createTime,
error = null,
accountingDetailList = newAccountingList,
isLoading = false)
}
private fun createLoadNextPageState(
preViewState: MainViewState,
partialChanges: MainPartialStateChanges.LoadNextPagePartialState
): MainViewState {
...
return preViewState.copy(
lastDate = partialChanges.accountingList.last().createTime,
accountingDetailList = distinctList,
error = null,
isNoMoreData = isNoMoreData,
isLoading = false)
}
private fun createDeleteAccountingState(
preViewState: MainViewState,
partialChanges: MainPartialStateChanges.DeleteAccountingPartialState
): MainViewState {
...
return preViewState.copy(
lastDate = (newAccountingList.last() as MainAccountingDetailContent).createTime,
accountingDetailList = newAccountingList,
error = null,
isNoData = newAccountingList.isEmpty(),
isLoading = false)
}
...
}
可以看到,我們使用Rxjava的操作符scan()
,調用viewStateReducer()
方法輸出最終的MainViewState
。在viewStateReducer()
方法中通過MainPartialStateChanges
對象的值和上一次的MainViewState
來生成新的MainViewState
。
注:上面的代碼通過類型判斷來區分不同的狀態,這樣實現并不優雅,當狀態特別多的時候viewStateReducer()
會十分龐大,這里只是作為demo事例,讓大家跟好理解。在實際項目中最好使用設計模式,一個比較簡單的做法,我們可以在MainPartialStateChanges
中定義一個reduce(MainViewState)
方法,其中參數就是上一次的狀態:
sealed class MainPartialStateChanges {
abstract fun reduce(preState: MainViewState): MainViewState
...
/**
* 加載下一頁的結果
*/
data class LoadNextPagePartialState(
val lastDate: Date,
val accountingList: List<Accounting>) : MainPartialStateChanges() {
override fun reduce(preState: MainViewState): MainViewState {
return preState.copy(...)
}
}
}
// MainPresenter
override fun bindIntents() {
...
val stateIntents: Observable<MainViewState> =
allIntent
.distinctUntilChanged()
.scan(MainViewState(lastDate = NOW.time, isLoading = true)) {
preState, curChanges -> curChanges.reduce(preState)
}
.observeOn(AndroidSchedulers.mainThread())
subscribeViewState(stateIntents, MainView::render)
}
Side Effect(副作用)
前面提到過mosby內部使用BehaviorSubject
作為業務邏輯到View層的“中繼“,了解Rxjava的同學會知道,BehaviorSubject
在訂閱的時候會發射最后一個值,即Model,而訂閱的時機是Activity#onStart()
。我們考慮下這樣的場景:我們把數據保存到服務端,保存成功之后主動展示一個新的頁面(NewActivity
),使用MVI的做法就是在view.render()
里直接跳轉,這樣做的話你會發現從NewActivity
返回的時候NewActivity
會被重新打開,因為Activity#onStart()
被觸發(大家腦海里回顧下Activity的生命周期)。Hannes Dorfmann大神認為頁面跳轉不是一種狀態(不是對界面的描述),他給出的一個解決方案是添加一個Navigator
類來進行頁面跳轉。具體請看這個issues Navigation in MVI。
在demo中也有類似的場景,當增/改的時候,需要關閉編輯頁面(AddOrEditActivity
),我定義了一個AddOrEditNavigator
接口:
interface AddOrEditNavigator {
fun finish()
}
讓AddOrEditActivity
實現該接口,作為參數傳入AddOrEditPresenter
:
class AddOrEditPresenter @Inject constructor(
private var accountingDao: AccountingDao,
private var addOrUpdateObservable: PublishSubject<Accounting>,
private val addOrEditNavigator: AddOrEditNavigator) :
MviBasePresenter<AddOrEditView, AddOrEditViewState>() {
private lateinit var saveOrUpdateDisposable: Disposable
...
override fun bindIntents() {
...
saveOrUpdateDisposable =
intent { it.saveOrUpdateIntent() }
.doOnNext { Timber.d("saveOrUpdateIntent") }
.flatMap {
val id: Int = it[0].toInt()
val amount: Float = it[1].toFloat()
val tagName: String = it[2]
val dateTime: Date = dateTimeFormat.parse(it[3])
val remarks: String? = it[4]
val accounting = Accounting(amount, dateTime, tagName, remarks)
if (id != AddOrEditActivity.ADD) {
accounting.id = id
}
Observable.just(accounting)
.doOnNext {
val insertedId = accountingDao.insertAccounting(it).toInt()
if (id == AddOrEditActivity.ADD) {
it.id = insertedId
}
}
.subscribeOn(Schedulers.io())
}
.doOnNext { addOrUpdateObservable.onNext(it) }
.doOnNext { addOrEditNavigator.finish() }
.subscribe()
subscribeViewState(loadDataIntent, AddOrEditView::render)
}
override fun unbindIntents() {
saveOrUpdateDisposable.dispose()
}
}
在增/改操作完成后,通知首頁刷新addOrUpdateObservable.onNext(...)
,同時關閉頁面addOrEditNavigator.finish()
。
總結
本文只是簡單的介紹了MVI,貼的代碼比較多,比較啰嗦,希望大家能對MVI有初步的了解,同時希望大家有空閱讀一下Hannes Dorfmann大神博客REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 - 7和mosby源碼。在MVI中Presenter的概念并不強,我們僅僅是讓它連接業務邏輯和View層,上面也提到過Interactor和reduce。MVI只是一種思想,可以有不同實現方式,如TODO-MVI-RxJava,deck-of-cards ,這2個項目把MVI分層分得更細。感謝你閱讀這篇文章,本文的demo 已上傳到github,如果對本文有疑問,或者哪里說得不對的地方,歡迎在github上提issue。
參考
github TODO-MVI-RxJava
github deck-of-cards
REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 - 7