從狀態管理(State Manage)到MVI(Model-View-Intent)

littlegnal發表於2019-02-20

本文同步自個人部落格從狀態管理(State Manage)到MVI(Model-View-Intent)

什麼是狀態?介面上展示給使用者的都是一種狀態,如loading顯示,error資訊顯示,列表展示等。這是日常開發中必然會遇到的,本文將講解如何用更有效的方式來進行狀態管理,提高程式碼的可讀性,可維護性,健壯性。。。。。。文章中程式碼示例比較多,但是別慌,邏輯都比較簡單,穩住就行。文章程式碼使用kotlin實現,關於狀態管理部分示例程式碼使用MVP + RxJava模式來編寫。

關於狀態管理

假設我們有這樣一個需求:在輸入框輸入使用者名稱,點選儲存按鈕把使用者儲存到資料庫。在儲存資料庫之前,顯示loading狀態,然後把儲存按鈕設定為不可點選,儲存資料庫需要非同步操作,最後在成功的時候隱藏loading狀態並且把儲存按鈕設定為可點選,若發生錯誤,需要隱藏loading狀態,把儲存按鈕設定為可點選狀態,然後顯示錯誤資訊。Show you the code:

class MainPresenter constructor(
    private val service: UserService,
    private val view: MainView,
    private val disposables: CompositeDisposable
) : Presenter {

  val setUserSubject = PublishSubject.create<String>()
  
  init {
    disposables.add(
        setUserSubject
            .doOnNext {
              view.showLoading()
              view.setButtonUnable()
            }
            .flatMap { service.setUser(it) }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(
                {
                  view.hideLoading()
                  view.setButtonEnable()
                },
                {
                  view.hideLoading()
                  view.setButtonEnable()
                  view.showError(it.message.toString())
                }
            ))
  }

  override fun setUser(userName: String) {
    setUserSubject.onNext(userName)
  }
}
複製程式碼

這段程式碼看上去不怎麼優雅,但已經實現了我們的需求了。簡單畫下流程圖:

setUser流程

可以看到當儲存資料庫操作前呼叫view.showLoading()view.setButtonUnable(),當操作成功或者錯誤的時候呼叫view.hideLoading()view.setButtonEnable(),像這種“配套”方法越來越多的時候就很容易會疏忽,出現忘記隱藏loading狀態,忘記把按鈕設定為可點選等問題。在這簡單例子你可能會覺得沒什麼,實際開發的時候一定會記得呼叫相應的方法,這不同於註冊介面監聽,一般我們會在Activity#onCreate()的時候註冊監聽,在Activity#onDestroy()取消監聽,但我們在View裡可以有很多地方呼叫Presenter的方法,如setUser(),我們認為呼叫Presenter方法是一種輸入,同時Presenter也有很多地方輸出狀態給View,如view.showLoading()view.showError()等。我們不能確定setUser()方法在哪裡被呼叫,view.showLoading()方法在哪裡被呼叫,假設我們還有其他方法在同時執行:

多個方法呼叫流程

這很容易會造成狀態混亂,例如loading狀態和錯誤資訊同時出現,當錯誤資訊顯示的時候儲存按鈕沒有恢復可點選狀態等,在實際的業務中,這種問題尤其明顯。

響應式狀態(Reative State)

我們能不能限制Presenter只有一個輸入,狀態只從一個地方輸出呢?我們藉助PublishSubject作為橋接(如上面程式碼片段setUserSubject),然後通過Observable.merge()把它們合併成一個流,來實現只有一個地方輸入。下面我們主要看看我們如何實現狀態只從一個地方輸出。

引用物件導向程式設計一句經典的話:萬物皆物件。使用者輸入使用者名稱,點選儲存按鈕,這是一個事件,我們把它看成一個事件物件SetUserEvent,把UI狀態作為一個狀態物件(SetUserState),同時狀態是對介面的描述。於是我們在把事件作為輸入(SetUserEvent),輸出狀態(SetUserState),View只需要根據狀態SetUserState的資訊(如loading,顯示錯誤資訊)來展示介面就可以了:

使用者輸入-狀態

可以看到這是一條單向的“流”,而且是迴圈的,View把使用者事件輸出到Presenter,接收狀態展示介面;PresenterView的事件輸入進行處理,輸出狀態。接下來看看如何用程式碼實現。

首先定義介面狀態SetUserState:

