My take on Model View Intent (MVI) — Part 1: State Renderer

Automate UI testing with predictable state and flexibility, off the UI thread

Goals

Fully automate UI testing (Espresso on Android) All computation (except view access) done on a background thread A front-end architecture that can fit any platform. The same ideas apply to iOS, Android, & the web, thanks to ReactiveX’s cross-platform nature. A UI layer that can adapt to anything. Edge cases, new requirements, and increased complexity do not require refactoring

This article covers goals #1 and #2. In a future post, I’ll dig into why #4 is true.

Model View Intent (MVI)

I definitely recommend checking out Hannes Dorfmann’s amazing blog series on MVI and Android. I won’t get in to what Model View Intent is, but rather my specific implementation of it.

In a nutshell, we will merge input from our data layer with user input to output a continuously updated ViewState over time, rendering each new instance of a ViewState onto our Ui / View .

Demo App — a “Deck of Cards”

Deal a deck, shuffle, or build a new deck.

Components

My MVI implementation has the following 7 components. Note: #2-5 are all interfaces that your UI/View will implement.

ViewState — a simple POJO that represents all the data displayed in the UI ( Activity , Fragment , ViewGroup , etc). StateRenderer —exposes a single function that accepts an instance of ViewState and can render it to the Ui . This single point of entry is the only way to modify what the user can see. Implementations of this interface will call Ui.Actions methods (see #4).

— fun render(state: ViewState) Ui — the view interface. This is the V in MVP. To be implemented by your view ( Activity , Fragment , ViewGroup , etc).

/**

* The user interface for dealing cards.

*

* @see DealCardsActivity

*/

interface DealCardsUi : StateRenderer<DealCardsUi.State> {



val state: ViewState



override fun render(state: ViewState)

}

4. Ui.Actions — a subset of the view interface. The dumb passive view methods. To be implemented by your view ( Activity , Fragment , ViewGroup , etc).

interface Actions {

/**

* Show or hide the loading UI

* @param isLoading true to show the loading UI, false to hide it

*/

fun showLoading(isLoading: Boolean = true)

}

5. Ui.Intentions — a subset of the view interface. A stream of user input over time. To be implemented by your view ( Activity , Fragment , ViewGroup , etc).

interface Intentions {

/**

* When the user requests to deal the top card from the deck

*/

fun dealCardRequests(): Observable<Unit>

}

6. Presenter — receives input from the Ui.Intentions and input from the data layer to output a new ViewState , which gets rendered onto the view. The P in MVP.

7. Data layer — your disk and network layers. This layer should output any events like “data loaded” or “network error” that occurred to the Presenter .

The rest of the article will go over components 1–4 from above (others will be covered in a later post).

View State & State Renderer

Let’s take a deeper look at the ViewState .

/**

* The view state for [DealCardsUi]

*/

data class State(

val deck: Deck,

private val isShuffling: Boolean,

private val isDealing: Boolean,

private val isBuildingNewDeck: Boolean,

val error: String?

) {

val isLoading: Boolean = isShuffling || isDealing || isBuildingNewDeck

val remaining: Int get() = deck.remaining.size

val dealt: List<Card> get() = deck.dealt

}

Below, you can see how the state affects the contents of the screen.

Here’s the simplified implementation of StateRenderer<DealCardsUi.State> .

Using Rx’s Schedulers and observing the latest ViewState , we achieve Goal #2 — stay off the UI thread as much as possible.

Automated UI Testing

All of our Ui classes have the following function — a single point of entry for displaying information to the user.

fun render(state: ViewState)

Testing is reduced to a simple input/output function.

Input — the ViewState . Grab a reference to your Ui , and call the ui.render(viewState) function. Output — the Ui . Use Espresso to verify the Ui looks as expected.

Want to test configuration changes? Call activity.recreate() and verify the output is unchanged again.

Meet the following requirements to simplify testing.

Unhook (disable) every Presenter from activating during testing, and/or disable your disk/network layer. Keep navigation functional without presenters. Navigation via intents simplifies this. Ability to get a reference to your view. This could be an Activity , Fragment , ViewGroup , Controller , etc - but you must be able to call your view.render(state: ViewState) function.

Conclusion

I truly believe this style of view architecture is the natural evolution over MVP, MVVM, etc. A single ViewState allows for predictable state and maximum testability.

In future articles, I will dig deeper into other components, such as the business logic that is responsible for the ViewState .