From State Manage (Model-view-Intent) to MVI (Model-view-Intent)

What is a state? The interface displays a state to users, such as loading display, error information display, list display, etc. This article will show you how to improve your code’s readability, maintainability, and robustness in a more efficient way. There are many code examples in this article, but don’t panic, the logic is relatively simple, just hold on. The article code is implemented using kotlin, and the state management part of the sample code is written using MVP + RxJava mode.

About State Management

Suppose we have a requirement: enter a user name in the input field and click the Save button to save the user to the database. Before saving the database, display the loading state and set the save button to unclickable, which requires asynchronous operation. Finally, hide the loading state when the database is successfully saved and set the Save button to clickable. If an error occurs, hide the loading state and set the Save button to clickable. An error message is displayed. 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)
  }
}
Copy the code

This code doesn’t look very elegant, but it does what we need. Simply draw the flow chart:

View.showloading () and view.setButtonUnable() are called before saving the database operation, and view.hideloading () and view.setButtonEnable() are called when the operation succeeds or fails. When there are more and more “supporting” methods like this, it is easy to neglect, forget to hide the loading state, forget to set the button to be clickable and other problems. This is different from registering a listener on Activity#onCreate(), and undoing a listener on Activity#onDestroy(). But there are many places in a View to call a Presenter method, such as setUser(), which is considered input, and many places for a Presenter to output state to the View, such as view.showloading (), The showError (), etc. We are not sure where the setUser() method is called and the view.showloading () method is called, assuming we have other methods executing at the same time:

For example, the loading state and error message appear at the same time. When the error message is displayed, the save button does not restore the clickable state. This problem is particularly obvious in actual services.

Reative State

Can we restrict presenters to only one input and state output from one place? We bridge with publishSubjects (as in the code snippet setUserSubject above) and merge them into a stream via observable.merge () to achieve a single place input. Let’s focus on how we can output state from only one place.

To quote a classic expression of object-oriented programming: Everything is an object. The user enters the user name, hits the save button, and this is an event, so we think of it as an event object, SetUserEvent, and we think of the UI state as a state object, and the state is a description of the interface. Therefore, we take events as input (SetUserEvent) and output state (SetUserState). The View only needs to display the interface according to the information of state SetUserState (such as loading, displaying error information) :

The View outputs user events to the Presenter and receives the status display. Presenter processes the event input to the View and outputs the status. Let’s see how to do this in code.

First define the interface state SetUserState:

data class SetUserState(
    val isLoading: Boolean.// Whether it is loading
    val isSuccess: Boolean.// Check whether it succeeds
    val error: String? // Error message
) {
  companion object {

    fun inProgress(a) = SetUserState(isLoading = true, isSuccess = false, error = null)
    
    fun success(a) = SetUserState(isLoading = false, isSuccess = true, error = null)
    
    fun failure(error: String) = SetUserState(isLoading = false, isSuccess = false, error = error)
  }
} 
Copy the code

There are three methods defined to represent the loading state, the success state, and the failure state. Next we rewrite the save database operation:

.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)
  }
Copy the code

The core of the change is the inner Observable in the flatMap:

service.setUser(it.userName)
    .map { SetUserState.success() }
    .onErrorReturn { SetUserState.failure(it.message.toString()) }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .startWith(SetUserState.inProgress())
Copy the code

In this internal Observable, events are converted to SetUserState and output. This Observable outputs loading state (startWith(setUserState.inProgress ())). If service.setuser (it. UserName) succeeds, output the success status (map {setUserstate.success ()}); Error status is output when an error occurs, including error messages (onErrorReturn {setUserstate.failure (it.message.tostring ())}). As you can see, we don’t need to care about UI, we don’t need to care about when view.showloading () is called to display loading state, we don’t need to care about when view.hideloading () is called to hideLoading state, Just display the interface according to the SetUserState state in SUBSCRIBE (). To facilitate unit testing and reuse, break this out:

.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)}}})}...Copy the code

