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 differentUI
The 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, namelyUI
Layer andState Holder
.State Holder
The role of the general byViewModel
To undertake
The data layer stores and manages application data and provides access to application data. Therefore, the interface layer must perform the following steps:
- Get application data and convert it to
UI
It can be presented easilyUI State
. - To subscribe to
UI State
To refresh when the page state changesUI
- Receives input events from the user and processes them accordingly to refresh
UI State
- 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:
- How to define
UI State
. - How to use one-way data flow (
UDF
), as provided and managedUI State
The way. - How to expose and update
UI State
- How to subscribe
UI 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 State
Advantages 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:
UI
Some 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
:UiState
The 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 havediffing
Mechanism to see if the data stream is the same for successive dispatches, so that each dispatch results in a view update. Sure, we canLiveData
或Flow
usedistinctUntilChanged()
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:
ViewModel
It’s stored and exposedUI State
.UI State
Is aViewModel
Application data for transformation.UI
Layer toViewModel
Sends user event notifications.ViewModel
Handles user actions and updatesUI State
.- The updated status will be fed back to
UI
To present. - 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:
- Data consistency. The interface has only one trusted source.
- Testability. The source of state is independent and therefore can be tested independently of the interface.
- 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 State
Implement 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 logicViewModel
Simple 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:
- Avoid code duplication.
- Improved readability of classes that use domain layer classes.
- Improve application testability.
- 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 beforeMVVM
There 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
- 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
- An emphasis on
UI State
You only need to subscribe to oneViewState
To get all the state of the page, relativeMVVM
Much less template code - Adding state only requires adding a property, lowered
ViewModel
withView
Layer of communication costs to focus business logic inViewModel
,View
The 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