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 aViewState
onto ourUi
/View
.
Demo App — a “Deck of Cards”
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 ofViewState
and can render it to theUi
. This single point of entry is the only way to modify what the user can see. Implementations of this interface will callUi.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 ofStateRenderer<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 yourUi
, and call theui.render(viewState)
function. - Output — the
Ui
. Use Espresso to verify theUi
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 yourview.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
.
Catch the conversation on Reddit
All source is available on GitHub.
Shoutout to the many pioneers of reactive programming that have made this architecture possible!
Additional resource — watch Jake Wharton’s awesome talk on managing state with Rx.
Hacker Noon is how hackers start their afternoons. We’re a part of the @AMI family. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!