Typically there is a lot of input, such as pulling up to load the next page, pulling down to refresh, etc. Now it is assumed that a checkUser() method needs to be added to check whether a user exists. To combine different inputs, we need to define a common parent class, UIEvent, so that each input inherits the parent class:

sealed class UIEvent {

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

  data class CheckUserEvent(val userName: String) : UIEvent()
}
Copy the code

Here is an implementation of 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)
  }
}
Copy the code

As mentioned earlier, we merge input events using observable.merge () :

val allEvents: Observable<UIEvent> = Observable.merge(setUserSubject, checkUserSubject)
Copy the code

Then define the checkUserTransformer as you did earlier. The implementation of the Transformers property is as follows:

  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))
    }
  }
Copy the code

To allow different event inputs to combine different business logic, here we split the merged inputs, then combine different business logic for different inputs, and finally recombine them into one flow:

The advantage of this is that each event input does its own thing without affecting the others. Now going back to the process, we have implemented a cyclic one-way flow:

However, if you are careful, you will find that the left logic part is coupled with the View. In fact, the logic part should not care about what the user’s input event (UIEvent) is, nor how the interface (UIState) should be displayed, which will lead to the part cannot be reused. To decouple this part of the decomposition, we add another layer of transformations:

The logical part only cares about Action and Result and is not coupled to the View. As mentioned above, the state describes the interface, and the View displays the corresponding interface according to the state. If we create a new state each time, the interface will be reset, so we need to know the last state to make corresponding adjustments. If uistate.isloading = true, then uistate.isloading = false. Scan () in RxJava implements this:

sealed class Action {

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

  data class CheckUserAction(val userName: String) : Action()
}
Copy the code
sealed class Result {

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

      fun success(a) = 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(a) = CheckNameResult(isLoading = true, isSuccess = false, error = null)

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

      fun failure(error: String) = CheckNameResult(
          isLoading = false,
          isSuccess = false,
          error = error)
    }
  }
}
Copy the code
data class UIState(val isLoading: Boolean.val isSuccess: Boolean.val error: String?) {
  companion object {
    fun idle(a) = UIState(isLoading = false, isSuccess = false, error = null)}}Copy the code
.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 valcheckUserTransformer = 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)
                    }
                    isResult.CheckNameResult -> { previousState.copy( isLoading = result.isLoading, isSuccess = result.isSuccess, error = result.error) } } }) .subscribe { ... })}...Copy the code

Set the input and output objects of setUserTransformer and checkUserTransformer properties to Action and Result. The scan() method combines the new state based on the previous state and the current Result.

Now that we have a brief understanding of how state management is implemented, we will explain the MVI pattern based on the knowledge of state management.

MVI (Model – View – Intent)

What is the MVI

Briefly summarized as: Unidirectional flow, immutability, responsive, takes user input, converts it to a specific Model using a function, Feed the results back to the user (render the interface). Intent (); intent(); intent(); intent();

  • Intent (): An intent that passes user actions (such as a touch, click, swipe, etc.) as input to a data stream to the model()** method.
  • Model (): The model() method takes the output of the Intent () method as input to create a model(state), which is passed to the view().
  • View (): The **view() method takes the model(state) output of the Model ()** method as input and displays the interface based on the result of the model(state).

As you can see, this is similar to the state management described above. Here is a slightly more detailed description of the MVI pattern:

We use the ViewModel to decouple the business logic, receiving the Intent and returning the State, where the Processor handles the business logic, such as the previously split setUserTransformer and checkUserTransformer properties. View exposes only two methods:

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

  fun render(state: S)
}
Copy the code
  • Pass the user intent to the ViewModel
  • The state of the subscription ViewModel output is used to present the interface

The ViewModel also exposes only two methods:

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

  fun states(a): Observable<S>
}
Copy the code
  • Handle user intents passed by the View
  • Output state to View for rendering interface

