Blog Infos
Author
Published
Topics
, , , ,
Published

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 the count 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:

  • Model (e.g., CounterModel) to hold data and operations.
  • 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 the CounterController.
  • On rotation, LoaderManager reattaches the same loader, so the same CounterController instance is retained.
  • The result is an MVC setup that can survive rotations — albeit with extra boilerplate.
Why MVC Is Still Limited
  1. Strong Coupling: The Controller knows about MainActivity by name.
  2. Testing Challenges: The Controller depends on a real Android class (MainActivity).
  3. 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
  1. Loose Coupling: The Presenter has no direct reference to MainActivity; it only knows CounterView.
  2. Easier Testing: You can test CounterPresenter by providing a mock or fake CounterView.
  3. 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:

  1. 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.
  2. Decoupling from Android Classes: In classic Android MVC, the Controller often references a specific MainActivity (or a Fragment). In MVP, the Presenter just references CounterView. This frees up the Presenter from knowledge of Android life cycle details, making it easier to write vanilla unit tests.
  3. 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) in onCreate and detachView() in onDestroy).

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. The ViewModel 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 (LiveDataFlow, 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., LiveDataFlow, 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 the Activity or Fragment 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

No results found.

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 StateIntent, and optional Effect. To avoid duplicating code for flows and handlers, you can create a BaseMviViewModel that holds:

  1. StateFlow for the screen’s state.
  2. SharedFlow for ephemeral effects (e.g., Toasts, navigation).
  3. 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

  1. 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 the LaunchedEffect 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 StateIntent, and optional Effect, then implements a straightforward reduce method.

While a Redux-like MVI can be more verbose initially, it often pays off in maintainabilityscalability, 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 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.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu