preface

Some time ago, I wrote some articles about THE MVI architecture, but there is no best architecture in software development, only the most suitable architecture, and as we all know, Google recommended the MVVM architecture. I’m sure many of you will be wondering why I don’t use the official recommended MVVM instead of the MVI architecture you are talking about. However, I have been looking at the Android Application Architecture guide for a few days and found that the best practices recommended by Google have changed to one-way data flow + centralized state management. Isn’t that MVI architecture? It looks like Google is recommending the MVI architecture, and it’s worth checking out the latest version of the Android Application Architecture Guide

This article is based on the Android application architecture guide, or you can read it directly

The overall architecture

Two architectural principles

There are two main architectural principles for Android

Separation of concerns

The most important principle to follow is separation of concerns. A common mistake is to write all your code in a single Activity or Fragment. These interface-based classes should contain only the logic that handles interface and operating system interactions. In general, the code in an Activity or Fragment should be as lean as possible and move the business logic to another layer as possible

Through a data-driven interface

Another important principle is that you should use a data-driven interface (preferably a persistence model). The data model is independent of the interface elements and other components in the application. This means that they are not associated with the lifecycle of the interface and application components, but are still destroyed when the operating system decides to remove the application’s process from memory. Data model and interface elements, life cycle decoupling, so easy to reuse, easy to test, more stable and reliable.

Recommended application architecture

Based on the common architectural principles mentioned in the previous section, each application should have at least two layers:

  • Interface layer – Displays application data on the screen.
  • Data layer – Provides required application data.

You can add an additional architectural layer called the “domain layer” to simplify and reuse the interaction between the interface layer and the data layer

As shown above, the dependence between layers is one-way, the domain layer and the data layer do not depend on the interface layer

Interface layer

The interface displays application data on the screen and responds to user clicks. Whenever the data changes, whether because of a user interaction (such as pressing a button) or external input (such as a network response), the interface should be updated to reflect those changes.

However, the format of application data retrieved from the data layer is usually differentUIThe format of the data that needs to be presented, so we need to translate the data layer data into the state of the page

Therefore, the interface layer is generally divided into two parts, namelyUILayer andState Holder.State HolderThe role of the general byViewModelTo undertake

The data layer stores and manages application data and provides access to application data. Therefore, the interface layer must perform the following steps:

  1. Get application data and convert it toUIIt can be presented easilyUI State.
  2. To subscribe toUI StateTo refresh when the page state changesUI
  3. Receives input events from the user and processes them accordingly to refreshUI State
  4. Repeat steps 1-3 as needed.

It is mainly a one-way data flow, as shown in the figure below:

Therefore, the interface layer mainly needs to do the following:

  1. How to defineUI State.
  2. How to use one-way data flow (UDF), as provided and managedUI StateThe way.
  3. How to expose and updateUI State
  4. How to subscribeUI State

How to defineUI State

If we were to implement a news list interface, how would we define UI State? We encapsulate all the states needed by the interface in a Data class. One of the major differences from the previous MVVM pattern is also here, namely that while there was usually one State for one LiveData, the MVI architecture emphasizes centralized management of UI State

data class NewsUiState(
    val isSignedIn: Boolean = false.val isPremium: Boolean = false.val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,...).Copy the code

The UI State definition in the example above is immutable. The main benefit is that immutable objects are guaranteed to provide the state of the application in real time. In this way, the UI can focus on a single role: reading UI State and updating its UI elements accordingly. Therefore, do not change UI State directly in the UI. Violating this principle can lead to multiple trusted sources for the same piece of information, leading to data inconsistency problems.

For example, if the bookmarked tag in the NewsItemUiState object from UI State above has been updated in the Activity class, that tag can compete with the data layer, causing problems with multiple data sources.

UI StateAdvantages and disadvantages of centralized management

In MVVM we usually have multiple data streams, i.e. one State for one LiveData, whereas in MVI we have a single data stream. What are the advantages and disadvantages of each? The main advantage of a single data stream is convenience, reducing the template code, adding a state only requires adding an attribute to the data class, You can effectively reduce the cost of ViewModel to View communication, and centralized UI State management can easily achieve mediatorLiveData-like effects, such as the possibility that you only need to show bookmark buttons if the user is logged in and a subscriber to a paid news service. You can define UI State as follows:

data class NewsUiState(
    val isSignedIn: Boolean = false.val isPremium: Boolean = false.val newsItems: List<NewsItemUiState> = listOf()
){
	val canBookmarkNews: Boolean get() = isSignedIn && isPremium
}
Copy the code

As shown above, the bookmark’s visibility is a derivative of the other two attributes. When the other two attributes change, canBookmarkNews automatically changes as well. When we need to implement the bookmark’s visible and hidden logic, we simply subscribe to canBookmarkNews. This makes it easy to achieve mediatorLiveData-like effects, but much simpler

Of course, there are some problems with centralized UI State management:

  • Unrelated data types:UISome of the required states may be completely independent of each other. In such cases, the costs of bundling these different states together may outweigh the benefits, especially if one of them is updated more frequently than the others.
  • UiState diffing:UiStateThe more fields in an object, the more likely it is that the data flow will be emitted because one of the fields is updated. Because the view doesn’t havediffingMechanism to see if the data stream is the same for successive dispatches, so that each dispatch results in a view update. Sure, we canLiveDataFlowusedistinctUntilChanged()And other methods to achieve local refresh, so as to solve this problem

Use one-way data flow managementUI State