data class SetUserState(
    val isLoading: Boolean, // 是否在載入
    val isSuccess: Boolean, // 是否成功
    val error: String? // 錯誤資訊
) {
  companion object {

    fun inProgress() = SetUserState(isLoading = true, isSuccess = false, error = null)
    
    fun success() = SetUserState(isLoading = false, isSuccess = true, error = null)
    
    fun failure(error: String) = SetUserState(isLoading = false, isSuccess = false, error = error)
  }
} 
複製程式碼

這裡定義了3個方法,用於表示正在載入狀態,成功狀態和失敗狀態。接下來對儲存資料庫操作進行重寫:

  ...

  val setUserSubject = PublishSubject.create<SetUserEvent>()

  init {
    disposables.add(
        setUserSubject.flatMap {
          service.setUser(it.userName)
              .map { SetUserState.success() }
              .onErrorReturn { SetUserState.failure(it.message.toString()) }
              .subscribeOn(Schedulers.io())
              .observeOn(AndroidSchedulers.mainThread())
              .startWith(SetUserState.inProgress())
        }
        .subscribe { setUserState ->
          if (setUserState.isLoading) {
            view.showLoading()
            view.setButtonUnable()
            return@subscribe
          }

          view.hideLoading()
          view.setButtonEnable()
          if (setUserState.isSuccess) {
            // do something...
          } else {
            setUserState.error?.apply { view.showError(this) }
          }
        })
  }

  override fun setUser(setUserEvent: SetUserEvent) {
    setUserSubject.onNext(setUserEvent)
  }
複製程式碼

修改的核心部分是flatMap裡的內部Observable:

service.setUser(it.userName)
    .map { SetUserState.success() }
    .onErrorReturn { SetUserState.failure(it.message.toString()) }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .startWith(SetUserState.inProgress())
複製程式碼

在這個內部Observable裡,把事件轉換為SetUserState狀態並輸出。這個Observable在執行時,會先輸出loading狀態(startWith(SetUserState.inProgress()));當service.setUser(it.userName)成功後輸出成功狀態(map { SetUserState.success() });當錯誤時輸出錯誤狀態,錯誤狀態中包括錯誤資訊(onErrorReturn { SetUserState.failure(it.message.toString()) })。可以看到,我們不需要關心UI,不需要關心什麼時候呼叫view.showLoading()顯示loading狀態,不需要關心什麼時候呼叫view.hideLoading()隱藏loading狀態,在subscribe()中根據SetUserState狀態展示介面就可以了。為了方便單元測試和重用,把這部分拆分出來:

  ...

  private val setUserTransformer = ObservableTransformer<SetUserEvent, SetUserState> {
    event -> event.flatMap {
      service.setUser(it.userName)
          .map { SetUserState.success() }
          .onErrorReturn { SetUserState.failure(it.message.toString()) }
          .subscribeOn(Schedulers.io())
          .observeOn(AndroidSchedulers.mainThread())
          .startWith(SetUserState.inProgress())
    }
  }

  init {
    disposables.add(
        setUserSubject.compose(setUserTransformer)
            .subscribe { setUserState ->
              if (setUserState.isLoading) {
                view.showLoading()
                view.setButtonUnable()
                return@subscribe
              }

              view.hideLoading()
              view.setButtonEnable()
              if (setUserState.isSuccess) {
                // do something...
              } else {
                setUserState.error?.apply { view.showError(this) }
              }
            })
  }

  ...
複製程式碼

一般情況下都會有很多輸入,如上拉載入下一頁,下拉重新整理等。現假設需要新增一個checkUser()方法,用於查詢使用者是否存在,要把不同輸入合併,我們需要定義一個公共的父類UIEvent,讓每個輸入都繼承該父類:

sealed class UIEvent {

  data class SetUserEvent(val userName: String) : UIEvent()

  data class CheckUserEvent(val userName: String) : UIEvent()
}
複製程式碼

下面是Presenter的實現:

