Evolve Unidirectional Data Flow a.k.a MVI into MVVM + Jetpack Compose

Migrate from the most overrated Android architecture to a simpler MVVM with the help of Jetpack Compose

David Santos
Eureka Engineering
Published in
7 min readDec 6, 2021

--

This is the December 7 article for Eureka’s 2021 Advent Calendar.

Introduction

After the inspiration of reddit and medium posts regarding Unidirectional Data Flow a.k.a MVI, made me open my eyes and decided to try to simplify my coding experience by using MVVM and add Jetpack Compose to the recipe to make it hot 🔥.

Some reasons why it is not advisable to use MVI since it add nothing but complexity described by Gabor Varadi:

It unnecessarily restricts the implementation detail of a screen model to be at most 1 mutable property

This makes it impossible to apply transformations to said individual properties, reducers are restrictive in this regard (and highly complex, as they manage all aspects of the screen, even if they are independent)

As all transformations apply to all properties, this makes changes made to the screen coupled: an update to an EditText triggering a data load can delay the processing of an independent event on an independent view component, causing performance and UX responsivity problems

The single mutable property adds requirement of copying deeply nested objects, that would otherwise not even be needed at all

Both databinding and compose can detect better that “one particular property changed” if other properties don’t trigger the same change on the same observer (you can theoretically export only a subset combined if necessary)

While MVI might be forcing you to build a state machine for the screen where all state is global, this can be overhead when a state machine isn’t actually needed

And most importantly,

combining all data, state, and transient state in a single mutable property makes state persistence hard, as you only want to save/restore the state, and the data is supposed to be asynchronously loaded based on switchMap, but in MVI this always happens inside the reducer, rather than merely a switchMap

Purpose of this article

The main purpose of this article is to evolve the current Unidirectional Data Flow architecture project to a simpler one (MVVM) with the help of Jetpack Compose, so it would be easier to maintain and read for other developers.
Finally test edge cases and try to solve possible problems.

Scope

  • Migration to MVVM architectural pattern
  • Use of Jetpack Compose

Out of scope

  • Network request
  • Error handling
  • Dependency Injection
  • Multi module project
  • Navigation Jetpack

Application specification

You can find a description of the application specification on this article.

Migration to MVVM

Steps performed for the migration.

Remove SharedFlow<Actions> and use ViewModel public methods instead for communication between Fragment and ViewModel. Since events from UI would call ViewModel methods and GreetingAction will also be removed, handleActions is not necessary.

setOnClickListener {
viewModel.greet(nickname?.text.toString())
}

Separate the one StateFlow<State>property into individual properties and combine them with the help of combineTuple to create an immutable state.

private val isLoading = MutableStateFlow(false)
private val successMessage = savedStateHandle.getLiveData("successMessage", "")
private val failureMessage = savedStateHandle.getLiveData("failureMessage", "")
val state = combineTuple(
isLoading,
successMessage.asFlow(),
failureMessage.asFlow()
)
.map { (isLoading, successMessage, failureMessage) ->
GreetingViewState(
isLoading,
successMessage,
failureMessage
)
}

After this change the following code becomes shorter and much simpler

fun greet(nickname: String) = viewModelScope.launch {
if (nickname.isBlank()) {
failureMessage.value = "Please write your nickname"
successMessage.value = ""
} else {
isLoading.value = true
failureMessage.value = ""
successMessage.value = ""
val message = GreetingRepositoryImpl.getMessage(nickname)
infoWorkingIn(
"Greet Action: finished")
isLoading.value = false
successMessage.value = message
}
}

Since now we are using individual properties with savedStateHandle, there is no need to make GreetingState extend from Serializable.

data class GreetingViewState(
val isLoading: Boolean,
val successMessage: String,
val failureMessage: String
)

As bonus, with the recent release of androidx.lifecycle:lifecycle-*:2.4.0 we can clean up this code part

override fun onStart() {
super.onStart()
uiStateJob = lifecycleScope.launch {
viewModel.state.collect { state -> handleState(state) }
}
}
override fun onStop() {
uiStateJob?.cancel()
super.onStop()
}

by using flowWithLifecycle, it will become

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope
.launch {
viewModel.state
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect { state -> handleState(state) }
}
}

Use of Jetpack Compose

Instead of using a full Compose screen by replacing the Fragment, I decided to integrate it into the current Fragment.

I have already created the composables for every UI part and integrate it on the Fragment as follows. You can find the detail implementation of UI composable elements in the repository.

super.onViewCreated(view, savedInstanceState)
view.findViewById<ComposeView>(R.id.compose_view).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { Greeting(viewModel) }
}

DisposeOnViewTreeLifecycleDestroyed composition strategy was used to match compose lifecycle with Fragment lifecycle.

As you can see viewModel is passed to Greeting composable element since it will recompose when state changes, so we don’t need this part anymore:

lifecycleScope.launch {
viewModel.state
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect { state -> handleState(state) }
}

