MVI with Android Compose on a real example

Alex Zaitsev
4 min readDec 6, 2021

--

Photo by Dropped Magazine on Unsplash

To make it as short as possible I won’t waste the symbols and describe what is MVI in general. However I will explain in details how do I understand it and how I implemented MVI in Unstoppable Domains App.

Short intro

The main actors in my MVI are:

  • ViewModel (Model)
  • Composable function (View)
  • View Contract (Intent)

First two are clear so let’s start from last one.

View Contract

So, the contract between ViewModel and Composable can consist of:

  • ViewState
  • ViewEvent
  • ViewAction

ViewState flows from ViewModel to Composable. It describes what screen state should be displayed right now.

ViewEvent also flows from ViewModel to Composable. These are one-time events like showing Snackbar or Dialog, etc.

ViewAction flows from Composable to ViewModel. This is the only legal way in my implementation to tell something to ViewModel from a Composable.

Now let’s look to same examples of how these can be implemented.

sealed class DetailsViewState {
object Default : DetailsViewState()
data class DataReady(
val domain: Domain,
val wallet: Wallet?
) : DetailsViewState()
}

sealed class DetailsViewEvent {
data class Message(val message: UiMessage) : DetailsViewEvent()
}

sealed class DetailsViewAction {
data class GetData(val domain: Domain) : DetailsViewAction()
}

As we see all the View* classes described above are sealed classes. This is very convenient, I’ll show usage example later.

I keep them in the single file Contract.kt. The rule is simple: one ViewModel — one Contract. Now to get understanding of what can be done on the screen it’s enough just to open that file. As we see, there are 2 ViewState, 1 ViewEvent, and 1 ViewAction.

ViewModel

Now let’s see the example of BaseViewModel:

abstract class BaseViewModel<ViewState, ViewEvent, ViewAction> : ViewModel() {

protected abstract val initialViewState: ViewState
protected abstract fun processAction(action: ViewAction)

protected var lastViewState: ViewState = initialViewState

private val _viewState = MutableStateFlow(initialViewState)
val viewState = _viewState.asStateFlow()

private val _viewEvents = Channel<ViewEvent?>(Channel.BUFFERED)
val viewEvents = _viewEvents.receiveAsFlow()

private val _viewActions = Channel<ViewAction>(Channel.BUFFERED)

init {
launch {
_viewActions.consumeEach { action ->
processAction(action)
}
}
}

open protected fun updateViewState(state: ViewState) {
_viewState.value = state
lastViewState = state
}

protected fun sendEvent(event: ViewEvent) = launch {
_viewEvents.send(null)
_viewEvents.send(event)
}

fun applyAction(action: ViewAction) = launch {
_viewActions.send(action)
}

}

How the simple implementation can look like:

class DetailsViewModel : BaseViewModel<DetailsViewState, DetailsViewEvent, DetailsViewAction>() {

override val initialViewState = DetailsViewState.Default

override fun processAction(action: DetailsViewAction) = when (action) {
is DetailsViewAction.GetData -> getData(action.domain)
}
private fun getData(domain: Domain) = launch {
// 1. Get the data.
// 2. Send the event in case of error or update the state in case of success (below).
val state = DetailsViewState.DataReady(domain, wallet)
updateViewState(state)
}
}

So, all the elements of View Contract are represented as flows / channels. _viewActions is the channel that ViewModel listens itself. It’s implemented as a channel to be able so support a lot of actions from different Composables at the same time.

viewState and viewActions are the flows that view subscribes to.

Composable

Now let’s see how we can use that from our Composable functions and how the code can be organised.

On the top level, there is a composable called *Screen. This composable is called from the Composable Navigation (as the app is written on pure Compose).

@Composable
fun DetailsScreen(
snackbarController: SnackbarController,
viewModel: DomainDetailsViewModel = getViewModel(),
domain: Domain,
onNavIconClicked: () -> Unit
) {
OnActive {
val action = DetailsViewAction.GetData(domain)
viewModel.applyAction(action)
}
EventsProcessor(snackbarController, viewModel)
Content(snackbarController, viewModel, onNavIconClicked)
}

How the ViewEvent is processed:

@Composable
private fun EventsProcessor(
snackbarController: SnackbarController,
viewModel: DetailsViewModel
) {
val event: DetailsViewEvent? = viewModel.viewEvents.collectAsState(initial = null).value
return when (event) {
is DetailsViewEvent.Message -> ShowSnackbar(
message = event.message,
snackbarController = snackbarController
)
null -> {
}
}
}

So we subscribe to the flow, collect it as a state and use when expression to properly handle all the possible cases.

How the ViewState is processed:

@Composable
private fun Content(
snackbarController: SnackbarController,
viewModel: DetailsViewModel,
onNavIconClicked: () -> Unit
) {
val state: DetailsViewState? = viewModel.viewState.collectAsState(initial = null).value
return when (state) {
null, DetailsViewState.Default -> {
}
is DetailsViewState.DataReady ->
DetailsDataReadyViewState(snackbarController, state, onNavIconClicked)
}
}

Pretty similar to the previous flow. Collect and use when expression to handle all the possible cases.

Important! When should be an expression, not a statement. Only in this case compiler will check if all the options are handled.

In my app every ViewState has its own composable.

And the last point here — how to use ViewAction . In your Composable:

val action = DetailsViewAction.GetData(domain)
viewModel.applyAction(action)

Packages structure

screens (root for all the screens)

domain (a set of screens)

— — details (particular screen)

— — — view (package that holds Composables for this screen)

— — — — DomainDetailsScreen.kt

— — — — DomainDetailsDataReadyViewState.kt(if there are a lot of states they can be combined in a dedicated package states )

— — — DomainDetailsViewModel.kt

— — — Contract.kt

Such structure allows to have as many screens as needed. It’s understandable and easy to navigate.

Conclusion

That’s it! In this small article we covered how MVI can be implemented with an Android Compose. I find this presentational pattern really convenient as it allows to separate code and to write unit tests for any part of your view layer: ViewModel or Composables.

Happy coding! Follow me on Twitter.

--

--

Alex Zaitsev

Migrating to https://alexzaitsev.substack.com, follow me there! #android #mobile #kmp #kmm #kotlin #multiplatform #flutter #crossplatform