Understanding and Implementing MVI Architecture in Android Applications
This article introduces Google's recommended MVI (Model-View-Intent) architecture for Android, compares it with MVVM, explains the benefits of unidirectional data flow, and provides practical Kotlin implementations—including LiveData state handling, single-event management, and step-by-step code examples—to guide developers in adopting MVI in mobile projects.
Preface
Google's Android app architecture guide now recommends the MVI (Model-View-Intent) pattern instead of the previous MVVM approach. MVI is the latest Google‑recommended architecture and is worth a deep dive.
Box is an internal e‑sports live‑stream product used at Tencent.
Review of MVVM‑AAC
Because MVI is similar to MVVM, we first briefly recap the non‑two‑way‑binding version of MVVM.
Process
View sends events (initialisation, clicks, etc.) to ViewModel.
ViewModel requests or updates data from Model.
ViewModel updates LiveData (states) with the latest data.
State changes drive the View to create or refresh UI elements.
The cycle repeats for new interactions.
What is MVI?
MVI (Model‑View‑Intent) is an architecture that follows reactive and stream‑processing ideas. It resembles MVVM but borrows concepts from front‑end frameworks, emphasizing a single source of truth and unidirectional data flow.
Unidirectional Data Flow
Data moves in a closed loop and never backwards. The flow consists of:
User actions are expressed as Intents and sent to the Model.
The Model updates the State based on the Intent.
The View receives the new State and refreshes the UI.
Why use unidirectional flow?
It enables separation of concerns by isolating the source, transformation, and consumption of state changes.
The benefits are:
Data consistency – the UI has a single trusted source.
Testability – state origins are independent and can be tested without the UI.
Maintainability – state changes follow a well‑defined pattern driven by user events and data fetches.
Android MVI
Process
View sends Intent events to ViewModel.
ViewModel fetches or updates data from the Data layer.
ViewModel updates ViewState with the new data.
ViewState changes drive the View to create or refresh UI elements.
The cycle repeats.
MVVM → MVI
MVI consolidates non‑single events and states from MVVM into a single flow, achieving a true unidirectional data model.
Intent: Encapsulates operations; the View creates an Intent and passes it to ViewModel, centralising event handling and decoupling the View from specific ViewModel methods.
ViewState: Represents the whole page state; the UI subscribes to a single ViewState, ensuring consistent data flow.
Pros and Cons of MVI vs MVVM
MVI Practical Implementation
While MVI unifies events and states, practical migration from MVVM introduces new challenges, such as handling one‑time events and avoiding unnecessary UI refreshes.
How to listen to LiveData properties for partial refresh?
When ViewState contains many fields, any change triggers a UI update, which can hurt performance. Using distinctUntilChanged() on LiveData limits updates to changed values.
fun <T, A> LiveData<T>.observeState(
lifecycleOwner: LifecycleOwner,
prop1: KProperty1<T, A>,
action: (A) -> Unit
) {
this.map {
StateTuple1(prop1.get(it))
}.distinctUntilChanged().observe(lifecycleOwner) { (a) ->
action.invoke(a)
}
}Map converts State to the property value.
distinctUntilChanged() prevents callbacks for unchanged values.
KProperty1 requires the State data class to avoid obfuscation.
One‑time state events compatibility?
One‑time events (e.g., Toast, Dialog) wrapped in ViewState can be lost on configuration changes. MVVM uses SingleLiveEvent ; the same can be applied in MVI, but distinctUntilChanged may filter unchanged events.
Approach 1 – Mavericks style
Airbnb’s Mavericks component uses a uniqueOnly listening mode to separate one‑time events from regular state, preserving unidirectional flow while handling events centrally.
Advantages: Solves both problems and keeps a single ViewState.
Disadvantages: Highly custom, based on Kotlin Flow rather than LiveData, increasing learning and migration cost.
Approach 2 – Split LiveData
Separate regular state (MutableLiveData) from one‑time events (SingleLiveEvent wrapped in a sealed class). This reduces migration effort but breaks the single source of truth.
class MainViewModel : ViewModel() {
private val _viewStates = MutableLiveData<MainViewState>(MainViewState())
val viewStates = _viewStates.asLiveData()
private val _viewEvents = SingleLiveEvents<MainViewEvent>()
val viewEvents = _viewEvents.asLiveData()
} // ViewState
data class MainViewState(
val fetchStatus: FetchStatus = FetchStatus.NotFetched,
val newsList: List<NewsItem> = emptyList()
)
// One‑time events
sealed class MainViewEvent {
data class ShowSnackbar(val message: String) : MainViewEvent()
data class ShowToast(val message: String) : MainViewEvent()
}Advantages: Low migration cost.
Disadvantages: One‑time events need a separate channel; the pure single‑source principle is weakened.
Proposed Solution – SingleEventState wrapper
Wrap one‑time properties in SingleEventState that generates a unique diff tag each time, allowing distinctUntilChanged to fire even when the underlying value is unchanged.
data class SingleEventState<DATA>(val data: DATA,
val diffTag: String? = if (data != null) System.currentTimeMillis().toString() + data.hashCode() else null) : ISingleEventState {
override fun isNone(): Boolean = data == null
}
interface ISingleEventState { fun isNone(): Boolean }Update observeState to ignore ISingleEventState.isNone() during initialisation.
fun <T, A> LiveData<T>.observeState(
lifecycleOwner: LifecycleOwner,
prop1: KProperty1<T, A>,
action: (A) -> Unit
) {
this.map { StateTuple1(prop1.get(it)) }
.distinctUntilChanged()
.observe(lifecycleOwner) { (a) ->
if (a is ISingleEventState && a.isNone()) return@observe
action.invoke(a)
}
}Then define ViewState, Intent, and Event sealed classes, register observers in the Activity, and update ViewState in the ViewModel as shown in the original code snippets.
Conclusion
Using SingleEventState allows one‑time events to bypass the diff function while keeping normal ViewState updates unchanged, fully satisfying MVI principles with LiveData.
Summary Table
Approach
Pros
Cons
Adopt?
Mavericks
Unified ViewState management
Flow‑based, higher learning cost
No
MVI best‑practice (LiveData)
Low migration cost
Splits ViewState, breaks single source
No
Box internal proposal
Unified ViewState, low LiveData cost
None identified
Yes
Android MVI Overall Architecture Diagram
MVI Beyond Android
Compose and Flutter also adopt reactive/declarative UI patterns where UI rebuilds are driven by State changes. The same unidirectional data‑flow concepts apply, using ViewModel + LiveData on Android or Provider + Consumer on Flutter.
Further Reading
Android App Architecture Guide – https://developer.android.com/jetpack/guide
Google recommends MVI – https://mp.weixin.qq.com/s/dd5mfTVSt8WgW7aOOf-CDg?token=891227444&lang=zh_CN
MVI best practice with LiveData – https://app.yinxiang.com/fx/92b88756-9014-4fd5-9eee-88b8b29aeb73
IEG Growth Platform Technology Team
Official account of Tencent IEG Growth Platform Technology Team, showcasing cutting‑edge achievements across front‑end, back‑end, client, algorithm, testing and other domains.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.