MVI Architecture - Android Tutorial for Beginners - Step By Step Guide
Android architectural patterns are evolving day by day. As we develop apps, we start facing new challenges and issues. New patterns will be discovered as we keep solving similar challenges. As Android Developers, we have MVC, MVP, and MVVM as the most commonly used patterns. All of them use an imperative programming approach. With this approach even though most of our challenges will be resolved, we still face some challenges regarding the thread safety, maintaining states of the application. With this let’s see what MVI architectural pattern is, how it resolves these challenges, and how to get started on MVI.
In this blog, I will walk you through,
- What is MVI architecture?
- How does the MVI work?
- Advantages and Disadvantages of MVI
- Creating a Project with MVI architecture
- Project Source Code and What Next?
What is MVI architecture?
MVI stands for Model-View-Intent . This pattern has been introduced recently in Android. It works based on the principle of unidirectional and cylindrical flow inspired by the Cycle.js framework.
Let’s see what is the role of each component of MVI.
- Model: Unlike other patterns, In MVI Model represents the state of the UI. i.e for example UI might have different states like Data Loading, Loaded, Change in UI with user Actions, Errors, User current screen position states. Each state is stored as similar to the object in the model.
- View: The View in the MVI is our Interfaces which can be implemented in Activities and fragments. It means to have a container which can accept the different model states and display it as a UI. They use observable intents(Note: This doesn't represent the Android traditional Intents) to respond to user actions.
- Intent: Even though this is not an Intent as termed by Android from before. The result of the user actions is passed as an input value to Intents. In turn, we can say we will be sending models as inputs to the Intents which can load it through Views.
How does the MVI work?
User does an action which will be an Intent → Intent is a state which is an input to model → Model stores state and send the requested state to the View → View Loads the state from Model → Displays to the user. If we observe, the data will always flow from the user and end with the user through intent. It cannot be the other way, Hence its called Unidirectional architecture. If the user does one more action the same cycle is repeated, hence it is Cyclic.
Advantages and Disadvantages of MVI
Let’s see what are the advantages and disadvantages of MVI
Advantages of MVI
- Maintaining state is no more a challenge with this architecture, As it focuses mainly on states.
- As it is unidirectional, Data flow can be tracked and predicted easily.
- It ensures thread safety as the state objects are immutable.
- Easy to debug, As we know the state of the object when the error occurred.
- It's more decoupled as each component fulfills its own responsibility.
- Testing the app also will be easier as we can map the business logic for each state.
Disadvantages of MVI
- It leads to lots of boilerplate code as we have to maintain a state for each user action.
- As we know it has to create lots of objects for all the states. This makes it too costly for app memory management.
- Handling alert states might be challenging while we handle configuration changes. For example, if there is no internet we will show the snackbar, On configuration change, it shows the snackbar again as its the state of the intent. In terms of usability, this has to be handled.
With this background, let’s create a small app with MVI
Creating a Project with MVI architecture
Let's start by setting up the Android project.
We are trying to achieve this in our Android project
Create a Project
- Start a new Android project
- Select Empty Activity and Next
- Name: MVI-Architecture-Android-Beginners
- Package name: com.mindorks.framework.mvi
- Language: Kotlin
- Finish
- Your starting project is ready now
Add dependencies
// Added Dependencies
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation 'android.arch.lifecycle:extensions:1.1.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
implementation 'com.github.bumptech.glide:glide:4.11.0'
//retrofit
implementation 'com.squareup.retrofit2:retrofit:2.8.1'
implementation "com.squareup.retrofit2:converter-moshi:2.6.2"
//Coroutine
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.6"
Project Structure
For the project, We are going to follow a beginner version of MVI. Our package in the project will look like below.
Setup Data Layer
Now, in this section, we will set up the data layer.
Under the data package, we will have api, model, and repository packages. We will create these packages and let’s concentrate on adding classes to each package one by one.
Let’s add classes supporting API.
We need a model to which the response will be transformed. Create User.kt data class as shown below.
package com.mindorks.framework.mvi.data.model
import com.squareup.moshi.Json
data class User(
@Json(name = "id")
val id: Int = 0,
@Json(name = "name")
val name: String = "",
@Json(name = "email")
val email: String = "",
@Json(name = "avatar")
val avatar: String = ""
)
Create ApiHelper.kt interface
package com.mindorks.framework.mvi.data.api
import com.mindorks.framework.mvi.data.model.User
interface ApiHelper {
suspend fun getUsers(): List<User>
}
Note: We have used suspend keyword to support Coroutines so that we can call it from a Coroutine or another suspend function.
Kotlin-Coroutines and Flow API are used in this project. You can learn from the following:
Create a class ApiService.kt where we will specify HTTP methods to communicate to the API.
package com.mindorks.framework.mvi.data.api
import com.mindorks.framework.mvi.data.model.User
import retrofit2.http.GET
interface ApiService {
@GET("users")
suspend fun getUsers(): List<User>
}
Now add retrofit builder which will build endpoint URL and consume REST services.
package com.mindorks.framework.mvi.data.api
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
object RetrofitBuilder {
private const val BASE_URL = "https://5e510330f2c0d300147c034c.mockapi.io/"
private fun getRetrofit() = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.build()
val apiService: ApiService = getRetrofit().create(ApiService::class.java)
}
Now we need to implement this interface to fetch the List<Users>, create ApiHelperImpl.kt
package com.mindorks.framework.mvi.data.api
import com.mindorks.framework.mvi.data.model.User
class ApiHelperImpl(private val apiService: ApiService) : ApiHelper {
override suspend fun getUsers(): List<User> {
return apiService.getUsers()
}
}
Now we are ready to communicate with restful services in our data layer.
We will need a repository to requests data. In our case, we are calling the
getUsers
method from activity through ViewModel to get the user list. Create
MainRepository.kt
package com.mindorks.framework.mvi.data.repository
import com.mindorks.framework.mvi.data.api.ApiHelper
class MainRepository(private val apiHelper: ApiHelper) {
suspend fun getUsers() = apiHelper.getUsers()
}
So our data layer is ready. Coming to UI part now, we need an adapter to recyclerview, Intent for storing user actions, our main activity under view, MainViewModel under viewModel, View state where we have defined different states for which we need to load data to views.
Create MainAdapter in adapter package
package com.mindorks.framework.mvi.ui.main.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.mindorks.framework.mvi.R
import com.mindorks.framework.mvi.data.model.User
import kotlinx.android.synthetic.main.item_layout.view.*
class MainAdapter(
private val users: ArrayList<User>
) : RecyclerView.Adapter<MainAdapter.DataViewHolder>() {
class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(user: User) {
itemView.textViewUserName.text = user.name
itemView.textViewUserEmail.text = user.email
Glide.with(itemView.imageViewAvatar.context)
.load(user.avatar)
.into(itemView.imageViewAvatar)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
DataViewHolder(
LayoutInflater.from(parent.context).inflate(
R.layout.item_layout, parent,
false
)
)
override fun getItemCount(): Int = users.size
override fun onBindViewHolder(holder: DataViewHolder, position: Int) =
holder.bind(users[position])
fun addData(list: List<User>) {
users.addAll(list)
}
}
Create MainIntent.kt under the intent package
package com.mindorks.framework.mvi.ui.main.intent
sealed class MainIntent {
object FetchUser : MainIntent()
}
Now adding the MainState.kt under viewstate package. This is the most important part of MVI. In this class, we are defining the states Idle, loading, users, error. Each state can be loaded on to the view by the user intents.
package com.mindorks.framework.mvi.ui.main.viewstate
import com.mindorks.framework.mvi.data.model.User
sealed class MainState {
object Idle : MainState()
object Loading : MainState()
data class Users(val user: List<User>) : MainState()
data class Error(val error: String?) : MainState()
}
Create a ViewModel class - MainViewModel
package com.mindorks.framework.mvi.ui.main.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mindorks.framework.mvi.data.repository.MainRepository
import com.mindorks.framework.mvi.ui.main.intent.MainIntent
import com.mindorks.framework.mvi.ui.main.viewstate.MainState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
@ExperimentalCoroutinesApi
class MainViewModel(
private val repository: MainRepository
) : ViewModel() {
val userIntent = Channel<MainIntent>(Channel.UNLIMITED)
private val _state = MutableStateFlow<MainState>(MainState.Idle)
val state: StateFlow<MainState>
get() = _state
init {
handleIntent()
}
private fun handleIntent() {
viewModelScope.launch {
userIntent.consumeAsFlow().collect {
when (it) {
is MainIntent.FetchUser -> fetchUser()
}
}
}
}
private fun fetchUser() {
viewModelScope.launch {
_state.value = MainState.Loading
_state.value = try {
MainState.Users(repository.getUsers())
} catch (e: Exception) {
MainState.Error(e.localizedMessage)
}
}
}
}
Here in the ViewModel, we are observing on the
userIntent
to perform the action on it.
And based on the response from the data layer, we change the state inside the
fetchUser
method. And that state is being observed in the MainActivity.
Let us set up ViewModelFactory under the util package.
We are instantiating our viewModel in this class and we will return the instance of the ViewModel.
package com.mindorks.framework.mvi.util
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.mindorks.framework.mvi.data.api.ApiHelper
import com.mindorks.framework.mvi.data.repository.MainRepository
import com.mindorks.framework.mvi.ui.main.viewmodel.MainViewModel
class ViewModelFactory(private val apiHelper: ApiHelper) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(MainRepository(apiHelper)) as T
}
throw IllegalArgumentException("Unknown class name")
}
}
Now, let's set up the XML layout.
In the layout folder, update the activity_main.xml with the following code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.view.MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/buttonFetchUser"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/fetch_user"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Add item_layout.xml in the layout folder and add the following code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="60dp">
<ImageView
android:id="@+id/imageViewAvatar"
android:layout_width="60dp"
android:layout_height="0dp"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/textViewUserName"
style="@style/TextAppearance.AppCompat.Large"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageViewAvatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="MindOrks" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/textViewUserEmail"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textViewUserName"
app:layout_constraintTop_toBottomOf="@+id/textViewUserName"
tools:text="MindOrks" />
</androidx.constraintlayout.widget.ConstraintLayout>
Add the following in strings.xml.
<string name="fetch_user">Fetch User</string>
Coming to our MainAcitvity.kt class, We will be adding this under view package. This is the user-facing activity that takes input from the user, based on this MVI checks for the states mentioned in viewModel and loads the particular state in the view.
Let's see how MainActivity takes care of requesting data, handling states
package com.mindorks.framework.mvi.ui.main.view
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProviders
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.mindorks.framework.mvi.R
import com.mindorks.framework.mvi.data.api.ApiHelperImpl
import com.mindorks.framework.mvi.data.api.RetrofitBuilder
import com.mindorks.framework.mvi.data.model.User
import com.mindorks.framework.mvi.util.ViewModelFactory
import com.mindorks.framework.mvi.ui.main.adapter.MainAdapter
import com.mindorks.framework.mvi.ui.main.intent.MainIntent
import com.mindorks.framework.mvi.ui.main.viewmodel.MainViewModel
import com.mindorks.framework.mvi.ui.main.viewstate.MainState
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@ExperimentalCoroutinesApi
class MainActivity : AppCompatActivity() {
private lateinit var mainViewModel: MainViewModel
private var adapter = MainAdapter(arrayListOf())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupUI()
setupViewModel()
observeViewModel()
setupClicks()
}
private fun setupClicks() {
buttonFetchUser.setOnClickListener {
lifecycleScope.launch {
mainViewModel.userIntent.send(MainIntent.FetchUser)
}
}
}
private fun setupUI() {
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.run {
addItemDecoration(
DividerItemDecoration(
recyclerView.context,
(recyclerView.layoutManager as LinearLayoutManager).orientation
)
)
}
recyclerView.adapter = adapter
}
private fun setupViewModel() {
mainViewModel = ViewModelProviders.of(
this,
ViewModelFactory(
ApiHelperImpl(
RetrofitBuilder.apiService
)
)
).get(MainViewModel::class.java)
}
private fun observeViewModel() {
lifecycleScope.launch {
mainViewModel.state.collect {
when (it) {
is MainState.Idle -> {
}
is MainState.Loading -> {
buttonFetchUser.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is MainState.Users -> {
progressBar.visibility = View.GONE
buttonFetchUser.visibility = View.GONE
renderList(it.user)
}
is MainState.Error -> {
progressBar.visibility = View.GONE
buttonFetchUser.visibility = View.VISIBLE
Toast.makeText(this@MainActivity, it.error, Toast.LENGTH_LONG).show()
}
}
}
}
}
private fun renderList(users: List<User>) {
recyclerView.visibility = View.VISIBLE
users.let { listOfUsers -> listOfUsers.let { adapter.addData(it) } }
adapter.notifyDataSetChanged()
}
}
Here, we are sending the intent to fetch the data on button click(User Action).
Also, we are observing on the ViewModel State for the state changes. And, using "when" condition we are comparing the response intent state and loading the respective states.
Finally, add the Internet Permission in your project. Add the following in the AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET"/>
Now, build the project and run the app on the device. It should load the data into the UI.
Project Source Code and What Next?
For the project source code, check here .
As we have done some simplifications in this project for the Beginners level, so, we can improve this project to go to the Advanced level, a few of the things which we can improve are as follows:
- Implement Dependency Inject Framework - Dagger in the project.
- Make ApiService Singleton and reuse the same instance for all the features.
- Create base classes such as BaseActivity.
- Handle all the API errors at a single place in a better way.
- Create Interfaces for the classes wherever required.
- Take advantage of Android KTX - Kotlin Extensions .
- Write Unit-Test
- and so on.
I hope you have learned the MVI architecture in Android.
Keep Learning, Keep Exploring, Keep Growing
Thank you