If we want to safe collect flow in Jetpack Compose we need to use Flow.collectAsState which represents the values as State<T> to be able to update Compose UI.

We also need to use Flow.flowWithLifecycle since flow producer is still active and can waste resources when app is on background.

Finally, we need to remember the flow that is aware of the lifecycle with viewModel.state and lifecycleOwner as keys to always use the same flow unless one of the keys change.

After applying all these changes to Greeting composable would like something like this

@Composable
fun Greeting(viewModel: GreetingViewModel, modifier: Modifier = Modifier) {
val lifecycleOwner = LocalLifecycleOwner.current
val stateLifecycleAware = remember(viewModel.state, lifecycleOwner) {
viewModel.state.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
val state by stateLifecycleAware.collectAsState(GreetingViewState())

Since we want to use reusable composables, I have exposed both a stateful and a stateless version of the same Greeting composable. The above Greeting composable is the one which holds the state (stateful), which calls the stateless composable version. If you want to know more about this topic, please read Stateful versus stateless and State hoisting.
Stateless composable’s call inside the stateful composable:

Greeting(
state = state,
onGreetClick = { viewModel.greet() },
onNextClick = onNextClick,
onNickNameChange = { viewModel.onNickNameChange(it) },
modifier = modifier
)
  • state: flow collected as State<T>. Stateless Greeting will recompose the UI affected elements on state changes.
  • onGreatClick: delegates greet button click event to the composable’s caller.
  • onNextClick: delegates next float action button click event to the composable’s caller.
  • onNickNameChange: since we used state hoisting, nickname input field text change event is moved to the composable’s caller which call ViewModel method provoking a change on the state. Once the state is changed the stateless Greeting composable will recompose only the affected UI elements, in this case the nickname input field.
    One of the ways to accomplish the state hoisting is adding a new nickname field into the ViewModel and GreetingViewState.
private val nickname = savedStateHandle.getLiveData("nickname", "")val state = combineTuple(
isLoading,
nickname.asFlow(),
successMessage.asFlow(),
failureMessage.asFlow()
)
.map { (isLoading, nickname, successMessage, failureMessage) ->
GreetingViewState(
isLoading,
nickname,
successMessage,
failureMessage
)
}

Something I wanted to make notice is that we are not using Navigation Compose to keep the article as simple as possible. So we needed to move next button click event to outside of the stateful Greeting composable to be able to navigate with Fragment navigation controller.

setContent {
Greeting(
viewModel = viewModel,
onNextClick = { view.findNavController().navigate(R.id.to_otherFragment) }
)
}

Edge cases

Let’s start the interesting part!

Interruptions

  • Going to background while loading and wait

Thanks to flowWithLifecycle the flow is not being consumed when app is on background, so state is not changed until flow is consumed again when going back to foreground.

  • Rotation while loading

UI is recomposed with the latest state, so no problems here!

  • Navigation to next view (Fragment) while loading

Since we are storing the state into ViewModel and it is not cleared until Fragment is totally destroyed, when remote call is completed, the result will be saved as LiveData value, then going back to the first screen will consume latest state making recompose UI.

Process death

As you can see on the previous code, this time I have decided to use LiveData properties derived from SavedStateHandle for every piece of data that I wanted to keep, convert them as Flow to combine in one view state. This way we have the best of each world: SavedStateHandle + Flow operators.

private val isLoading = MutableStateFlow(false)
private val nickname = savedStateHandle.getLiveData("nickname", "")
private val successMessage = savedStateHandle.getLiveData("successMessage", "")
private val failureMessage = savedStateHandle.getLiveData("failureMessage", "")

val state = combineTuple(
isLoading,
nickname.asFlow(),
successMessage.asFlow(),
failureMessage.asFlow()
)
.map { (isLoading, nickname, successMessage, failureMessage) ->
GreetingViewState(
isLoading,
nickname,
successMessage,
failureMessage
)
}

This allows us to save the state and recompose UI even after process death.
Another option could be use rememberSaveable instead of using a ViewModel to host our state or part of it.

rememberSaveable automatically saves any value that can be saved in a Bundle.

Conclusion

Looks like the code has become simpler and easy to follow up without the different Actions and handleActions logic. We have a less (unnecessary) restricted architecture and less boilerplate code.

Now, every singular property is independent which allows granular updates without the unneeded complexity such as copying deeply nested objects.
With the help of combine, we retain reactive updates and it also allows us to apply operators like debounce() in individual properties instead of apply it to the global state.

I have to mention that we have migrated the Unidirectional Data Flow architecture we had to MVVM but by adding Jetpack Compose, we are using Unidirectional Data Flow. Compose is build off the standard of a UDF and it is expected that this paradigm is adhered to for proper implementation of Compose.

The only difficulty that I could think of this approach is the learning curve of the new paradigm (correctly) coding with Jetpack Compose.

Repository

https://github.com/DavidEure/unidirectional-arch/tree/mvvm_compose

Reference links

--

--