Created
March 31, 2026 18:06
-
-
Save RohitSurwase/0a2c18c115a15c4de1c5145d476aa9c0 to your computer and use it in GitHub Desktop.
Scaling Android MVI: Taming Fat ViewModels with DelegateVM 🧩
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Prerequisites: This builds on our modernised MVI foundation: Evolving Android MVI: Kotlin Flows, Compose, and Lifecycle Safety. | |
| MVI keeps screen state predictable, but it creates a new problem: Fat ViewModels. | |
| If your app has a complex "Feature Carousel" component used on the Home Screen, the Search Screen, and the Profile Screen, duplicating the loading, error handling, and state logic across three ViewModels violates DRY. You cannot use inheritance because a ViewModel can only extend one base class, and a carousel is just one piece of a screen. | |
| We need Composition. We need to extract the MVI logic of that component into a reusable unit that any ViewModel can plug in. | |
| Introducing AacMviDelegateVM | |
| A DelegateVM is a plain class (not an AndroidViewModel) that encapsulates a full MVI loop (ViewState, ViewEffect, ViewEvent) for a specific, reusable UI component. | |
| It shares code, not state. Each Host ViewModel instantiates its own unique instance of the Delegate. The Host ViewModel funnels the Delegate's state and effects into its own MVI stream, and the View remains completely unaware of the delegation. | |
| Because the Delegate is strictly bound to a single Host ViewModel, it doesn't need to manage its own lifecycle. It simply borrows the viewModelScope of its Host. | |
| 1. The AacMviDelegateVM Base Class | |
| open class AacMviDelegateVM<STATE, EFFECT, EVENT> : ViewModelContract<EVENT> { | |
| private var _delegateScope: CoroutineScope? = null | |
| protected var delegateScope: CoroutineScope | |
| get() = _delegateScope ?: throw UninitializedPropertyAccessException("Call initDelegate() first") | |
| set(value) { _delegateScope = value } | |
| // State: SharedFlow with replay=1 ensures the Host gets the current state instantly | |
| private val _viewStates = MutableSharedFlow<STATE>(replay = 1) | |
| val viewStates: SharedFlow<STATE> get() = _viewStates.asSharedFlow() | |
| protected var viewState: STATE | |
| get() = _viewState ?: throw UninitializedPropertyAccessException() | |
| set(value) { | |
| _viewState = value | |
| delegateScope.launch { _viewStates.emit(value) } | |
| } | |
| // Effects: Channel for one-shot delivery | |
| private val _viewEffects = Channel<EFFECT>(Channel.BUFFERED, BufferOverflow.DROP_OLDEST) | |
| val viewEffects: Flow<EFFECT> get() = _viewEffects.receiveAsFlow() | |
| protected var viewEffect: EFFECT | |
| get() = _viewEffect ?: throw UninitializedPropertyAccessException() | |
| set(value) { | |
| _viewEffect = value | |
| delegateScope.launch { _viewEffects.send(value) } | |
| } | |
| @CallSuper | |
| override fun process(viewEvent: EVENT) { /* Handle events */ } | |
| /** | |
| * Binds the Delegate to the Host ViewModel's scope and wires up the output. | |
| */ | |
| fun initDelegate( | |
| coroutineScope: CoroutineScope, | |
| handleDelegateViewState: (STATE) -> Unit, | |
| handleDelegateViewEffect: (EFFECT) -> Unit, | |
| ) { | |
| _delegateScope = coroutineScope | |
| initViewState() | |
| delegateScope.launch { | |
| viewStates.collect { handleDelegateViewState(it) } | |
| } | |
| delegateScope.launch { | |
| viewEffects.collect { handleDelegateViewEffect(it) } | |
| } | |
| } | |
| @CallSuper | |
| protected open fun initViewState() {} | |
| } | |
| 2. Building the Delegate | |
| Let's build the reusable FeatureCarouselDelegate: | |
| data class CarouselState(val items: List<Item> = emptyList(), val isLoading: Boolean = false) | |
| sealed class CarouselEffect { data class ShowError(val msg: String) : CarouselEffect() } | |
| sealed class CarouselEvent { object Load : CarouselEvent() } | |
| class FeatureCarouselDelegate( | |
| private val repo: Repository | |
| ) : AacMviDelegateVM<CarouselState, CarouselEffect, CarouselEvent>() { | |
| override fun initViewState() { | |
| viewState = CarouselState(isLoading = true) | |
| process(CarouselEvent.Load) | |
| } | |
| override fun process(viewEvent: CarouselEvent) { | |
| when (viewEvent) { | |
| is CarouselEvent.Load -> loadItems() | |
| } | |
| } | |
| private fun loadItems() { | |
| delegateScope.launch { | |
| val data = repo.getFeatures() | |
| viewState = viewState.copy(items = data, isLoading = false) | |
| } | |
| } | |
| } | |
| 3. Wiring it into the Host ViewModel | |
| The Host ViewModel includes the Delegate's state in its own ViewState and uses initDelegate to funnel the data. | |
| data class HomeState( | |
| val carouselState: CarouselState = CarouselState(), | |
| val screenTitle: String = "Home" | |
| ) | |
| class HomeViewModel( | |
| application: Application, | |
| private val carouselDelegate: FeatureCarouselDelegate // Unique instance for this VM | |
| ) : AacMviViewModel<HomeState, HomeEffect, HomeEvent>(application) { | |
| init { | |
| viewState = HomeState() | |
| // Bind the delegate to this ViewModel's lifecycle | |
| carouselDelegate.initDelegate( | |
| coroutineScope = viewModelScope, | |
| handleDelegateViewState = { delegateState -> | |
| // Merge delegate state into screen state | |
| viewState = viewState.copy(carouselState = delegateState) | |
| }, | |
| handleDelegateViewEffect = { delegateEffect -> | |
| // Forward delegate effects | |
| when (delegateEffect) { | |
| is CarouselEffect.ShowError -> viewEffect = HomeEffect.ShowSnackbar(delegateEffect.msg) | |
| } | |
| } | |
| ) | |
| } | |
| override fun process(viewEvent: HomeEvent) { | |
| when (viewEvent) { | |
| is HomeEvent.CarouselAction -> carouselDelegate.process(CarouselEvent.Load) | |
| // Handle Home-specific events... | |
| } | |
| } | |
| // No manual cleanup needed! When HomeViewModel is cleared, | |
| // viewModelScope cancels, which automatically kills all Delegate coroutines. | |
| } | |
| The Takeaway | |
| By combining Unidirectional Data Flow with Composition, we keep ViewModels focused solely on screen-level orchestration. Complex, reusable UI logic lives isolated, tested, and shared in DelegateVMs, without muddying the inheritance tree or creating lifecycle headaches. | |
| Thanks for reading! | |
| Rohit Surwase | |
| If you liked this article, clap clap clap 👏👏👏 as many times as you can. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment