How to Implement MVI with Delegates on Android

Easy MVI implementation without all the unnecessary extra complexity…

ilyas ipek
Teknasyon Engineering
5 min readJan 31, 2024

--

In most MVI blogs, I see Redux-like implementations with a lot of unnecessary complexity and tons of boilerplate code that won’t be useful for most screens. (I’m talking about ActionProcessors & Reducers)

In this blog, I will show you a simpler implementation that we use in Getcontact for small/mid-size screens.

Note: You might need more complex solutions when you have complex screens.

What is MVI?

MVI is an architecture that enforces unidirectional flow, meaning data flows in one direction usually Intent/Action -> Model/State -> View/UI. This makes the data flow predictable and easier to debug 🪲

Here is how it works

1- ViewModel hosts the UiState and initializes it with the default state.
2- View (Screen/Dialog) observes the UiState
3- The user triggers an Intent/Action
4- ViewModel handles the Intent/Action and Manipulates the UiState causing the View change.
5- ViewModel can also fire a one-time event called SideEffects like showing a Toast or navigating to another screen.

Note: We can use a plain class instead of a ViewModel but the flow will be the same.

Implementation 🔥

Instead of binding the MVI to our BaseViewModel, we can create an MVI interface with a default implementation (see composition vs inheritance). This allows you to use it as a delegate without coupling your BaseViewModel to MVI. 😎

1- Let's start with the interface…

interface MVI<UiState, UiAction, SideEffect> {
val uiState: StateFlow<UiState>
val sideEffect: Flow<SideEffect>

fun onAction(uiAction: UiAction)

fun updateUiState(block: UiState.() -> UiState)

fun updateUiState(newUiState: UiState)

// Another option would to add the extention on ViewModel
// ViewModel.emitSideEffect(effect: SideEffect)
// to make it easier to use if you're only using it in ViewModel
//
// or just leave this responsibility to the MVIDelegate to
// get a CoroutineScope. see: https://proandroiddev.com/lighten-mvi-architecture-delegate-responsibilities-to-new-components-7ea27ea54021
// fun emitSideEffect(effect: SideEffect)
fun CoroutineScope.emitSideEffect(effect: SideEffect)
}

2- Now we need a good implementation to use it as a Delegate to our MVI interface…

class MVIDelegate<UiState, UiAction, SideEffect> internal constructor(
initialUiState: UiState,
) : MVI<UiState, UiAction, SideEffect> {

private val _uiState = MutableStateFlow(initialUiState)
override val uiState: StateFlow<UiState> = _uiState.asStateFlow()

private val _sideEffect by lazy { Channel<SideEffect>() }
override val sideEffect: Flow<SideEffect> by lazy { _sideEffect.receiveAsFlow() }

override fun onAction(uiAction: UiAction) {}

override fun updateUiState(newUiState: UiState) {
_uiState.update { newUiState }
}

override fun updateUiState(block: UiState.() -> UiState) {
_uiState.update(block)
}

override fun CoroutineScope.emitSideEffect(effect: SideEffect) {
this.launch { _sideEffect.send(effect) }
}
}

fun <UiState, UiAction, SideEffect> mvi(
initialUiState: UiState,
): MVI<UiState, UiAction, SideEffect> = MVIDelegate(initialUiState)

- We’re using Channels for SideEffects cuz we want the effect to be handled only once.

- We’re using MutableStateFlow’s update function which provides a thread-safe way to update the uiState value. See Atomic Updates on MutableStateFlow.

- We’re enabling the delegation only through mvi() function instead of the MVIDelegate() by making its constructor internal (your classes should be in a different module tho). This is easier to use and allows future implementation swaps without affecting the whole app.

3- Now we’re all set, let’s use it in a screen…

