For those keeping track of the latest trends in view architecture patterns, MVI, aka Redux, has seemingly become the latest and greatest for Android Development. There’s tonnes of things to love about MVI - but what are the downsides, and what should you know going into it?
For the sake of this article, I’m basing my thoughts on an adaptation of MVI which I’ve used for a while now for both personal projects and at work. You can find the important classes here for a deeper dive, but I’ll run through the basics here too.
A Gentle Introduction
Fundamentally, MVI imagines the user as a function, and models a unidirectional data flow based on that. The user creates new events at the top of the loop, which are interpreted through various steps until rendered in the UI. This lends itself very well to modern functional reactive programming techniques, and attempts to keep side effects to a minimum and only in one place.
Network &
Navigation] G -.-> |Data| D D --> |Delivers| E[Result] E --> |Reducer| F[ViewState] F --> A
To model this data flow fully, we’ll use 4 marker interfaces: Intent
, Action
, Result
and ViewState
:
interface MviIntent<A : MviAction> {
fun mapToAction(): A
}
interface MviAction
interface MviResult
interface MviViewState
These are then subclassed by sealed classes, except for ViewState
which is subclassed by a data class. It’s this modelling at every step of the data flow which is so beneficial - it forces you to think about every possible input to the model and how it affects other parts of the system. Here’s a contrived example for a view that allows users to load a list of posts:
sealed class UserIntent : MviIntent<PostsAction> {
object Refresh : UserIntent()
override fun mapToAction(): PostsAction = when (this) {
Refresh -> PostsAction.LoadPosts
}
}
sealed class PostsAction : MviAction {
object LoadPosts : PostsAction()
}
sealed class PostsResult : MviResult {
object Loading : PostsResult()
data class Error(val errorMessage: String) : PostsResult()
data class Success(val data: List<ListDisplayModel>) : PostsResult()
}
data class PostsViewState(
val refreshing: Boolean = false,
val data: List<ListDisplayModel> = emptyList(),
val error: ViewStateEvent<String>? = null
) : MviViewState
Intent
objects are mapped to an Action
, and there may well be some crossover between the two. For a very contrived example, a UserClickedRefresh
type Intent
may very well map to the same kind of Action
as UserClickedRetry
. The Processor
which handles the Action
objects doesn’t care what the user intended: it only cares about what it needs to do in response - in this case load the Posts
. You could very easily argue that Action
classes are somewhat redundant, but these are important (in my opinion) to help separate the view from the underlying logic.
These Action
objects are then sent to a Processor
, and this is where the meat of the work happens. Within this class, Action
objects are filtered by type and then split into individual streams where some data may be fetched from the network, or perhaps some navigation occurs. Once the data is fetched, this is then returned as a Result
type, and all of these streams are merged into a single Observable
which is fed back into the Model
.
It’s at this point that the Result
is reduced: if a Result.Loading
object is returned, you might update the current ViewState
by copying it and setting loading
to true
. If your result returns some data, you might set loading to false
and copy the data into the ViewState
. Here, we’re making use of Kotlin’s copy
constructor for data classes.
val reducer: Reducer<PostsViewState, PostsResult> = { state, result ->
when (result) {
PostsResult.Loading -> state.copy(
refreshing = true,
error = null
)
is PostsResult.Error -> state.copy(
refreshing = false,
error = ViewStateEvent(result.errorMessage)
)
is PostsResult.Success -> state.copy(
refreshing = false,
error = null,
data = result.data
)
}
}
Finally, this new ViewState
is sent to the View
, where it’s up to the Activity or Fragment to render this information.
The Good
I really like this view architecture for a few different reasons.
As I mentioned earlier, this architecture really forces you to think about how you model the users interactions and the possible states of the View
, and this can be highly beneficial. In working out which Intent
objects are required, I often realise that I’m missing some key piece somewhere and that alone can be really valuable. I’m sure people feel the same about MVVM or MVP, but in my experience I find that MVI has the strongest “planning” effect on me. This may be because it’s so critical to model failure states in MVI otherwise the stream gets broken - you can’t just log an error in onError
and be done with it.
There’s a lot of separation at every step, and this makes every part extremely easy to test. Mappings from Intent
to Action
, the Reducer
logic, the Processor
… all of these components have very few, tightly defined responsibilities and that makes it super easy to write unit tests at every stage. If you’re a TDD sort of person, this architecture lends itself well to that. This also makes MVI extremely predictable.
It’s also super easy to write UI tests. Because there’s only a single entry point and exit point from the Model
, you only have to mock one dependency and return the different ViewState
objects to test if the UI has any bugs:
@Test
fun on_loading_displays_progress() {
// Given
launchFragmentInContainer {
PostsListFragment().apply {
model = mock {
on { viewState }.thenReturn(
Observable.just(PostsViewState(refreshing = true))
)
}
}
}
// Then
R.id.progressBar.checkVisible()
R.id.postsList.checkGone()
}
(As an aside, if you like these Int
extensions check out a gist here)
If you were using MVVM for instance, this test would have an awful lot of thenReturn(Observable.never())
just to get the test to run.
Adding new functionality is super simple. As we’ve based most of the state around sealed classes we can augment the existing functionality with a new Intent
, hit compile, and then work our way through the errors until the app builds successfully again - then do the same for tests. This way it’s impossible to “miss a step” when you decide to add another feature to a screen.
You can log information at every step in the flow which makes it very easy to debug. Each component, such as the Action
mapper, the Reducer
, or the ViewStates
returning from the Model
can log out information about the current state, and this makes it easy to find bugs. I wouldn’t recommend doing this by default - you’d end up with a very chatty Logcat - but it’s super helpful whilst working through an issue.
Finally, MVI lends itself very well to the upcoming Jetpack Compose. When your UI is a single function which accepts a model, it’s obvious that MVI’s ViewState
can be neatly plugged in.
So far so good. So why shouldn’t everyone go this route?
The Bad and the Ugly
Each View
requires 4 “state” classes, a Model
, a Processor
and a Reducer
. This is a serious class explosion problem, and in a large codebase you could spend a pretty long time looking through Auto-Complete for the correct flavour of Intent
for this class. This is a non-trivial organisational problem, as well as being a tonne of boilerplate for every screen.
There’s two potential solutions here. Firstly to avoid all the boilerplate, get acquainted with Android Studio’s powerful template support. I’d highly recommend standardising these templates and including them within your repo if you work in a team of any size, as it can avoid tonnes of bike-shedding and save everyone an enormous amount of time. At some point, I’ll build these templates and share them on here.
Secondly: modularise. In 2019 you should ideally be creating as many modules as you can get away with, and it’s more than a good idea to group together a handful of Views into a feature module. Keep your state models internal
and don’t expose any more than you need to. This is good practise anyway, but goes a long way towards taming the potential class explosion of MVI.
You could if you wanted to skip mapping Intent
-> Action
, and probably remove the Result
step too by reducing the ViewState
directly in the Processor
. This would remove a fair bit of boilerplate but as I’ve mentioned elsewhere, this would also break some of the separation somewhat. If you wanted a slightly lighter version of MVI with most of the same advantages - go for it.
One relatively minor problem which is quite frustrating is in the naming itself. Intent
is obviously also a framework class and this can rapidly get confusing. You could rename this Intention
or something more distinct, and you might have noticed that I’ve prefixed Intent
to MviIntent
just to break the two classes up a bit. It’s a silly problem but an annoying one.
Arguably the biggest issue is the learning curve. MVI can be pretty difficult to parse even for extremely experienced developers, and there’s quite a few advanced concepts here being borrowed from RxJava (scan
, ObservableTransformer
, ofType
).
Whilst MVI offers a lot of advantages that I really like, bringing a junior onboard and getting them up to speed with both RxJava and MVI could potentially be pretty painful. That’s not to suggest in any way that it’s insurmountable, but it’s a lot more difficult than MVVM to begin to be productive. In a larger organisation with a broad range of developers contributing to the codebase - some who perhaps haven’t touched Android before - it’s probably a wise decision to stick with Google’s preferred MVVM pattern, as there’s a tonne of resources out there that go into depth.
Wrapping up
Overall, there’s a highly theoretical part of me that loves MVI as a View architecture; I like the idea of user-as-a-function and the continual feedback loop that it triggers. For me, I’ll keep using it in personal projects but I wouldn’t deviate from MVVM for a commercial project these days. Android finally has a standardised architecture pattern, and whilst I love experimenting: I’m very glad that it does.