// Android Development · Architecture Patterns

Four Kings of
Android Architecture

MVC, MVP, MVVM, MVI — every pattern explained with diagrams, live demos, and real Kotlin code.

MVC
// Model · View · Controller — "Activity does everything"
// Architecture Diagram
VIEW
XML Layout
Activity UI
user event
update UI
CONTROLLER
Activity / Fragment
⚠ God Class risk
read/write
data
MODEL
Data + Logic
Pure Kotlin
⚠ Controller (Activity) directly touches both View and Model. As features grow, it becomes the dreaded "God Class" — thousands of lines, impossible to test.
// Live Demo — Counter App
Controller (Activity) manages everything
0
Waiting for user interaction...
// Model.kt
CounterModel.kt
// Pure data class — no Android imports
data class CounterModel(
val count: Int = 0
) {
fun increment() = copy(count = count + 1)
fun decrement() = copy(count = count - 1)
fun reset() = copy(count = 0)
}
// Controller (Activity).kt
CounterActivity.kt — Controller
// ⚠ Activity = Controller + View logic mixed
class CounterActivity : AppCompatActivity() {
private var model = CounterModel()

@Override
fun onCreate(bundle: Bundle?) {
btnInc.setOnClickListener {
model = model.increment() // touches Model
tvCount.text = model.count.toString() // touches View
}
btnDec.setOnClickListener {
model = model.decrement()
tvCount.text = model.count.toString()
}
}
} // Hard to unit-test — needs Android runtime
// Traits
  • Controller is usually the Activity or Fragment — it handles user input AND updates the UI
  • Tight coupling: View and Controller are often the same class, violating separation of concerns
  • Difficult to unit-test: business logic is tangled with Android framework classes
  • Simple to understand for small apps or quick prototypes
  • Model is still clean and testable in isolation
MVP
// Model · View · Presenter — "Delegate everything to the Presenter"
// Architecture Diagram
VIEW
Activity + IView
interface contract
delegates to
showCount()
PRESENTER
Pure Kotlin
No Android deps
updates
data
MODEL
Data + Logic
Pure Kotlin
✓ Presenter has zero Android imports — unit-testable on the JVM without a device. Mock IView in tests.
// Live Demo — Counter App
View calls Presenter · Presenter calls IView back
0
Waiting for user interaction...
// IView Interface + Activity.kt
ICounterView.kt + CounterActivity.kt
// Contract the View must honour
interface ICounterView {
fun showCount(count: Int)
fun showError(msg: String)
}

// Activity only knows the interface
class CounterActivity : AppCompatActivity(),
ICounterView {
private val presenter = CounterPresenter(this)

override fun showCount(count: Int) {
tvCount.text = count.toString()
}
// btn clicks → presenter.onIncrement() etc.
}
// Presenter.kt
CounterPresenter.kt
// Zero Android imports — fully testable!
class CounterPresenter(
private var view: ICounterView?
) {
private var model = CounterModel()

fun onIncrement() {
model = model.increment()
view?.showCount(model.count)
}
fun onDecrement() {
model = model.decrement()
view?.showCount(model.count)
}
fun detach() { view = null } // prevent leak
}
// Traits
  • Presenter is pure Kotlin — zero Android imports, fully unit-testable on the JVM
  • View is passive: it only calls Presenter methods and implements IView to receive updates
  • IView interface can grow verbose as the screen gains more interactions
  • Memory leak risk: Presenter holds a reference to the Activity — must null it in onDestroy
  • Does not survive configuration changes (screen rotation) by default
MVVM
// Model · View · ViewModel — "Observe, don't poll"
// Architecture Diagram
VIEW — Composable / Fragment
Observes StateFlow. Re-renders on every new UiState emission. Never holds logic.
collects StateFlow ↓    emits event ↑
VIEWMODEL — Survives rotation
Exposes _uiState: MutableStateFlow<UiState>. Calls Repository. No Context reference.
calls suspend fun ↓    returns data ↑
REPOSITORY
Decides: local cache or network?
ROOM / RETROFIT
Data sources
// Live Demo — Counter App
View collects StateFlow · ViewModel holds state
0
Waiting for user interaction...
// ViewModel.kt
CounterViewModel.kt
@HiltViewModel
class CounterViewModel @Inject constructor() :
ViewModel() {

private val _uiState = MutableStateFlow(
CounterUiState(count = 0)
)
val uiState = _uiState.asStateFlow()

fun increment() {
_uiState.update { it.copy(count = it.count + 1) }
}
fun decrement() {
_uiState.update { it.copy(count = it.count - 1) }
}
fun reset() {
_uiState.update { it.copy(count = 0) }
}
}
// Composable View.kt
CounterScreen.kt
@Composable
fun CounterScreen(
vm: CounterViewModel = hiltViewModel()
) {
val state by vm.uiState.collectAsStateWithLifecycle()

Column {
Text(text = state.count.toString())
Button(onClick = { vm.increment() }) {
Text("Increment")
}
Button(onClick = { vm.decrement() }) {
Text("Decrement")
}
}
} // Survives rotation. No memory leaks.
// Traits
  • ViewModel survives configuration changes (screen rotation) automatically
  • StateFlow removes the IView interface boilerplate from MVP
  • No memory leak: View collects a flow — no direct reference held by ViewModel
  • Google's official recommendation for modern Android with Jetpack Compose
  • State can be mutated from multiple places — discipline required for larger screens
MVI
// Model · View · Intent — "One truth. One direction. No exceptions."
// Unidirectional Data Flow Loop
VIEW renders State
INTENT sealed class
REDUCER f(state,intent)
STATE immutable copy
↑ State flows back to View — loop repeats ↑
Reducer = pure function: reduce(state, intent) → newState
Same inputs ALWAYS produce the same output. Every state change is logged and replayable.
// Intent — all possible user actions
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
object Reset : CounterIntent()
}

// State — single immutable data class
data class CounterState(val count: Int = 0)
// Live Demo — Counter App
View emits Intent → Reducer → new State → View
0
Waiting for user interaction...
CounterViewModel.kt — MVI style
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state = _state.asStateFlow()

fun process(intent: CounterIntent) {
_state.update { current -> reduce(current, intent) }
}

private fun reduce(s: CounterState, i: CounterIntent) =
when(i) {
Increment -> s.copy(count = s.count + 1)
Decrement -> s.copy(count = s.count - 1)
Reset -> s.copy(count = 0)
}
}
// Traits
  • Reducer is a pure function — trivially unit-testable with no mocks needed
  • Single source of truth: the entire screen UI is derived from one State data class
  • Unidirectional flow makes every state change traceable, loggable, and replayable
  • More boilerplate per screen: Intent sealed class + State data class + Reducer function
  • Best for complex screens with many interactions — overkill for simple screens

// Side-by-side comparison

Pattern Who handles logic? Testability Config change Boilerplate Best for
MVC Activity / Controller Hard — needs Android Manual Low Tiny apps, prototypes
MVP Presenter Easy — mock IView Needs handling Medium Legacy XML projects
MVVM ViewModel Easy — StateFlow Automatic Medium Modern Compose apps
MVI Reducer (pure fn) Trivial — no mocks Automatic High Complex feature screens