Modern Android Architecture with MVI design pattern
Starting a new project we have to make several important decisions that will have a huge impact not only on the final success or failure of the project but also on our daily work. These decisions include the choice of project methodology, tools, technology and architecture. When I started my work as an inexperienced developer over 7 years ago I thought that the definition of project success is simply meeting the business requirements of the client. As time passed and my experience grew, I realized that just as important as meeting the client’s business requirements is to build the project in a way that allows for easy change and testability. To build a project this way, we need not only the right technologies but also a well-thought-out architecture.
There are several well-known and battle-tested architectures for Android applications like MVP or MVVM. Implementing them in your project solves many issues that are common in Android environment. Moreover, it makes the code flexible, testable, and maintainable. However, each of these approaches has some drawbacks such as bidirectional coupling or the need to recreate state manually for MVP, and the lack of a single “source of truth” that modifies state and also cumbersome testing for MVVM. These problems are one of the reasons why a new pattern — MVI — is growing in popularity.
MVI (Model-View-Intent) streamlines the process of creating and developing applications by introducing a reactive approach. In a way, this pattern is a mixture of MVP and MVVM adapted to reactive programming. It eliminates the use of callback and significantly reduces the number of input/output methods. It is also a great solution for synchronization states between view and the business logic layer.
Why MVI?
As the application grows or unplanned in advance functionality is added — without clear state management, the view rendering along with the business logic can get a little bit messy.
The more scalable the application code is, the more flexible it is to new ideas and updates. Scalability, flexibility and easy testability — that’s what the MVI architecture offers us.
Key advantages of MVI:
- single source of truth — one immutable state common to all layers, as the only source of truth
- unidirectional and cyclic data flow
- easiness of catching and fixing bugs
- easiness of code testability
- ability to test all layers of the application with unit tests
Architecture Layers
View — The view layer observes user actions and system events. As a result, it sets the intention for the triggered event. Also, it listens and reacts to the change in state of the model.
Model — A model is a representation of the view state. It contains all the information necessary to render the view correctly.
Intent — A representation of a future action that changes the state of the model.
User* — Many people also include the application user as a part of MVI architecture. He or she observes and reacts to view state changes by playing with the application. You can see it on the diagram below.
Let’s assume the following scenario — a “counter” application. This application has the following functionalities:
- displays the current counter state to the user
- gives the possibility to increase the counter by 1, by clicking the “increase” button
- gives the possibility to decrease the counter by 1, by clicking the “decrease” button
The user holds a phone with the “counter” application running. The screen shows the initial counter state “0” and 2 buttons. The user interacts with the application by clicking the “increase” button. Clicking the button is an intention — in this case, the intention is “to increase the counter by 1”. The business logic layer handles the emitted intention, changes the state and passes the new state to the view. The view with the new state is rendered, the user sees the result of the operation.
Every rose has its thorn
Every solution, besides the benefits it generates, also has its drawbacks. In the case of MVI, at this moment I see two significant weaknesses:
- more boilerplate, comparing to other architectures
- less popular than MVP or MVVM, so in case of problems it is harder to get help or learn something from the community
The first of the described problems can be eliminated by using a library that handles the core of MVI flow for us (moves the common logic to abstraction), removing the burden of creating repetitive code. At this point, there are already several libraries available that support application development in the MVI architecture:
The second problem, in my opinion, will fade over time as more and more people become convinced of the new approach. I think that in order to understand something well and be able to use it, the best way is to start from scratch. That is why I decided to create my own library to facilitate the implementation of the MVI pattern.
See it in action
This library contains code that simplifies implementing the MVI pattern and reduces boilerplate. The implementation is based on several basic components:
- ViewState — state of a given screen
- ViewEffect — an effect that can occur on a given screen (f.e. showing a snackbar or navigation action)
- Intent — intention or desire to perform an action (e.g. clicking a button or typing text)
- Presenter — object that observes the intents and maps them into PartialStates
- PartialState — object that updates the ViewState using “reduce” method
- View — representation of the view — interface mostly implemented by fragment/activity
- MviActivity — activity class containing boilerplate code required by MVI flow
- MviFragment — fragment class containing boilerplate code required by MVI flow
Let’s take a look at the example described above — the “counter” application:
The following class that holds the counter state is the view state representation for this screen:
data class CounterViewState(
val counterValue: Int = 0
) : ViewState {
val counterValueText: String = "$counterValue"
}
Then, on this screen, the only effect that can occur is navigation to the next screen, so the viewEffect will look like this (let’s make it a sealed class to make sure that we have handled all cases in the view implementation):
sealed class CounterViewEffect : ViewEffect {
object NavigateToSecondScreen : CounterViewEffect()
}
Now let’s take a look at the actions the user can perform. The user can click the “increase” button, click the “decrease” button or navigate to the next screen. Let’s create corresponding intents (it also should be a sealed class to cover all the cases in the presenter mapping method):
sealed class CounterIntent : Intent {
object Increase : CounterIntent()
object Decrease : CounterIntent()
object NavigateToSecondScreen : CounterIntent()
}
Then, we need to implement partial state classes that will know how to modify the viewstate depending on the case:
sealed class CounterPartialState : PartialState<CounterViewState, CounterViewEffect> {
object Increase : CounterPartialState() {
override fun reduce(previousState: CounterViewState): CounterViewState {
return previousState.copy(counterValue = previousState.counterValue + 1)
}
}
object Decrease : CounterPartialState() {
override fun reduce(previousState: CounterViewState): CounterViewState {
return previousState.copy(counterValue = previousState.counterValue - 1)
}
}
object NavigateToSecondScreen : CounterPartialState() {
override fun mapToViewEffect(): CounterViewEffect {
return CounterViewEffect.NavigateToSecondScreen
}
}
}
Now, we create a presenter that maps intents to partial states:
class CounterPresenter @Inject constructor(
@Named(MAIN_THREAD) mainThread: Scheduler
) : Presenter<CounterViewState, CounterView, CounterPartialState, CounterIntent, CounterViewEffect>(mainThread) {
override val defaultViewState: CounterViewState
get() = CounterViewState()
override fun intentToPartialState(intent: CounterIntent): Observable<CounterPartialState> =
when (intent) {
is CounterIntent.Increase -> Observable.just(CounterPartialState.Increase)
is CounterIntent.Decrease -> Observable.just(CounterPartialState.Decrease)
is CounterIntent.NavigateToSecondScreen -> Observable.just(CounterPartialState.NavigateToSecondScreen)
}
}
Now let’s go to the view. We assign the values of the viewstate to the widgets using databinding:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewState"
type="com.bonacode.modernmvi.sample.presentation.feature.counter.CounterViewState" />
</data>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:gravity="center"
android:text="@{viewState.counterValueText}"
android:textColor="@color/colorBlack"
android:textSize="80sp"
tools:text="43" />
<com.google.android.material.button.MaterialButton
android:id="@+id/increaseButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Increase"
android:textSize="50sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/decreaseButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Decrease"
android:textSize="50sp" />
</LinearLayout>
</layout>
The last step is to implement the activity/fragment class, which collects the intents and render the state to the screen:
class CounterActivity :
MviActivity<CounterViewState, CounterViewEffect, CounterView, CounterPresenter, ActivityCounterBinding>(),
CounterView {
override val binding: ActivityCounterBinding by viewBinding(ActivityCounterBinding::inflate)
override val presenter: CounterPresenter by viewModels()
override fun getMviView(): CounterView = this
override fun render(viewState: CounterViewState) {
binding.viewState = viewState
binding.executePendingBindings()
}
override fun handleViewEffect(event: CounterViewEffect) {
when (event) {
is CounterViewEffect.NavigateToSecondScreen -> navigateToSecondScreen()
}
}
override fun emitIntents(): Observable<CounterIntent> = Observable.merge(
listOf(
binding.increaseButton clicksTo CounterIntent.Increase,
binding.decreaseButton clicksTo CounterIntent.Decrease,
binding.navigateForwardButton clicksTo CounterIntent.NavigateToSecondScreen
)
)
private fun navigateToSecondScreen() {
startActivity(
Intent(
this,
DogsActivity::class.java
)
)
}
}
Bonus — testing!
We create a viewRobot class that will pretend to be a view (fragment/activity). Thanks to this, we will be able to effectively test each layer of the application using only unit tests. No espresso needed!
class CounterViewRobot(
presenter: CounterPresenter
) : ViewRobot<CounterViewState, CounterViewEffect, CounterView, CounterPresenter>(presenter) {
private val increaseSubject = PublishSubject.create<CounterIntent.Increase>()
private val decreaseSubject = PublishSubject.create<CounterIntent.Decrease>()
private val navigateToSecondScreenSubject =
PublishSubject.create<CounterIntent.NavigateToSecondScreen>()
override val view: CounterView = object : CounterView {
override fun render(viewState: CounterViewState) {
renderedStates.add(viewState)
}
override fun handleViewEffect(event: CounterViewEffect) {
emittedViewEffects.add(event)
}
override fun emitIntents(): Observable<CounterIntent> = Observable.merge(
increaseSubject,
decreaseSubject,
navigateToSecondScreenSubject
)
}
fun increase() {
increaseSubject.onNext(CounterIntent.Increase)
}
fun decrease() {
decreaseSubject.onNext(CounterIntent.Decrease)
}
fun navigateToSecondScreen() {
navigateToSecondScreenSubject.onNext(CounterIntent.NavigateToSecondScreen)
}
}
We test the presenter by executing the action that the user would perform and then, by checking whether the appropriate view state was emitted after the execution of this action.
class CounterPresenterTest {
private val testScheduler = Schedulers.trampoline()
private val presenter = CounterPresenter(testScheduler)
private val viewRobot = CounterViewRobot(presenter)
@Test
fun `when increase button clicked then proper view states emitted`() {
viewRobot.test {
viewRobot.increase()
}
viewRobot.assertViewStates(
CounterViewState(counterValue = 0),
CounterViewState(counterValue = 1)
)
}
@Test
fun `when increase button clicked then no view effects emitted`() {
viewRobot.test {
viewRobot.increase()
}
viewRobot.assertViewEffects()
}
@Test
fun `when decrease button clicked then proper view states emitted`() {
viewRobot.test {
viewRobot.decrease()
}
viewRobot.assertViewStates(
CounterViewState(counterValue = 0),
CounterViewState(counterValue = -1)
)
}
@Test
fun `when decrease button clicked then no view effects emitted`() {
viewRobot.test {
viewRobot.decrease()
}
viewRobot.assertViewEffects()
}
@Test
fun `when navigate to second screen button clicked then proper view effects emitted`() {
viewRobot.test {
viewRobot.navigateToSecondScreen()
}
viewRobot.assertViewEffects(
CounterViewEffect.NavigateToSecondScreen
)
}
@Test
fun `when navigate to second screen button clicked then only default view state emitted`() {
viewRobot.test {
viewRobot.navigateToSecondScreen()
}
viewRobot.assertViewStates(CounterViewState())
}
}
On this Github page, you can find the library and more complex samples of modern MVI.
Happy coding!
Originally published at https://amsterdamstandard.com.