MVC
// Model · View · Controller — "Activity does everything"
// Architecture Diagram
VIEW
XML Layout
Activity UI
Activity UI
user event
update UI
CONTROLLER
Activity / Fragment
⚠ God Class risk
⚠ God Class risk
read/write
data
MODEL
Data + Logic
Pure Kotlin
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
interface contract
delegates to
showCount()
PRESENTER
Pure Kotlin
No Android deps
No Android deps
updates
data
MODEL
Data + Logic
Pure Kotlin
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.
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)
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 |