As mentioned above, in order to ensure that the State cannot be changed in the UI, the elements in the UI State are immutable. How do you update the UI State? We generally use the ViewModel as a container for UI State, so updating UI State in response to user input can be divided into the following steps:

  1. ViewModelIt’s stored and exposedUI State.UI StateIs aViewModelApplication data for transformation.
  2. UILayer toViewModelSends user event notifications.
  3. ViewModelHandles user actions and updatesUI State.
  4. The updated status will be fed back toUITo present.
  5. The system repeats this for all events that cause the state to change.

For example, if the user needs to bookmark the news list, the event needs to be passed to the ViewModel, which then updates the UI State(possibly with updates in the data layer), and the UI layer subscribes to the UI State in response to the refresh, thus completing the page refresh, as shown in the following figure:

Why use one-way data flow?

The principle of separation of concerns is implemented by one-way data flow, which separates the source of state changes, the transition, and the end use. This separation allows the UI to do just what its name suggests: display page information by observing UI State changes, and pass user input to the ViewModel for State refreshes.

In other words, one-way data flows help achieve the following:

  1. Data consistency. The interface has only one trusted source.
  2. Testability. The source of state is independent and therefore can be tested independently of the interface.
  3. Maintainability. Changes in state follow a well-defined pattern in which state changes are the result of a combination of user events and their data pull sources.

Exposure and renewalUI State

Once you have defined the UI State and determined how to manage the corresponding State, the next step is to send the supplied State to the interface. We can use LiveData or StateFlow to convert UI State to data flow and expose it to the UI layer. To ensure that State cannot be changed in the UI, we should define a mutable StateFlow and an immutable StateFlow as follows:

class NewsViewModel(...). : ViewModel() {private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}
Copy the code

This way, the UI layer can subscribe to the state, and the ViewModel can modify the state. In the case of asynchronous operations, for example, the viewModelScope can be used to start the coroutine, and the state can be updated when the operation is complete.

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

    fun fetchArticles(category: String){ fetchJob? .cancel() fetchJob = viewModelScope.launch {try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                 }
            }
        }
    }
}
Copy the code

In the example above, the NewsViewModel class tries to make a network request and then updates the UI State, to which the UI layer can react appropriately

To subscribe toUI State

Subscribing to UI State is as simple as observing and refreshing the UI at the UI layer

class NewsActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?). {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}
Copy the code

UI StateImplement local refresh

Since MVI implements centralized management of UI State, updating an attribute will result in updating UI State. How to implement local refresh in this case? We can implement distinctUntilChanged, which only refreshes after the value has changed, acting as a sort of shock protection for the property, so we can implement a local refresh as shown below

class NewsActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?). {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}
Copy the code

Of course, we can also encapsulate it to some extent by adding an extension function to Flow or LiveData to support listening properties, as shown below

class MainActivity : AppCompatActivity() {
	private fun initViewModel(a) {
        viewModel.viewStates.run {
            / / to monitor newsList
            observeState(this@MainActivity, MainViewState::newsList) {
                newsRvAdapter.submitList(it)
            }
            // Monitor network status
            observeState(this@MainActivity, MainViewState::fetchStatus) {
                / /..}}}}Copy the code

For more details on MVI support for attribute listening, see: MVI better practice: Support LiveData attribute listening

Domain layer

The domain layer is an optional layer between the interface layer and the data layer.

The domain layer is responsible for encapsulating complex business logic, or multiple business logicViewModelSimple business logic that can be reused. This layer is optional because not all applications have such requirements. Therefore, you should use this layer only when needed.

The network domain layer has the following advantages:

  1. Avoid code duplication.
  2. Improved readability of classes that use domain layer classes.
  3. Improve application testability.
  4. Allows you to divide responsibilities so that large classes are avoided.

I feel that the domain layer is not necessary for common apps. For the repeated logic of ViewModel, util is generally sufficient. Developer.android.com/jetpack/gui…

The data layer

The Data layer is responsible for the logic of obtaining and processing Data. The Data layer consists of multiple repositories, each of which can contain zero to multiple Data sources. You should create a Repository class for each of the different types of data your application processes. For example, you can create the MoviesRepository class for movie-related data, or the PaymentsRepository class for payment-related data. For the convenience of a Repository with only one data source, you can also write the data source code in the Repository and split it later when there are multiple data sources

The data layer is the same as the one beforeMVVMThere is no difference between the data layer under the architecture, so I won’t go into details here.Developer.android.com/jetpack/gui…

conclusion

Compared with the previous version of the architecture guide, the new version is mainly to add the domain layer and modify the interface layer, the domain layer is optional, you can use according to their own project needs. The interface layer changed from MVVM architecture to MVI architecture, emphasizing one-way data flow and centralized management of state. The MVI architecture has the following advantages over the MVVM architecture

  1. It emphasizes one-way data flow and is easy to track and track state changes. It has certain advantages in data consistency, testability and maintainability
  2. An emphasis onUI StateYou only need to subscribe to oneViewStateTo get all the state of the page, relativeMVVMMuch less template code
  3. Adding state only requires adding a property, loweredViewModelwithViewLayer of communication costs to focus business logic inViewModel,ViewThe layer simply subscribes to the state and then refreshes

Of course, in software development, there is no best architecture, only the most appropriate architecture. You can choose the architecture that suits your project according to your situation. In fact, IT seems to me that Google recommended using MVI instead of MVVM in the guide, probably in order to unify the Android and Compose architectures. Because there is no two-way data binding in Compose, only one-way data flow, MVI is the most suitable architecture for Compose.

Of course, if you don’t use DataBinding in your project, you might want to start using MVI. Switching from MVVM architecture without DataBinding to MVI is not expensive, and it is relatively easy to switch. It has some advantages in terms of ease of use, data consistency, testability, maintainability, etc. You can then seamlessly switch to Compose as well.

More and more

Support LiveData attribute listening MVI Architecture Encapsulation: Fast and elegant implementation of network requests Android application Architecture Guide