My Profile Photo

Chang Min Park


Senior Software Engineer at Yahoo!



Android FE Architecture History: MVC to Compose

Prerequisites

Read these first if Activities, ViewModel, unidirectional UI state, or Compose architecture are new.

Patterns relocate state — Compose rewrites the View

Each meaningful Android FE pattern move did one job: pull state and side effects off the Activity. Compose rewrote how the View half is expressed; it did not invent a sixth acronym for the split. The patterns exist to stop God-object Activities as features grow — not as a résumé tour of MVC → MVP → MVVM → Flux → MVI → Clean.

For example, in a mail app, an inbox screen starts as “show unread count and refresh.” Then ads, sync, search, multi-pane, and A/B flags land in the same class. The Activity still “works,” but it is a mutant: click handlers, network calls, adapter glue, and animation flags share one lifecycle. Architecture is the bargain that keeps the next feature from rewriting that class again.

Three eras below reuse that inbox refresh story as proof. Platform tiers live in Android Architecture Breakdown. Clean’s layers ≠ UI pattern — use cases under MVVM are packaging, not a fifth screen pattern.

Era 1 — God Activity → Presenter / ViewModel

Early Android borrowed MVC. In theory Model / View / Controller split cleanly. In practice the Activity was often all three: XML was “the view,” but the same class handled clicks and mutated widgets.

class InboxActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_inbox)

        findViewById<Button>(R.id.refresh).setOnClickListener {
            // Controller + Model + View glue in one place
            val count = api.fetchUnreadCount()
            findViewById<TextView>(R.id.title).text = "Unread: $count"
        }
    }
}

That fails on rotation, unit tests, and multi-pane because view and controller share the Android entry point. For example, in a mail app, rotating while a refresh is in flight used to recreate the Activity mid-request and lose or double-fire work unless you stuffed more flags into onSaveInstanceState.

MVP pulled interaction logic into a Presenter behind a View interface. The Activity became a dumb renderer; the Presenter owned refresh + error mapping and never needed findViewById in tests.

interface InboxView {
    fun showUnread(count: Int)
    fun showError(message: String)
}

class InboxPresenter(private val api: MailApi) {
    private var view: InboxView? = null
    fun attach(view: InboxView) { this.view = view }
    fun detach() { view = null }

    fun onRefresh() {
        runCatching { api.fetchUnreadCount() }
            .onSuccess { view?.showUnread(it) }
            .onFailure { view?.showError(it.message ?: "Failed") }
    }
}

MVVM kept the same relocation — state off the Activity — but dropped the View interface. A ViewModel survives configuration changes and exposes observable UI state; the View only observes. Google’s architecture components (ViewModel + Repository) made that the default recommendation.

flowchart LR
  U[User] --> V[View]
  V -->|events| VM[ViewModel / Presenter]
  VM --> R[Repository]
  R --> VM
  VM -->|state / showX| V

Figure 1. Era 1 — logic and state leave the Activity; View stays a renderer.

The pain moved: Presenter boilerplate and attach/detach leaks, or fat ViewModels that become a second Activity (network + navigation + formatting in one class). The win stayed the same — inbox refresh logic is testable without an emulator, and the Activity stops owning the unread number.

Era 2 — Unidirectional state

MVP/MVVM still allow the View to poke the model in ad hoc ways. Flux-style stores and MVI close that door: the UI emits actions (or intents), a reducer/store folds them into one immutable model, and the View re-reads state. No View calling the Model sideways.

flowchart LR
  V[View] -->|action / intent| R[Store / reducer]
  R --> M[Immutable state]
  M --> V

Figure 2. Era 2 — one direction: events in, state out.

sealed interface InboxAction {
    data object Refresh : InboxAction
    data class UnreadLoaded(val count: Int) : InboxAction
    data class Failed(val message: String) : InboxAction
}

data class InboxState(val unread: Int = 0, val error: String? = null)

fun reduce(state: InboxState, action: InboxAction): InboxState = when (action) {
    InboxAction.Refresh -> state.copy(error = null)
    is InboxAction.UnreadLoaded -> state.copy(unread = action.count, error = null)
    is InboxAction.Failed -> state.copy(error = action.message)
}

For example, in a mail app, logging every InboxAction makes “why did unread flicker?” a replay problem instead of a hunt through click listeners. Cost: ceremony. A settings toggle does not need a full intent machine; a multi-step message-compose flow or sync-heavy inbox often does. (A Kotlin Redux sketch lives in Redux with Kotlin.)

Unidirectional flow does not replace ViewModel — teams usually put the reducer inside a ViewModel. The pattern move is still relocation: side effects and state transitions leave the Activity, with a stricter contract on how updates happen.

Era 3 — Compose changes the View contract

Jetpack Compose (stable 2021; default for new samples by 2026) is a UI toolkit. Imperative Views held widget references and mutated them when the Presenter/VM said so. Declarative Compose passes state into composables; the runtime diffs the tree. The state split from eras 1–2 stays; only the View half is rewritten. Migration failure modes are their own post: Imperative vs Declarative Android UI.

@Composable
fun InboxScreen(viewModel: InboxViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    if (uiState.isLoading) CircularProgressIndicator()
    Text("Unread: ${uiState.unread}")
    Button(onClick = viewModel::refresh) { Text("Refresh") }
}

Pressure moves from “who calls setText” to where state lives and what triggers recomposition. Less adapter glue; more collectAsStateWithLifecycle, list-item stability, and interop with legacy Views. For example, in a mail app, the inbox list still needs one source of truth for messages — Compose will happily recompose a screen whose ViewModel still hides a God-object of sync and navigation.

Lightest structure that stops mutants

Walk the inbox refresh story once: if state and side effects still live in the Activity, you are in era 0. If a Presenter/VM owns them and the View only renders, you cleared the God-object wall. If updates are hard to reason about, tighten to unidirectional reducers. If you are on Compose, keep that same split — @Composable is how you draw, not where unread count and network live.

Pick the lightest structure that stops the next feature from mutating the entry-point class. Tiny screens can stay thin MVVM. Complex flows earn MVI-style single models. Layers under the UI (repositories, use cases) are optional packaging — useful when you need JVM-pure domain tests, not a reason to memorize seven diagram names. The acronym on the whiteboard matters less than whether the next inbox feature can land without growing another mutant Activity.

References