class MainPresenter(
    private val service: UserService,
    private val view: MainView,
    private val disposables: CompositeDisposable
) : Presenter {

  val setUserSubject = PublishSubject.create<UIEvent.SetUserEvent>()

  val checkUserSubject = PublishSubject.create<UIEvent.CheckUserEvent>()

  private val setUserTransformer = ObservableTransformer<UIEvent.SetUserEvent, UIState> {
    event -> event.flatMap {
      service.setUser(it.userName)
          .map { UIState.success() }
          .onErrorReturn { UIState.failure(it.message.toString()) }
          .subscribeOn(Schedulers.io())
          .observeOn(AndroidSchedulers.mainThread())
          .startWith(UIState.inProgress())
    }
  }

  private val checkUserTransformer = ObservableTransformer<UIEvent.CheckUserEvent, UIState> {
    event -> event.flatMap {
      service.checkUser(it.userName)
          .map { UIState.success() }
          .onErrorReturn { UIState.failure(it.message.toString()) }
          .subscribeOn(Schedulers.io())
          .observeOn(AndroidSchedulers.mainThread())
          .startWith(UIState.inProgress())
    }
  }

  private val transformers = ObservableTransformer<UIEvent, UIState> {
    events -> events.publish { shared ->
      Observable.merge(
          shared.ofType(UIEvent.SetUserEvent::class.java).compose(setUserTransformer),
          shared.ofType(UIEvent.CheckUserEvent::class.java).compose(checkUserTransformer))
    }
  }

  init {
    val allEvents: Observable<UIEvent> = Observable.merge(setUserSubject, checkUserSubject)

    disposables.add(
        allEvents.compose(transformers)
            .subscribe { setUserState ->
              if (setUserState.isLoading) {
                view.showLoading()
                view.setButtonUnable()
                return@subscribe
              }

              view.hideLoading()
              view.setButtonEnable()
              if (setUserState.isSuccess) {
                // do something...
              } else {
                setUserState.error?.apply { view.showError(this) }
              }
            })
  }

  override fun setUser(setUserEvent: UIEvent.SetUserEvent) {
    setUserSubject.onNext(setUserEvent)
  }

  override fun checkUser(checkUserEvent: UIEvent.CheckUserEvent) {
    checkUserSubject.onNext(checkUserEvent)
  }
}
複製程式碼

如前面提到的,我們使用Observable.merge()對輸入事件進行合併:

val allEvents: Observable<UIEvent> = Observable.merge(setUserSubject, checkUserSubject)
複製程式碼

然後按照前面的套路,定義checkUserTransformer。這部分程式碼需要注意的是transformers屬性的實現:

  private val transformers = ObservableTransformer<UIEvent, UIState> {
    events -> events.publish { shared ->
      Observable.merge(
          shared.ofType(UIEvent.SetUserEvent::class.java).compose(setUserTransformer),
          shared.ofType(UIEvent.CheckUserEvent::class.java).compose(checkUserTransformer))
    }
  }
複製程式碼

為了讓不同的事件輸入組合不同的業務邏輯,這裡把合併的輸入拆分,然後對不同的輸入組合不同的業務邏輯,最後再重新合併成一個流:

publish拆分-merge合併

這樣做的好處是每個事件輸入做自己的事而不影響到其他。現在回過頭來整個流程,我們已經實現了一個迴圈單向的流:

使用者輸入-狀態

但細心的你會發現,左側邏輯部分跟View耦合了,事實上邏輯部分不應該關心使用者的輸入事件(UIEvent)是什麼,也不應該關心介面(UIState)該怎麼展示,這還會導致該部分無法重用。為了把這部分解耦出來,我們多加一層轉換:

增加Action-Result轉換

邏輯部分只關心ActionResult,不與View耦合。Result並不關心介面狀態,只是某個Action的結果,前面說過狀態是對介面的描述,View根據狀態來展示相應的介面,如果我們每次建立一個新的狀態就相當於把介面重置了,所以我們需要知道上一次的狀態,來做相應的調整,如開始狀態UIState.isLoading = true,成功後我們只需要UIState.isLoading = false就可以了,藉助RxJava的scan()來實現這一點:

sealed class Action {

  data class SetUserAction(val userName: String) : Action()

  data class CheckUserAction(val userName: String) : Action()
}
複製程式碼
sealed class Result {

  data class SetUserResult(
      val isLoading: Boolean,
      val isSuccess: Boolean,
      val error: String?
  ) : Result() {
    companion object {
      fun inProgress() = SetUserResult(isLoading = true, isSuccess = false, error = null)

      fun success() = SetUserResult(isLoading = false, isSuccess = true, error = null)

      fun failure(error: String) = SetUserResult(
          isLoading = false,
          isSuccess = false,
          error = error)
    }
  }

