Getting to know the MVI architecture pattern in Android
MVI architecture proposed and implemented a new theory that by writing a method only once, it is possible to control all UI interactions with the user and actually get rid of CallBacks to some extent.
In the following, we examine the MVI architecture, compare it with other architectures such as the MVP architecture , and finally do a project with it. If you haven’t heard the name of this architecture until now, or you want to use it, stay with Son Learn’s programming training website until the end of this article.
What is MVI architecture?
MVI architecture is an emerging architecture that has been published in the last few years. This architecture is based on Model-View-Intent and is built on two principles (one-way) and (cyclic) inspired by the Cycle.js framework. MVI architecture has many differences compared to other architectures such as MVP, MVC or MVVM, which we will discuss further.
What do the three words Model-View-Intent mean?
As we mentioned in the previous part, MVI architecture is based on Model, View and Intent. In the following, we will define these three words:
Model: provides different states in the application. Models in the MVI architecture should be immutable, and data flows between it and other application layers should be unidirectional.
View : Like other architectures, it uses interfaces and creates contracts for Views such as Activities and Fragments and implements them.
Intent : declare any activity. This activity can be created by the user or the application itself, for all activities (actions), the View receives an Intent, and the Presenter listens to the Intent (Observes).
What are the advantages and disadvantages of MVI architecture?
In the previous parts, we introduced the MVI architecture. Now we want to examine the advantages and disadvantages of MVI architecture in detail.
What are the advantages of MVI architecture?
Advantages of MVI architecture that can be mentioned:
- Data in MVI architecture is unidirectional and has a cyclical data flow.
- In general, maintaining State in this architecture is not a challenge because the main focus of this architecture is on this issue.
- During the life cycle of the View application, it controls only one state and informs different layers.
- It is easier to debug the application because the states are clear at each stage.
- Models in the MVI architecture are immutable, which makes it difficult to move between threads or change data in large applications.
Disadvantages of MVI architecture
Disadvantages of MVI architecture that can be mentioned:
- The main difference that can be listed in the MVI architecture is the difficulty in understanding it for novice and inexperienced programmers. Because to understand this architecture, you need to understand other concepts such as Reactive Programming, Multi-Threading, RxJava, etc.
- In the MVI architecture, because we create a new object of our model for each state, problems such as memory disruption or memory leak may arise as time passes and the application grows. You must pay attention to this point when developing the software.
- The MVI architecture causes code repetition in different parts of the application because it creates a new state for each user interaction.
What are the capabilities of MVI architecture?
In the previous part, we explained that Models in the MVI architecture are State maintainers, and this has made this architecture popular. This feature makes the Model control all the data in the application and send it to higher or lower layers of the application if needed.
Another thing that developers deal with in large projects is the security of data in threads, when moving data in different threads, there is a possibility of data problems. MVI architecture has solved this problem to some extent.
In the MVI architecture, not all layers have the possibility to change the data in the Model. For example, views can only capture the state and display it using one method.
What are the differences between MVI and MVP architecture?
In general, MVP and MVI architectures are similar in most cases, but the main difference between these two architectures is in two cases, which we will mention below:
Models in MVI architecture
When in reactive programming, there is a reaction at the UI level or a change from the user level or the application itself, for example by clicking a button or sending a request to the server and displaying its result. A new state is created. These changes can happen in the background of the application or at the UI level, such as displaying a ProgressBar or receiving the result from the server side.
To understand better, imagine a Model in MVP architecture. This class holds the result received from the server, which is as follows:
data class Picture (
var id : Int ? = null ,
var name : String ? = null ,
var url : String ? = null ,
var date : String ? = null
)
The above example Presenter uses the Model above and prepares it for display at the UI level:
class MainPresenter(private var view: MainView?) {
override fun onViewCreated () {
view. showLoading ()
loadPictureList { pictureList ->
pictureList.let {
this. onQuerySuccess (pictureList)
}
}
}
override fun onQuerySuccess(data: List<Picture>) {
view. hideLoading ()
view. displayPictureList ()
}
}
In such a case, there is no particular problem, but the MVI architecture has investigated and solved some of the problems that may arise in the meantime:
- Receiving multiple inputs and in MVVM and MVP architectures, several inputs and outputs must be controlled inside the Presenter and ViewModel, which can be problematic as time passes and the application grows.
- The existence of several different states In MVVM : and MVP architectures, there may be different states for the application that the developer controls and programs with CallBacks. This may be problematic in exceptional circumstances.
In such cases, the MVI architecture helps us to solve these problems, in which case our model is as follows:
sealed class PictureState {
object LoadingState : PictureState ()
data class DataState (val data: List<Picture>) : PictureState ()
data class ErrorState (val data: String) : PictureState ()
data class ConfirmationState (val picture: Picture) : PictureState ()
object FinishState : PictureState ()
}
In fact, Models in MVI architecture try to control different states and announce them to ViewModel, View or Presenter, which prevents repeating the coding of a State in different layers of the application.
And also our Presenter in this example is as follows:
class MainPresenter {
private val compositeDisposable = CompositeDisposable ()
private lateinit var view : MainView
fun bind ( view: MainView ) {
this . view = view
compositeDisposable. add ( observePictureDeleteIntent ())
compositeDisposable. add ( observePictureDisplay ())
}
fun unbind ( ) {
if (!compositeDisposable. isDisposed ) {
compositeDisposable. dispose ()
}
}
private fun observePictureDisplay () = loadPictureList ()
. observeOn ( AndroidSchedulers . mainThread ())
. doOnSubscribe { view. render ( PictureState . LoadingState ) }
. doOnNext { view. render (it) }
. subscribe ()
}
Now, your application has only one output, which is the State of a View, and it can be displayed using the render() method, this method informs the View of the current state of the application. We make sure that during the lifetime of the application Model only controls one state and cannot be changed in other classes. In other words, it shows only one mode during the life of the application.
Views and Intents in MVI architecture
Like the MVP architecture, the MVI architecture creates an interface as a contract for one or more Views and is implemented by one or more Activities or Fragments. Views in the MVI architecture tend to use a render() and use it to display the UI to the user. Also, Views use intents that are Observable to respond to user interaction with the application.
Note: Intents in the Android MVI architecture do not mean the android.content.Intent class. Rather, it means a class that expresses the changes and performance of the application.
In this case, the View is as follows:
class MainActivity : MainView {
override fun onCreate ( savedInstanceState: Bundle? ) {
super . onCreate (savedInstanceState)
setContentView (R. layout . activity_main )
}
//1
override fun displayPictureIntent () = button. clicks ()
//2
override fun render ( state: PictureState ) {
when (state) {
is PictureState . DataState -> renderDataState (state)
is PictureState . LoadingState -> renderLoadingState ()
is PictureState . ErrorState -> renderErrorState (state)
}
}
//4
private fun renderDataState ( dataState: PictureState.DataState ) {
//Render picture list
}
//3
private fun renderLoadingState ( ) {
//Render progress bar on screen
}
//5
private fun renderErrorState ( errorState: PictureState.ErrorState ) {
//Display error mesage
}
}
- method displayMovieIntent : Binds user interactions to the appropriate Intent. In this example, clicking on a button is reported as an intent.
- method render : indicates the current state of the View. It is also part of the MainView interface.
- method renderDataState : This method prepares the data received from the Model for display.
- method renderLoadingState : This method displays the loading state in View.
- method renderErrorState : This method displays the error generated in the View.
Creating an Android project with MVI architecture
In the following, we are going to implement an Android project for a better understanding:
Note: This project requires knowledge of concepts such as Coroutine, Retrofit and Glide.
To create a new project, do the following steps in order:
- Select the Create a New Project option.
- Select Empty Activity and click Next.
- Choose the desired name, choose the storage location and choose the Kotlin language for the project and then click on the Finish option.
After completing the above steps, we have created our project and proceed to the next steps:
We use three components in this project: Glide, Retrofit and Coroutine. To use these libraries, add the following dependencies in the build.gradle file (you can see the latest version available on the manufacturer’s site or in the Android documentation):
//lifecycle
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:{last-version}'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:{last-version}'
//glide
implementation 'com.github.bumptech.glide:glide:{last-version}'
//retrofit
implementation 'com.squareup.retrofit2:retrofit:{last-version}'
implementation "com.squareup.retrofit2:converter-moshi:{last-version}"
//coroutine
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:{last-version}"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:{last-version}"
Next, we do the packaging for our project:
Before we start, in the AndroidManifest.xml file we add the following access:
< uses-permission android:name = "android.permission.INTERNET" />
In the first step, we need a Model as follows, which we call User (add this class to the repository package):
data class User (
val id : Int ? = null ,
val name : String ? = null ,
val email : String ? = null
)
In the next step, we create the following classes in the api package:
In this project, we need a class called ApiService to be able to use the Retrofit library:
interface ApiService {
@GET( "users" )
suspend fun getUsers(): List <User>
}
Next, we create an interface called ApiHelper:
interface ApiHelper {
suspend fun getUsers(): List < User >
}
Note: In order to be able to use Coroutine, we must change our method (Function) to suspend fun.
Now we create an Object called RetrofitBuilder and put the following methods in it:
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)
}
In the next step, we need a class to implement the ApiHelper interface, which is as follows:
class ApiHelperImpl(private val apiService: ApiService) : ApiHelper {
override suspend fun getUsers (): List<User> {
return apiService. getUsers ()
}
}
Up to this point, the request to our server part as well as the Retrofit implementation is complete. In the next step, we need a repository so that we can use it to call the getUser method in the ViewModel (add this class to the repository package):
class MainRepository(private val apiHelper: ApiHelper) {
suspend fun getUsers () = apiHelper. getUsers ()
}
Our model package is ready in this project. In the next step, we will create a package called adapter in the ui section. To implement RecyclerView, we need a class to implement Adapter and ViewHolder, which we create and write as follows:
import android. view . LayoutInflater
import android. view . View
import android. view . ViewGroup
import androidx. recyclerview . widget . RecyclerView
import com. bumptech . glide . Glide
import com. sevenlearn . mvi . R
import com. sevenlearn . 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
}
}
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)
}
}
Then we create another package called intent and add the following class to it (you can put this class in the package named intent in the UI package):
sealed class MainIntent {
object FetchUser : MainIntent ()
}
In the next step, we reach the important part of the application, the MainState class, this class is one of the most important parts of the application in the MVI architecture, which we add in a package named viewstate:
sealed class MainState {
object Idle : MainState ()
object Loading : MainState ()
data class Users (val user: List<User>) : MainState ()
data class Error (val error: String?) : MainState ()
}
Then we create our ViewModel and call it MainViewModel:
import androidx. lifecycle . ViewModel
import androidx. lifecycle . viewModelScope
import com. sevenlearn . mvi . data . repository . MainRepository
import com. sevenlearn . mvi . ui . main . intent . MainIntent
import com. sevenlearn . 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 )
}
}
}
}