Without deliberate architecture, Android apps devolve into "God Activities" — a single class responsible for UI rendering, business logic, data fetching, and lifecycle management simultaneously. Each pattern below is a response to the pain points of its predecessor.
MVC on Android
Model-View-Controller maps naturally to server-side web but awkwardly to Android. The Activity ends up playing both View and Controller roles, since it owns the layout and handles user events.
// Anti-pattern: Activity as MVC "Controller" AND "View"class UserProfileActivity :AppCompatActivity(){privateval retrofit = Retrofit.Builder().baseUrl("https://api.example.com/").build().create(UserApi::class.java)overridefunonCreate(savedInstanceState: Bundle?){super.onCreate(savedInstanceState)setContentView(R.layout.activity_user_profile)// Controller logic lives here — tightly coupled to UIloadUser(userId = intent.getStringExtra("user_id")?:return)}privatefunloadUser(userId: String){// Network call on main thread — just for illustration
lifecycleScope.launch{try{val user = retrofit.getUser(userId)// Direct view manipulation
findViewById<TextView>(R.id.tvName).text = user.name
findViewById<TextView>(R.id.tvEmail).text = user.email
}catch(e: Exception){
Toast.makeText(this@UserProfileActivity,"Error", Toast.LENGTH_SHORT).show()}}}}
MVC Pros & Cons
Pros
Cons
Familiar to web developers
Activity = View + Controller (God class)
Simple for tiny screens
Untestable without instrumented tests
No boilerplate
Business logic entangled with lifecycle
—
Model changes require Activity to know update order
MVP (Model-View-Presenter)
MVP separates concerns by introducing a Presenter that holds business logic and communicates with the View through an interface. The Activity implements the View interface; the Presenter has no Android imports.
MVVM replaces the Presenter with a ViewModel that survives configuration changes. The View observes state rather than receiving imperative calls. StateFlow or LiveData carries state from ViewModel to UI.
Event handling needs extra care (Channel/SharedFlow)
MVI (Model-View-Intent)
MVI enforces strict unidirectional data flow: the View sends Actions/Intents, a Reducer produces a new immutable State, and the View renders that state. Side effects are handled separately.
// Immutable statedataclassUserProfileState(val isLoading: Boolean =false,val user: User?=null,val error: String?=null)// All possible user actionssealedinterface UserProfileAction {dataclassLoadUser(val userId: String): UserProfileAction
object Retry : UserProfileAction
}// Side effects (one-shot events)sealedinterface UserProfileEffect {dataclassShowSnackbar(val message: String): UserProfileEffect
object NavigateBack : UserProfileEffect
}@HiltViewModelclass UserProfileViewModel @Injectconstructor(privateval getUserUseCase: GetUserUseCase
):ViewModel(){privateval _state =MutableStateFlow(UserProfileState())val state: StateFlow<UserProfileState>= _state.asStateFlow()privateval _effect = Channel<UserProfileEffect>(Channel.BUFFERED)val effect = _effect.receiveAsFlow()funonAction(action: UserProfileAction){when(action){is UserProfileAction.LoadUser ->loadUser(action.userId)
UserProfileAction.Retry ->{val userId = _state.value.user?.id ?:returnloadUser(userId)}}}// Pure reducer-style updateprivatefunloadUser(userId: String){
viewModelScope.launch{// Reduce: loading state
_state.update{reduce(it, LoadingStarted)}getUserUseCase(userId).onSuccess{ user ->// Reduce: success state
_state.update{reduce(it,UserLoaded(user))}}.onFailure{ error ->// Reduce: error state
_state.update{reduce(it,LoadingFailed(error.message))}// Side effect
_effect.send(UserProfileEffect.ShowSnackbar("Failed to load user"))}}}// Explicit reducer function — state transitions are explicit and testableprivatefunreduce(state: UserProfileState, event: UserEvent): UserProfileState =when(event){is LoadingStarted -> state.copy(isLoading =true, error =null)is UserLoaded -> state.copy(isLoading =false, user = event.user)is LoadingFailed -> state.copy(isLoading =false, error = event.message)}}// Fragment sends actions, observes state and effects@AndroidEntryPointclass UserProfileFragment :Fragment(R.layout.fragment_user_profile){privateval viewModel: UserProfileViewModel byviewModels()overridefunonViewCreated(view: View, savedInstanceState: Bundle?){super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch{repeatOnLifecycle(Lifecycle.State.STARTED){
launch { viewModel.state.collect{render(it)}}
launch {
viewModel.effect.collect{ effect ->when(effect){is UserProfileEffect.ShowSnackbar ->
Snackbar.make(view, effect.message, Snackbar.LENGTH_SHORT).show()
UserProfileEffect.NavigateBack ->findNavController().popBackStack()}}}}}
binding.btnRetry.setOnClickListener{
viewModel.onAction(UserProfileAction.Retry)}}privatefunrender(state: UserProfileState){
binding.progressBar.isVisible = state.isLoading
binding.tvName.text = state.user?.name ?:""
binding.tvEmail.text = state.user?.email ?:""
binding.errorGroup.isVisible = state.error !=null}}
MVI Pros & Cons
Pros
Cons
Predictable: single source of truth
More boilerplate than MVVM
State is immutable — no partial updates
Overkill for simple screens
Reducer is a pure function — trivial to unit test
Learning curve for teams new to functional style
Time-travel debugging possible
Action/State explosion in complex screens
When to Choose Which
Pattern
Best For
MVC
Legacy code you're not refactoring yet
MVP
Teams migrating away from MVC, Java-heavy codebases
MVVM
Most modern Android apps — Google's recommended approach
MVI
Complex screens with many interactions, need audit trail, Compose
Key Takeaways
Concept
Summary
MVC on Android
Activity inevitably becomes God class
MVP
Testable Presenter via View interface; manual lifecycle