  data class CheckNameResult(
      val isLoading: Boolean,
      val isSuccess: Boolean,
      val error: String?
  ) : Result() {
    companion object {
      fun inProgress() = CheckNameResult(isLoading = true, isSuccess = false, error = null)

      fun success() = CheckNameResult(isLoading = false, isSuccess = true, error = null)

      fun failure(error: String) = CheckNameResult(
          isLoading = false,
          isSuccess = false,
          error = error)
    }
  }
}
複製程式碼
data class UIState(val isLoading: Boolean, val isSuccess: Boolean, val error: String?) {
  companion object {
    fun idle() = UIState(isLoading = false, isSuccess = false, error = null)
  }
}
複製程式碼
  ...

  private val setUserTransformer = ObservableTransformer<Action.SetUserAction, Result.SetUserResult> {
    event -> event.flatMap {
      service.setUser(it.userName)
          .map { Result.SetUserResult.success() }
          .onErrorReturn { Result.SetUserResult.failure(it.message.toString()) }
          .subscribeOn(Schedulers.io())
          .observeOn(AndroidSchedulers.mainThread())
          .startWith(Result.SetUserResult.inProgress())
    }
  }

  private val checkUserTransformer = ObservableTransformer<Action.CheckUserAction, Result.CheckNameResult> {
    event -> event.flatMap {
      service.checkUser(it.userName)
          .map { Result.CheckNameResult.success() }
          .onErrorReturn { Result.CheckNameResult.failure(it.message.toString()) }
          .subscribeOn(Schedulers.io())
          .observeOn(AndroidSchedulers.mainThread())
          .startWith(Result.CheckNameResult.inProgress())
    }
  }

  private val transformers = ObservableTransformer<Action, Result> {
    events -> events.publish { shared ->
      Observable.merge(
          shared.ofType(Action.SetUserAction::class.java).compose(setUserTransformer),
          shared.ofType(Action.CheckUserAction::class.java).compose(checkUserTransformer))
    }
  }

  init {
    val setUserAction = setUserSubject.map { Action.SetUserAction(it.userName) }
    val checkUserAction = checkUserSubject.map { Action.CheckUserAction(it.userName) }
    val allActions: Observable<Action> = Observable.merge(setUserAction, checkUserAction)

    disposables.add(
        allActions.compose(transformers)
            .scan(UIState.idle(),
                { previousState, result ->
                  when(result) {
                    is Result.SetUserResult -> {
                      previousState.copy(
                          isLoading = result.isLoading,
                          isSuccess =  result.isSuccess,
                          error =  result.error)
                    }
                    is Result.CheckNameResult -> {
                      previousState.copy(
                          isLoading = result.isLoading,
                          isSuccess =  result.isSuccess,
                          error =  result.error)
                    }
                  }
                })
            .subscribe { ... })
  }

  ...
複製程式碼

程式碼比較多,但邏輯應該算比較清晰,把setUserTransformercheckUserTransformer屬性的輸入和輸出物件調整為ActionResult,在scan()方法里根據上一次的狀態和當前的結果Result來組合新的狀態。

至此,我們簡單的瞭解了狀態管理是如何實現的,接下來我們基於狀態管理的知識來講解MVI模式。

MVI(Model-View-Intent)

什麼是MVI

簡單概括為:單向流(unidirectional flow),資料流不可變(immutability)(關於不可變Model的優缺點網上已經很多,可自行百度或者檢視該文章),響應式的,接收使用者輸入,通過函式轉換為特定Model(狀態),將其結果反饋給使用者(渲染介面)。把MVI抽象為model(), view(), **intent()**三個方法,描述如下:

MVI示意圖

  • intent():中文意思為意圖,將使用者操作(如觸控,點選,滑動等)作為資料流的輸入,傳遞給**model()**方法。
  • model(): model()方法把intent()方法的輸出作為輸入來建立Model(狀態),傳遞給view()
  • view(): **view()方法把model()**方法的輸出的Model(狀態)作為輸入,根據Model(狀態)的結果來展示介面。

你會發現,這跟前面所說的狀態管理描述的如出一轍,下面稍微詳細的描述一下MVI模式:

mvi-detail

我們使用ViewModel來解耦業務邏輯,接收Intent(使用者意圖)並返回State(狀態),其中Processor用於處理業務邏輯,如前面的拆分出來setUserTransformercheckUserTransformer屬性。 View只暴露2個方法:

interface MviView<I : MviIntent, in S : MviViewState> {
  
  fun intents(): Observable<I>

  fun render(state: S)
}
複製程式碼
  • 將使用者意圖傳遞給ViewModel
  • 訂閱ViewModel輸出的狀態用於展示介面

同時ViewModel也只暴露2個方法:

