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
Activity
that depends on Android framework code. - State Loss: Rotations recreate the
Activity
, losing thecount
unless 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 used
LoaderManager 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:
ControllerLoader
is responsible for creating and holding onto theCounterController
.- On rotation,
LoaderManager
reattaches the same loader, so the sameCounterController
instance 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
MainActivity
by 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
CounterPresenter
by providing a mock or fakeCounterView
. - Clear Contracts: The
CounterView
interface 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)
inonCreate
anddetachView()
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
ViewModel
in Architecture Components removed the need for Loaders for rotation handling. TheViewModel
class 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
Activity
by 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
ViewModel
base class, binding it to theActivity
orFragment
lifecycle. 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
State
object 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
StateFlow
for the screen’s state. - A
SharedFlow
for ephemeral effects (e.g., Toasts, navigation). - A
reduce
function 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
viewState
is our single “source of truth.”viewEffect
handles 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 data
CounterIntent for user actions
CounterEffect 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") } } }
uiState
re-composes automatically when the state changes.processIntent(...)
is the single path to mutate the state.- Ephemeral effects like
ShowToast
are consumed once in theLaunchedEffect
block.
Why This Helps
- Single Source of Truth: By referencing
_viewState
for data, your UI remains simple and reactive. - Pure Reducers: The
reduce
function 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
BaseMviViewModel
ensures 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
View
interface, decoupling the Presenter from concrete UI classes; Loaders (or retained Fragments) again offered a rotation fix pre-ViewModel
. - MVVM uses lifecycle-aware
ViewModel
s 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.