Modern Android development stands on the shoulders of many architectural patterns. Each one emerged to address problems of the previous approach, especially around coupling between UI and logic and managing state across lifecycle events. This article traces that evolution step-by-step, illustrating how Android developers moved from “everything in the Activity” to more decoupled, testable patterns.
1. The Early Days: UI-Centric (God Activity)
What It Is
Early Android apps often threw all logic — business logic, UI updates, and state management — into one Activity or Fragment. This was sometimes erroneously labeled “MVC,” but in practice, there was no separate Controller file. Everything lived in the same UI class, leading to “God Activities.”
Example (Counter App)
class MainActivity : AppCompatActivity() {
private var count = 0
private lateinit var countTextView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
countTextView = findViewById(R.id.countTextView)
findViewById<Button>(R.id.incrementButton).setOnClickListener {
// Business logic + UI in the same place
count++
updateView()
}
}
private fun updateView() {
countTextView.text = count.toString()
}
}
Shortcomings
- Tight Coupling: The UI class owns everything — difficult to expand or test.
- Difficult Testing: All logic is in an
Activitythat depends on Android framework code. - State Loss: Rotations recreate the
Activity, losing thecountunless you manually handle it. - No Separation of Concerns: Business logic and UI code are interwoven.
2. Classic MVC: Introducing a Separate Controller
Model–View–Controller (MVC) is the next step. Instead of dumping everything in the Activity, we introduce a dedicated controller class that sits between the UI and the data Model. The Activity (or Fragment) still represents the View, but we now have:
- A Model (e.g.,
CounterModel) to hold data and operations. - A Controller that orchestrates data retrieval/updates.
- The View (the
Activity) that renders the data and forwards user input.
The Activity serves as the View, while a distinct Controller class references both the View and the Model. This is a step up from the UI-centric approach: at least we have a separate logic class.
Base MVC Example (Without Loaders)
// Model
class CounterModel {
private var count = 0
fun increment() { count++ }
fun getCount(): Int = count
}
// The "Controller" references a concrete Activity as the View
class CounterController(
private val view: MainActivity, // Direct link to Activity (the View)
private val model: CounterModel
) {
fun onIncrementClicked() {
model.increment()
view.updateCounter(model.getCount())
}
}
// The Activity as "View"
class MainActivity : AppCompatActivity() {
private lateinit var controller: CounterController
private lateinit var model: CounterModel
private lateinit var countTextView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
countTextView = findViewById(R.id.countTextView)
model = CounterModel()
controller = CounterController(this, model)
findViewById<Button>(R.id.incrementButton).setOnClickListener {
controller.onIncrementClicked()
}
}
fun updateCounter(count: Int) {
countTextView.text = count.toString()
}
}
Adding a Loader to Retain the Controller (Legacy Approach)
Before
ViewModel existed, developers commonly usedLoaderManager or retained Fragments to keep the Controller alive across rotations.However, today’s
ViewModel is a simpler approach, which we’ll cover in the MVVM section.
Below is an example of how we’d keep our controller instance around.
// 1) Create a Loader to hold the Controller
class ControllerLoader(
context: Context,
private val initialView: MainActivity
) : Loader<CounterController>(context) {
private var controller: CounterController? = null
override fun onStartLoading() {
if (controller == null) {
// Create a fresh Model and Controller the first time
val model = CounterModel()
controller = CounterController(initialView, model)
}
// Deliver the existing controller (if any)
controller?.let { deliverResult(it) }
}
}
// 2) Activity: Using the Loader to persist Controller
class MainActivity : AppCompatActivity() {
companion object {
private const val LOADER_ID = 1001
}
private var controller: CounterController? = null
private lateinit var countTextView: TextView
private val loaderCallbacks = object : LoaderManager.LoaderCallbacks<CounterController> {
override fun onCreateLoader(id: Int, args: Bundle?): Loader<CounterController> {
return ControllerLoader(this@MainActivity, this@MainActivity)
}
override fun onLoadFinished(loader: Loader<CounterController>, data: CounterController?) {
// We have our controller (could be newly created or retained)
controller = data
}
override fun onLoaderReset(loader: Loader<CounterController>) {
// Called if the Loader is reset or destroyed
controller = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
countTextView = findViewById(R.id.countTextView)
// Init or reattach the Loader that holds our Controller
loaderManager.initLoader(LOADER_ID, null, loaderCallbacks)
findViewById<Button>(R.id.incrementButton).setOnClickListener {
controller?.onIncrementClicked()
}
}
fun updateCounter(count: Int) {
countTextView.text = count.toString()
}
}
How It Works:
ControllerLoaderis responsible for creating and holding onto theCounterController.- On rotation,
LoaderManagerreattaches the same loader, so the sameCounterControllerinstance is retained. - The result is an MVC setup that can survive rotations — albeit with extra boilerplate.
Why MVC Is Still Limited
- Strong Coupling: The Controller knows about
MainActivityby name. - Testing Challenges: The Controller depends on a real Android class (
MainActivity). - Loader Boilerplate: Even with Loaders, you still have more manual code to handle rotations.
3. MVP: Breaking the Direct Coupling with a View Interface
What It Is
Model–View–Presenter (MVP) emerged as a response to the coupling issue in classic MVC. Instead of referencing the concrete Activity or Fragment as the View, the Presenter only knows a View interface. That interface is then implemented by the Activity. This effectively breaks the direct link between the Presenter and an Android class.
Base MVP Example (Without Loaders)
// Model
class CounterModel {
private var count = 0
fun increment() { count++ }
fun getCount(): Int = count
}
// View Interface
interface CounterView {
fun updateCount(count: Int)
fun showError(message: String)
}
// Presenter
class CounterPresenter(private val model: CounterModel) {
private var view: CounterView? = null
fun attachView(view: CounterView) {
this.view = view
view.updateCount(model.getCount())
}
fun detachView() {
this.view = null
}
fun onIncrementClicked() {
model.increment()
view?.updateCount(model.getCount())
}
}
// Activity implementing the View interface
class MainActivity : AppCompatActivity(), CounterView {
private lateinit var presenter: CounterPresenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
presenter = CounterPresenter(CounterModel())
presenter.attachView(this)
findViewById<Button>(R.id.incrementButton).setOnClickListener {
presenter.onIncrementClicked()
}
}
override fun updateCount(count: Int) {
findViewById<TextView>(R.id.countTextView).text = count.toString()
}
override fun showError(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
override fun onDestroy() {
super.onDestroy()
presenter.detachView()
}
}
We’ve already seen a Loader-based rotation fix in the MVC example; developers would have historically done something similar with MVP if they absolutely needed to hold onto the Presenter across configuration changes.
Why MVP Is Better Than Classic MVC
- Loose Coupling: The Presenter has no direct reference to
MainActivity; it only knowsCounterView. - Easier Testing: You can test
CounterPresenterby providing a mock or fakeCounterView. - Clear Contracts: The
CounterViewinterface declares exactly what UI methods the Presenter can invoke.
Comparing MVC vs. MVP and Their Evolution
In many Android “classic MVC” implementations, the Controller can directly reference the Activity (the View), and the Activity can also call back into the Controller. This creates a two-way street: the Controller calls methods on the View, and the View calls methods on the Controller. Because the Controller knows the concrete Activity (instead of a generic interface), you get some coupling that can make testing or swapping out the UI more cumbersome.
Presenter -> View (interface) <- MainActivity
Model–View–Presenter (MVP) tries to solve this by inserting a View interface between the Presenter and the actual UI class (Activity or Fragment). Now the Presenter depends only on that interface, not on a specific Activity. Meanwhile, the Activity implements that interface, receives user events, and routes them to the Presenter. In return, the Presenter calls view.updateCount(...) (or similar methods) without knowing any details about what “the view” actually is.
This shift does more than simply introduce an interface:
- Cleaner Data Flow: User actions flow into the Presenter. The Presenter updates the Model and then calls back to the View interface with results. The Model doesn’t talk directly to the View. Compared to some MVC variants where the Model might notify the Controller, and the Controller in turn notifies the View, MVP standardizes that the Presenter is the sole path for updating the UI.
- Decoupling from Android Classes: In classic Android MVC, the Controller often references a specific
MainActivity(or a Fragment). In MVP, the Presenter just referencesCounterView. This frees up the Presenter from knowledge of Android life cycle details, making it easier to write vanilla unit tests. - Reduced Two-Way Tangles: Because the Presenter is bound to an interface rather than a concrete class, you can more easily “mock” or “fake” the View in tests. And the Presenter can remain in memory while the real Activity is destroyed or recreated, so long as you handle re-attaching the View gracefully (e.g.,
attachView(view)inonCreateanddetachView()inonDestroy).
Therefore, the difference between MVC and MVP is not purely the presence of a View interface (though that is a crucial piece). It’s also about how references and data flow are structured. In classical MVC, you often see an Activity and Controller referencing each other directly, which can become a tangled web. In MVP, the Presenter orchestrates calls to and from a strictly defined View interface, resulting in a neater, more testable, and more maintainable code flow.
. . .
4. MVVM: Achieving Full UI Isolation with Reactive Streams
What It Is
MVVM (Model-View-ViewModel) goes even further by removing “push” calls from the Presenter. Instead, the ViewModel holds data in a reactive manner (LiveData, Flow, or RxJava Observables), and the View “observes” that data. This means the ViewModel is totally UI-agnostic—it has no references to Activity or Fragment.
Crucially, the arrival of
ViewModelin Architecture Components removed the need for Loaders for rotation handling. TheViewModelclass is inherently lifecycle-aware and survives configuration changes automatically.
Jetpack Compose Integration
Jetpack Compose, Android’s modern declarative UI toolkit, fits naturally with MVVM. Compose re-composes its UI whenever the underlying data changes. That synergy fosters a straightforward, unidirectional data flow:
- User action → ViewModel method
- ViewModel updates internal state (
LiveData,Flow, etc.) - The UI observing that state automatically re-renders
Example (Using StateFlow + Jetpack Compose)
// ViewModel
class CounterViewModel : ViewModel() {
private val _counter = MutableStateFlow(0)
val counter: StateFlow<Int> = _counter.asStateFlow()
fun increment() {
_counter.value++
}
}
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val count by viewModel.counter.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = count.toString())
Button(onClick = { viewModel.increment() }) {
Text("Increment")
}
}
}
Key Points
- No Direct Reference to the UI: The ViewModel never calls something like
activity?.updateCount(). - Lifecycle-Aware: The ViewModel outlives the
Activityby default, eliminating the need for Loaders or retained Fragments. - Completely UI-Agnostic: The UI “pulls” data changes, making the ViewModel trivial to unit test.
- Unidirectional Data Flow: With Compose or LiveData/Flow in XML-based apps, the user actions feed into the ViewModel, which updates state that the UI observes.
Note: The introduction of
ViewModel effectively replaced the need for Loaders in modern architectures, since it automatically survives configuration changes and handles state retention.
Why the MVVM replaced the MVP
The MVVM approach ensures a unidirectional data flow:
- The UI knows about the ViewModel, but the ViewModel knows absolutely nothing about the UI.
- With reactive programming, the ViewModel communicates back to the View via observables (e.g.,
LiveData,Flow, or RxJava). - Unlike iOS, where the ViewModel is often just a plain class, in Android the ViewModel inherits from the
ViewModelbase class, binding it to theActivityorFragmentlifecycle. This means it survives configuration changes automatically, replacing Loaders in the process.
By combining lifecycle-awareness and reactive updates, MVVM eliminates the boilerplate needed for rotation handling and keeps business logic fully decoupled from the UI.
5. MVI: Enforcing Unidirectional Data Flow
Why MVI?
- Unidirectional Data Flow: Every user action is an Intent that yields a new State — no hidden updates.
- Single Source of Truth: The UI renders from one State object, making debugging easier.
- Not Just Tidying Up: MVI explicitly models each interaction, reducing guesswork about how the UI changes.
- Compose Synergy: Jetpack Compose is reactive and stateless; feeding it MVI states is a natural fit.
What It Is
MVI (Model-View-Intent) is a specialization or extension of MVVM that enforces a strict unidirectional data flow with explicit user Intents. The user triggers Intent objects, which the ViewModel (or a dedicated “Processor”) reduces into a single State. The UI observes that State, re-rendering accordingly.
In simpler forms, MVI can look similar to MVVM with a single data class for State and a single sealed interface for Intents. In more advanced, “Redux-like” forms, we split logic into Intents, Actions, Results, and a dedicated Reducer function.
5.1 A Lightweight MVI in a ViewModel Approach
Below is a lightweight MVI example implemented in a single ViewModel class:
Lightweight MVI in a ViewModel
// 1) State: Single source of truth for the UI
data class CounterState(
val count: Int = 0,
val error: String? = null
)
// 2) Intents: All possible user actions
sealed interface CounterIntent {
object Increment : CounterIntent
object Reset : CounterIntent
data class SetCount(val value: Int) : CounterIntent
}
// 3) ViewModel in MVI style
class CounterMviViewModel : ViewModel() {
// Internal MutableStateFlow
private val _state = MutableStateFlow(CounterState())
// Exposed as a read-only StateFlow
val state: StateFlow<CounterState> = _state.asStateFlow()
/**
* The "Reducer": We can define it inline here, or as a separate function.
*
* In simpler MVI, the reducer is simply the logic inside 'processIntent'
* that takes the current state and returns a new state. For clarity,
* you could extract the below `when` branches into a smaller reduce(...) function.
*/
fun processIntent(intent: CounterIntent) {
_state.update { oldState ->
when (intent) {
is CounterIntent.Increment -> oldState.copy(count = oldState.count + 1, error = null)
is CounterIntent.Reset -> oldState.copy(count = 0, error = null)
is CounterIntent.SetCount -> {
if (intent.value >= 0) {
oldState.copy(count = intent.value, error = null)
} else {
oldState.copy(error = "Negative counts not allowed")
}
}
}
}
}
}
// 4) Composable "View" observing the StateFlow
@Composable
fun CounterMviScreen(viewModel: CounterMviViewModel = viewModel()) {
// Collect the current state from our MVI ViewModel
val uiState by viewModel.state.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = uiState.count.toString())
// Show an error if any
uiState.error?.let { error ->
Text(
text = error,
color = Color.Red
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.processIntent(CounterIntent.Increment) }) {
Text("Increment")
}
Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
Text("Reset")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Example: Setting a specific count
Button(onClick = { viewModel.processIntent(CounterIntent.SetCount(5)) }) {
Text("Set Count to 5")
}
}
}
Where Does the Reducer Go?
In simple MVI, the “Reducer” logic is typically just the when block creating a new CounterState from the old state + user intent. Some teams extract this into a dedicated function for clarity, but the principle remains: all changes flow through a single reduce step.
Job Offers
5.2 A Redux-like MVI Flavor with BaseViewModel Approach
Traditional MVVM already keeps business logic in the ViewModel, but MVI goes a step further by:
- Enforcing Unidirectional Flow: All updates come in as Intents, pass through a Reducer, and yield a new State.
- Centralizing State: You have one “source of truth” for the screen’s data.
- Explicit Intent Handling: Each user interaction is modeled explicitly.
- Pure Reducer: The function that updates State is a pure function — great for testing.
If you’ve worked with Redux in JavaScript, you’ll notice parallels. Even if not, think of it as a clear pipeline for user actions → state changes.
Why Go “Redux-Like”?
- Explicit Intent Handling: Each user action is a typed
Intent, making it easy to track. - Single Source of Truth: The
Stateobject is the one authoritative representation of the screen’s data. - Predictable: By funneling changes through a single Reducer, you can systematically track state changes over time.
- Scalable: As your app grows, it’s easier to add advanced features (e.g., side effects, asynchronous data loading) because the pattern remains consistent.
5.2.1 Structuring the Code with a Base ViewModel
In more complex apps, each screen can define its own State, Intent, and optional Effect. To avoid duplicating code for flows and handlers, you can create a BaseMviViewModel that holds:
- A
StateFlowfor the screen’s state. - A
SharedFlowfor ephemeral effects (e.g., Toasts, navigation). - A
reducefunction you override in each child ViewModel.
/**
* A generic base for Redux-like MVI in Android.
* @param S The screen's State type
* @param I The screen's Intent type
* @param E One-off Effects that the UI handles exactly once
*/
abstract class BaseMviViewModel<S, I, E>(
initialState: S
) : ViewModel() {
// The current immutable State
private val _viewState = MutableStateFlow(initialState)
val viewState: StateFlow<S> = _viewState.asStateFlow()
// For ephemeral effects like Toasts or navigation
private val _viewEffect = MutableSharedFlow<E>(replay = 0)
val viewEffect: SharedFlow<E> = _viewEffect
// Convenient way to read the latest state
protected val currentState: S
get() = _viewState.value
/**
* Called by the UI (or internal code) whenever an Intent occurs.
* 1) We run the 'reduce' function with (oldState + intent).
* 2) We update the StateFlow with the new State.
* 3) If there's an effect, we emit it for the UI to consume.
*/
fun processIntent(intent: I) {
val (newState, effect) = reduce(currentState, intent)
setState(newState)
effect?.let { postEffect(it) }
}
/**
* The child ViewModel implements how to combine oldState + Intent.
* The result is (newState, optionalEffect).
*/
protected abstract fun reduce(
oldState: S,
intent: I
): Pair<S, E?>
/**
* Updates our StateFlow with a fresh state.
*/
protected fun setState(newState: S) {
_viewState.value = newState
}
/**
* Emits a single effect that the UI will handle once.
*/
protected fun postEffect(effect: E) {
viewModelScope.launch {
_viewEffect.emit(effect)
}
}
}
How It Works
- Key Points
viewStateis our single “source of truth.”viewEffecthandles ephemeral events that do not belong in persistent state.processIntent(...)is the single funnel for user actions.reduce(...)merges(oldState + intent) -> (newState, effect?).
5.2.2 Defining State, Intents, and Effects
Let’s adapt our lightweight MVI counter into a Redux-like version using BaseMviViewModel. We’ll define:
CounterState for screen dataCounterIntent for user actionsCounterEffect for ephemeral events like toasts
// 1) State: Single source of truth
data class CounterState(
val count: Int = 0,
val error: String? = null
)
// 2) Intents: All possible user actions
sealed class CounterIntent {
object Increment : CounterIntent()
object Reset : CounterIntent()
data class SetCount(val value: Int) : CounterIntent()
}
// 3) Effects: One-off events
sealed class CounterEffect {
data class ShowToast(val message: String) : CounterEffect()
// e.g., object NavigateToDetail : CounterEffect()
}
5.2.3 The Redux-Like ViewModel
Now, create the actual ViewModel:
class CounterReduxViewModel : BaseMviViewModel<
CounterState,
CounterIntent,
CounterEffect
>(
initialState = CounterState()
) {
override fun reduce(
oldState: CounterState,
intent: CounterIntent
): Pair<CounterState, CounterEffect?> {
return when (intent) {
is CounterIntent.Increment -> {
val newState = oldState.copy(
count = oldState.count + 1,
error = null
)
newState to null
}
is CounterIntent.Reset -> {
val newState = oldState.copy(
count = 0,
error = null
)
newState to null
}
is CounterIntent.SetCount -> {
if (intent.value < 0) {
val newState = oldState.copy(
error = "Negative counts not allowed"
)
newState to CounterEffect.ShowToast("Invalid count!")
} else {
val newState = oldState.copy(
count = intent.value,
error = null
)
newState to null
}
}
}
}
}
Whenever you call processIntent(...) with a CounterIntent, the reduce function calculates a new CounterState and an optional CounterEffect.
5.2.4 Consuming the Redux-Like ViewModel in Compose
In Jetpack Compose, you’d collect both viewState and viewEffect:
@Composable
fun CounterReduxScreen(viewModel: CounterReduxViewModel = viewModel()) {
// Observe the state
val uiState by viewModel.viewState.collectAsStateWithLifecycle()
// Observe one-off effects
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.viewEffect.collect { effect ->
when (effect) {
is CounterEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
}
}
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Count: ${uiState.count}")
uiState.error?.let { errorMsg ->
Text(text = errorMsg, color = Color.Red)
}
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.processIntent(CounterIntent.Increment) }) {
Text("Increment")
}
Button(onClick = { viewModel.processIntent(CounterIntent.Reset) }) {
Text("Reset")
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.processIntent(CounterIntent.SetCount(5)) }) {
Text("Set Count to 5")
}
}
}
uiStatere-composes automatically when the state changes.processIntent(...)is the single path to mutate the state.- Ephemeral effects like
ShowToastare consumed once in theLaunchedEffectblock.
Why This Helps
- Single Source of Truth: By referencing
_viewStatefor data, your UI remains simple and reactive. - Pure Reducers: The
reducefunction is deterministic—easy to test(oldState, intent) -> (newState, effect). - Scalability: For bigger apps, handle advanced side effects (e.g., API calls) by introducing a “Processor” or dispatching new Intents.
- Consistency for Teams: A
BaseMviViewModelensures each feature’s state management follows the same rules, simplifying code reviews and onboarding.
5.3 Comparing MVI Approaches
In the Android architecture landscape, MVI stands out for its clarity and unidirectional data flow. A “Redux-like” MVI pattern goes further by structuring your code around a single Reducer pipeline, making state updates predictable and testable. By centralizing your logic in a BaseMviViewModel, each screen defines State, Intent, and optional Effect, then implements a straightforward reduce method.
While a Redux-like MVI can be more verbose initially, it often pays off in maintainability, scalability, and predictability — especially for complex apps with multi-step user interactions. Combined with Jetpack Compose, you get a modern, reactive, and traceable UI architecture that integrates seamlessly with lifecycle-aware components.
Whether you prefer a simple MVI or a more structured Redux flavor, the guiding principle remains: keep all logic unidirectional, centralize your state, and make interactions explicit. This foundation ensures fewer surprises, easier debugging, and a codebase that can grow gracefully.
Conclusion
Android’s architectural evolution reflects a consistent push toward loose coupling, testability, and robust state management:
- UI-Centric lumps everything into one place (the “God Activity”), making code fragile and hard to test.
- Classic MVC separates logic from UI but still references the Activity directly in the Controller. Loaders helped preserve logic but added boilerplate.
- MVP introduces a
Viewinterface, decoupling the Presenter from concrete UI classes; Loaders (or retained Fragments) again offered a rotation fix pre-ViewModel. - MVVM uses lifecycle-aware
ViewModels to naturally retain state, eliminating the need for Loaders. The UI observes reactive data, isolating business logic from UI code. - MVI locks down unidirectional state changes with an Intent→State pipeline, ideal for complex apps needing predictable flows.
MVI can be lightweight or Redux-like, but it always emphasizes a single “source of truth” and an explicit path for state changes. In modern projects, many teams favor MVVM or MVI — often with Jetpack Compose — for clarity, testability, and a lifecycle-friendly structure.
This article is previously published on proandroiddev.com.