interface MviViewModel<I : MviIntent, S : MviViewState> {
  fun processIntents(intents: Observable<I>)

  fun states(): Observable<S>
}
複製程式碼
  • 處理View傳遞過來的使用者意圖
  • 輸出狀態給View,用於渲染介面

需要說明的是,ViewModel會快取最新的狀態,當Activity/Fragment配置發生改變時(如螢幕旋轉),我們不應該重新建立 ViewModel,而是使用快取的狀態來直接渲染介面,這裡使用google的Architecture Components library的來實現ViewModel,方便生命週期的管理。

關於MVI的程式碼實現可以參考狀態管理部分,下面是我寫的demo中彙總頁的效果,這個頁面只有2個意圖,1)初始化意圖InitialIntent,2)點選曲線點切換月份意圖SwitchMonthIntent

SummaryActivity

這裡給出部分程式碼實現:

data class SummaryViewState(
    val isLoading: Boolean, // 是否正在載入
    val error: Throwable?, // 錯誤資訊
    val points: List<Pair<Int, Float>>, // 曲線圖點
    val months: List<Pair<String, Date>>, // 曲線圖月份
    val values: List<String>, // 曲線圖數值文字
    val selectedIndex: Int, // 曲線圖選中月份索引
    val summaryItemList: List<SummaryListItem>, // 當月標籤彙總列表
    val isSwitchMonth: Boolean // 是否切換月份
) : MviViewState {
  companion object {

    /**
     * 初始[SummaryViewState]用於Reducer
     */
    fun idle() = SummaryViewState(false, null, listOf(), listOf(), listOf(), 0, listOf(), false)
  }
}
複製程式碼
class SummaryActivity : BaseActivity(), MviView<SummaryIntent, SummaryViewState> {

  @Inject lateinit var viewModelFactory: ViewModelProvider.Factory
  private lateinit var summaryViewModel: SummaryViewModel

  private val disposables = CompositeDisposable()

  ...

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    ...

