Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save RohitSurwase/0a2c18c115a15c4de1c5145d476aa9c0 to your computer and use it in GitHub Desktop.

Select an option

Save RohitSurwase/0a2c18c115a15c4de1c5145d476aa9c0 to your computer and use it in GitHub Desktop.
Scaling Android MVI: Taming Fat ViewModels with DelegateVM 🧩
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