// ----------------- Screen Contract File ----------------------
// Creating a contract will make naming State/Actions/Effects shorter & easier
// plus, it's easier to view/change all the screen's components in one place.
// If you don't like this approach, you can remove the interface and
// just name the classes separately, like ProfileUiState, etc. But note that
// this approach can get messy with longer names, like ConfirmPaymentWithoutCardUiState.
interface ProfileContract {
data class UiState(val count: Int)

sealed interface UiAction {
object OnIncreaseCountClick : UiAction
object OnDecreaseCountClick : UiAction
}

sealed interface SideEffect {
object ShowCountCanNotBeNegativeToast : SideEffect
}
}

// ----------------------- ViewModel File -----------------------
// import all of the contract members
import xxx.ProfileContract.*

class ProfileViewModel : ViewModel(),
MVI<UiState, UiAction, SideEffect> by mvi(initialUiState()) {
override fun onAction(uiAction: UiAction) {
when (uiAction) {
UiAction.OnIncreaseCountClick -> increaseCount()
UiAction.OnDecreaseCountClick -> onDecreaseCountClick()
}
}

private fun increaseCount() {
updateUiState { copy(count = count + 1) }
}

private fun onDecreaseCountClick() {
if (uiState.value.count > 0) {
updateUiState { copy(count = count - 1) }
} else {
viewModelScope.emitSideEffect(SideEffect.ShowCountCanNotBeNegativeToast)
}
}
}

private fun initialUiState() = UiState(age = 18)

// ----------------------- Screen File -----------------------
import xxx.ProfileContract.*

@Composable
fun ProfileScreen() {
val vm = /*...*/
val uiState by vm.uiState.collectAsState()
ProfileScreen(
uiState = uiState,
sideEffect = vm.sideEffect,
onAction = vm::onAction
)
}

@Composable
fun ProfileScreen(
uiState: UiState,
sideEffect: Flow<SideEffect>,
onAction: (UiAction) -> Unit,
) {
val context = LocalContext.current

LaunchedEffect(sideEffect) {
sideEffect.collect {
when (it) {
SideEffect.ShowCountCanNotBeNegativeToast -> {
Toast.makeText(context, "Count can't be less than 0", Toast.LENGTH_SHORT).show()
}
}
}
}

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Count: ${uiState.count}",
color = Color.Black,
)
Row(
modifier = Modifier.padding(top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = { onAction(UiAction.OnIncreaseCountClick) }) {
Text("Increase")
}
Button(onClick = { onAction(UiAction.OnDecreaseCountClick) }) {
Text("Decrease")
}
}
}
}

We’ve created a stateful ProfileScreen that accesses the ViewModel and a stateless component that accepts the state, onAction, sideEffect
This allows screen Preview and testing without needing a ViewModel.

Finally, as sideEffect collection is consistent across screens, so let’s implement a simpler, lifecycle-aware function for it.

@Composable
fun <SideEffect> CollectSideEffect(
sideEffect: Flow<SideEffect>,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = Dispatchers.Main.immediate,
onSideEffect: suspend CoroutineScope.(effect: SideEffect) -> Unit,
) {
LaunchedEffect(sideEffect, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(minActiveState) {
if (context == EmptyCoroutineContext) {
sideEffect.collect { onSideEffect(it) }
} else {
withContext(context) {
sideEffect.collect { onSideEffect(it) }
}
}
}
}
}


@Composable
fun ProfileScreen(/*...*/) {
// use it instead of the privous usage
CollectSideEffect(sideEffect) { when (it) { /*...*/ } }
}

It’s important to use Dispatchers.Main.immediate to make sure that the effects will be processed immediately, for example, if you use Dispatchers.Main you might lose some events in very rare scenarios since the event is scheduled in a queue to run when the thread is available.

Learn more about the event loss from this video by Philipp Lackner

Conclusion

There are a lot of other complicated MVI implementations but I think this is a very easy and reliable one for most of the use cases.

What do you think about it? Any suggestions to improve on it? I hope you loved the blog ❤️

Thanks & bye ❤️

--

--

Android developer @teknasyon, writes about Android development and productivity.