Note that the ViewModel caches the latest state. When the Activity/Fragment configuration changes (such as screen rotation), we should not recreate the ViewModel. Instead, we should use the cached state to render the interface directly. Here we use Google’s Architecture Components Library to implement the ViewModel to facilitate lifecycle management.

For the code implementation of MVI, please refer to the state management section. Here is the summary page in the demo I wrote. The page has only two intents: 1) Initialize the intent and 2) click the curve point to switch the month intent SwitchMonthIntent.

Here is part of the code implementation:

data class SummaryViewState(
    val isLoading: Boolean.// Whether it is loading
    valerror: Throwable? .// Error message
    val points: List<Pair<Int.Float> >,// Graph points
    val months: List<Pair<String, Date>>, // Graph month
    val values: List<String>, // Graph numeric text
    val selectedIndex: Int.// Select the month index for the graph
    val summaryItemList: List<SummaryListItem>, // List of labels for the month
    val isSwitchMonth: Boolean // Whether to switch months
) : MviViewState {
  companion object {

    /** * Initial [SummaryViewState] was used to Reducer */
    fun idle(a) = SummaryViewState(false.null, listOf(), listOf(), listOf(), 0, listOf(), false)}}Copy the code
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(a) {
    summaryViewModel = ViewModelProviders.of(this, viewModelFactory)
        .get(SummaryViewModel::class.java)

    // Subscribe to the render method based on the state sent to render the interface
    disposables += summaryViewModel.states().subscribe(this::render)
    // Pass the UI intents to the ViewModel
    summaryViewModel.processIntents(intents())
  }

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

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

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

  ...
}
Copy the code
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)

  [mviIntEnts] and other [mviIntents] are selected only once, filtering out the initialization of the intent after a configuration change (such as screen rotation), causing the data to be reloaded
  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)}}}}}}Copy the code
class SummaryActionProcessorHolder(
    private val schedulerProvider: BaseSchedulerProvider,
    private val applicationContext: Context,
    private val accountingDao: AccountingDao) {

  ...

  private valinitialProcessor = ObservableTransformer<SummaryAction.InitialAction, SummaryResult.InitialResult> { actions -> actions.flatMap { ... }}private valswitchMonthProcessor = ObservableTransformer<SummaryAction.SwitchMonthAction, SummaryResult.SwitchMonthResult> { actions -> actions.flatMap { ... }}[Observable
      
       ] and provides a processor for each [MviAction], which processes the logic, and converts it to [MviResult]. Merges back into a stream with [Observable. Merge] * * To prevent [MviAction] from being left unhandled, merge an error check at the end of the stream for maintenance */
      
  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"))})}}Copy the code

Here is not too much code, interested brothers can check out the demo I wrote (a simple add, delete, change accounting app), demonstrating how to use the state management way to achieve MVI, logic is relatively simple.

test

When writing unit tests, we simply need to provide user intents to test the output’s expected state using RxJava’s TestObserver, as in the following code snippet:

summaryViewModel.processIntents(SummaryIntent.InitialIntent())
testObserver.assertValueAt(2, SummaryViewState(...) )Copy the code

This eliminates many of the View verification tests we used with MVP, such as mockito.verify (View, times(1)).showfoo (), because we don’t have to deal with the implementation details of the actual code, making the unit test code more readable, understandable, and maintainable. As we all know, UI testing in Android is a very big thing, but the state is the description of the interface, according to the state to display the interface, the interface display accuracy is also helpful, but to ensure the correctness of the interface display, or need to write UI test code.

conclusion

The article spends a lot of time on state management (which means a lot of code), because state management is understood, and so is MVI. I strongly encourage you to check out Jake Wharton’s talk on state Management (YouTube) and Hannes Dorfmann’s blog series on MVI. Thank you for reading this article and I hope it was helpful. The demo of this article has been uploaded to Github. If you have any questions about this article, or if there is something wrong with it, feel free to post them on Github.

reference

Managing State with RxJava by Jake Wharton

github TODO-MVI-RxJava

REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 – 7