    bind()
  }

  private fun bind() {
    summaryViewModel = ViewModelProviders.of(this, viewModelFactory)
        .get(SummaryViewModel::class.java)

    // 訂閱render方法根據傳送過來的state渲染介面
    disposables += summaryViewModel.states().subscribe(this::render)
    // 傳遞UI的intents給ViewModel
    summaryViewModel.processIntents(intents())
  }

  private fun initialIntent(): Observable<SummaryIntent> { ... }

  private fun switchMonthIntent(): Observable<SummaryIntent> { ... }

  override fun render(state: SummaryViewState) { ... }

  override fun intents(): Observable<SummaryIntent> {
    return Observable.merge(initialIntent(), switchMonthIntent())
  }

  ...
}
複製程式碼
class SummaryViewModel @Inject constructor(
    private val summaryActionProcessorHolder: SummaryActionProcessorHolder
) : BaseViewModel<SummaryIntent, SummaryViewState>() {

  override fun compose(intentsSubject: PublishSubject<SummaryIntent>):
      Observable<SummaryViewState> =
      intentsSubject
          .compose(intentFilter)
          .map(this::actionFromIntent)
          .compose(summaryActionProcessorHolder.actionProcessor)
          .scan(SummaryViewState.idle(), reducer)
          .replay(1)
          .autoConnect(0)

  /**
   * 只取一次初始化[MviIntent]和其他[MviIntent],過濾掉配置改變(如螢幕旋轉)後重新傳遞過來的初始化
   * [MviIntent],導致重新載入資料
   */
  private val intentFilter: ObservableTransformer<SummaryIntent, SummaryIntent> =
      ObservableTransformer { intents -> intents.publish { shared ->
          Observable.merge(
              shared.ofType(SummaryIntent.InitialIntent::class.java).take(1),
              shared.filter { it !is SummaryIntent.InitialIntent }
          )
        }
      }

  /**
   * 把[MviIntent]轉換為[MviAction]
   */
  private fun actionFromIntent(summaryIntent: SummaryIntent): SummaryAction =
      when(summaryIntent) {
        is SummaryIntent.InitialIntent -> {
          SummaryAction.InitialAction()
        }
        is SummaryIntent.SwitchMonthIntent -> {
          SummaryAction.SwitchMonthAction(summaryIntent.date)
        }
      }

  private val reducer = BiFunction<SummaryViewState, SummaryResult, SummaryViewState> {
        previousState, result ->
          when(result) {
            is SummaryResult.InitialResult -> {
              when(result.status) {
                LceStatus.SUCCESS -> {
                  previousState.copy(
                      isLoading = false,
                      error = null,
                      points = result.points,
                      months = result.months,
                      values = result.values,
                      selectedIndex = result.selectedIndex,
                      summaryItemList = result.summaryItemList,
                      isSwitchMonth = false)
                }
                LceStatus.FAILURE -> {
                  previousState.copy(isLoading = false, error = result.error)
                }
                LceStatus.IN_FLIGHT -> {
                  previousState.copy(isLoading = true, error = null)
                }
              }
            }
            is SummaryResult.SwitchMonthResult -> {
              when(result.status) {
                LceStatus.SUCCESS -> {
                  previousState.copy(
                      isLoading = false,
                      error = null,
                      summaryItemList = result.summaryItemList,
                      isSwitchMonth = true)
                }
                LceStatus.FAILURE -> {
                  previousState.copy(
                      isLoading = false,
                      error = result.error,
                      isSwitchMonth = true)
                }
                LceStatus.IN_FLIGHT -> {
                  previousState.copy(
                      isLoading = true,
                      error = null,
                      isSwitchMonth = true)
                }
              }
            }
          }
      }

}
複製程式碼
class SummaryActionProcessorHolder(
    private val schedulerProvider: BaseSchedulerProvider,
    private val applicationContext: Context,
    private val accountingDao: AccountingDao) {

  ...

  private val initialProcessor =
      ObservableTransformer<SummaryAction.InitialAction, SummaryResult.InitialResult> {
        actions -> actions.flatMap { ... }
      }


  private val switchMonthProcessor =
      ObservableTransformer<SummaryAction.SwitchMonthAction, SummaryResult.SwitchMonthResult> {
        actions -> actions.flatMap { ... }
      }

  /**
   * 拆分[Observable<MviAction>]並且為不同的[MviAction]提供相應的processor,processor用於處理業務邏輯,
   * 同時把[MviAction]轉換為[MviResult],最終通過[Observable.merge]合併回一個流
   *
   * 為了防止遺漏[MviAction]未處理,在流的最後合併一個錯誤檢測,方便維護
   */
  val actionProcessor: ObservableTransformer<SummaryAction, SummaryResult> =
      ObservableTransformer { actions -> actions.publish {
          shared -> Observable.merge(
            shared.ofType(SummaryAction.InitialAction::class.java)
                .compose(initialProcessor),
            shared.ofType(SummaryAction.SwitchMonthAction::class.java)
                .compose(switchMonthProcessor))
          .mergeWith(shared.filter {
                it !is SummaryAction.InitialAction &&
                    it !is SummaryAction.SwitchMonthAction
              }
              .flatMap {
                Observable.error<SummaryResult>(
                    IllegalArgumentException("Unknown Action type: $it"))
              })
        }
      }
}
複製程式碼

這裡不帖過多的程式碼了,感興趣的兄弟可以檢視我寫的demo(一個簡單的增刪改記帳app),演示瞭如何用狀態管理的方式實現MVI,邏輯比較簡單。

測試

編寫單元測試的時候,我們只需要提供使用者意圖,藉助RxJava的TestObserver,測試輸出的狀態是否符合我們預期的狀態就可以了,如下面程式碼片段:

summaryViewModel.processIntents(SummaryIntent.InitialIntent())
testObserver.assertValueAt(2, SummaryViewState(...))
複製程式碼

這消除了很多我們用MVP時對View的驗證測試,如Mockito.verify(view,times(1)).showFoo(),因為我們不必處理實際程式碼的實現細節,使得單元測試的程式碼更具可讀性,可理解性和可維護性。總所周知,在Android中UI測試是一件很頭大的事,但狀態是介面的描述,按照狀態來展示介面,對介面顯示正確性也有所幫助,但是要保證介面顯示正確性,還是需要編寫UI測試程式碼。

總結

文章花了很大的篇幅介紹狀態管理(其實就是程式碼比較多),因為狀態管理理解了,MVI也理解了。強烈建議大家看下Jake Wharton關於狀態管理的演講(youtube),和Hannes Dorfmann’s 關於MVI的系列部落格。感謝您閱讀本文,希望對您有幫助。本文的demo 已上傳到github,如果對本文有疑問,或者哪裡說得不對的地方,歡迎在github上實錘

參考

Managing State with RxJava by Jake Wharton
github TODO-MVI-RxJava
REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 - 7

相關文章