Android arch exploration: MVVM + flow + UseCases with a UI gate (combine use case’s output to generate the UI)

Juan Mengual
adidoescode
Published in
8 min readJul 11, 2021

Looking for an architecture which can work fine for simple to medium features based in MVVM, Clean architecture and unidirectional flow of information.

A UI tunnel seen from the inside
You can almost see the UI layer from this side (Photo by Jakob Søby on Unsplash)

We’ve started a phase of experimentation in the team to update the architecture approach that we use for most of the features. We are currently in MVVM with a few details here and there but we aim to create something more detailed which can be used for most screens of our app. We love MVVM and remaining in Google’s side of recommendations, but there is still plenty of room for decisions in there.

The goal of this architectural approach

I have been with an idea roaming around my head for a while, which is to have every UseCase exposing it’s result as a Flow and use combine to merge all the use case’s flows together, so there is one single point in charge of generating the UI data which will be then posted to the UI layer. This point is easy to test and far from side effects. This is what the title claims to be the UI gate (every opportunity to put a name on something is always welcome). Before jumping into detail on how this idea works, the pros and the cons, let take a look to what else this architectural approach has:

  • MVVM + Clean architecture with UseCases
  • Unidirectional flow of information
  • Coroutines + flow
  • One single LiveData (could be an stateFlow) to expose UI data wrapped in a sealed class to represent data state (loading, error, success).
  • An event LiveData to communicate single use events to the UI, like displaying popups to the user, navigating events, permission request, things like that.
  • Every request may fail, so prepare error state and retry mechanisms for all of them

What is a simple to medium complexity feature

We are trying to solve a screen which needs to load some info from the internet to be displayed to the user and, once loaded, the user can interact with that information triggering a different request whose response will produce changes in what is displayed to the user. The arch approach supports having more actions from the user, but we’ll keep it in one for simplicity. This screen should cover a wide range of the screens we usually develop in the team.

These is how our new screen should work:

  • Initial data is loaded from network, and it can fail, so the screen has success/error&retry/loading states.
  • Once the data is loaded, a new button is displayed to trigger an extra request when clicked. As a good request, it can fail, so the UI must display success/error&retry/loading. These state must not hide all the info previously loaded, we want a good experience for the user

This is how it looks on practice:

Show me the code

The code is divided in domain, data and UI packages but it could be separated in modules for better separation of concerns. Let’s take a look to the different layers from to bottom to the top.

The repository

The repo is responsible from communicating with the data sources, which in this case is just network, but could be also db if we had persistence.

This repo will expose flows which will wrap the state the data is at the moment. We’ll use the following sealed class to wrap that state:

sealed class Outcome<T> {
data class Progress<T>(val data: T? = null) : Outcome<T>()
data class Success<T>(val data: T) : Outcome<T>()
data class Failure<T>(val e: Throwable) : Outcome<T>()
}

In a real scenario we’d have a sealed class as well defining which kind of error we had (such as “NetworkFailure”, “InvalidCredentials”, etc)

The flows exposed by the repo represent perfectly the state of any request. Those states will become handy later to generate the UI depending on the state of each request. This is our repository:

class SampleFeatureRepository(private val api: BackendApi) : FeatureRepository {

// Get initial data for the screen
override fun getData() = flow {
// we could get data from persistence layer here
emit(Outcome.loading())
val response = api.getData()
emit(Outcome.success(response.toSampleData()))
}
.catch { emit(Outcome.failure(it)) }
.flowOn(Dispatchers.IO)

// perform action at backend and return response
override fun sendUserAction() = flow {
emit(Outcome.loading())
val response = api.performAction().toActionResponse()
emit(Outcome.success(response))
}
.catch { emit(Outcome.failure<ActionResponse>(it)) }
.flowOn(Dispatchers.IO)
}

To simulate errors and loading times, our BackendApi is not the classic Retrofit API but a fake API. You can take a look here

Use cases to encapsulate all screen functionalities

There are two features in our screen. The first one is to get data from a network source (our Backend API) and display it to the user. The second one is to react to the user tapping a button to trigger a new request which will return updated new information (the count of done actions).

An important note is that every request might fail, so we want a way to retry any use case. For this, we’ve created a base UseCase which wraps all the retry logic and exposing a flow to the upper layer.

/**
* Simple use case exposing result as a flow.
* Result flow will emit null while the action has not been triggered
*/
@ExperimentalCoroutinesApi
abstract class FlowUseCase<T> {

private val _trigger = MutableStateFlow(true)

/**
* Exposes result of this use case
*/
val resultFlow: Flow<T> = _trigger.flatMapLatest {
performAction()
}
/**
* Triggers the execution of this use case
*/
suspend fun launch() {
_trigger.emit(!(_trigger.value))
}

protected abstract fun performAction() : Flow<T>
}

A MutableStateFlow is used as a trigger to launch the logic for the use case. This way, we have a retry mechanism which we can call every time we want. Also, the flatMapLatest will cancel ongoing request when called.

After having this base UseCase, to get the main data for the screen looks like this:

class MainDataUseCase(private val repository: SampleFeatureRepository) : FlowUseCase<Outcome<SampleData>>() {
override fun performAction(): Flow<Outcome<SampleData>> {
return repository.getData()
}
}

And the UseCase to perform the user action is the following:

class ActionUseCase(private val repository: SampleFeatureRepository) : NullableResultFlowUseCase<Outcome<ActionResponse>>() {
override fun performAction() = repository.sendUserAction()
}

You might have noticed that this use case is extending a different base use case. This is because while `MainUseCase` will be always executed to get the main information to display in the screen, this second one is optional, the user might trigger it or might not. Because of our intention of combining all the flows, we need a new base use case type (`NullableResultUseCase`) which will return a flow with null data until the user has triggered the use case. This match the nature of the use case, which is in the end optional for the screen. If you don’t like nulls in Kotlin, we could opt by a more elegant solution (an enum or a Sealed class).
You can take a look to it here

The ViewModel and the UI gate

The ViewModel will be responsible from combining our two use case’s outputs and generate the UI in one single method. The main advantage for this approach is that you can test really easy that method to generate the UI.

class FeatureBViewModel(
private val mainDataUseCase: MainDataUseCase,
private val actionUseCase: ActionUseCase
) : ViewModel() {

/**
* LiveData for UI data and state
*/
val uiLiveData: LiveData<Outcome<UiData>> = combine(mainDataUseCase.resultFlow, actionUseCase.resultFlow) {
outcomeA: Outcome<SampleData>, outcomeB: Outcome<ActionResponse>? ->
generateUiData(outcomeA, outcomeB)
}.asLiveData()

private val _eventLiveData = SingleLiveEvent<SampleFeatureEvent>()

/**
* One time event .
* Used to send consumable events from viewModel to UI (i.e: display toast, or open screen)
* Exposed as non mutable
*
*/
val eventLiveData: LiveData<SampleFeatureEvent> = _eventLiveData

/**
* Trigger request to get data
*/
fun requestData() {
viewModelScope.launch {
mainDataUseCase.launch()
}
}

/**
* Trigger request to perform action at GW
*/
fun onUserActionDone() {
viewModelScope.launch {
actionUseCase.launch()
}
}

/**
* Generate UI state from the different sources
*/
private fun generateUiData(outcomeA: Outcome<SampleData>, actionOutcome: Outcome<ActionResponse>?): Outcome<UiData> {
val actionStatus = actionOutcome?.mapData {
it
.actionNum
}

return outcomeA.mapData { a ->
UiData(a.someData, actionStatus)
}
}
}

This ViewModel main points are:

  • Exposes one single LiveData with all that’s needed to render the UI. Outcome indicates if the whole screen is in loading, error&retry or success state. Data within (UiData in this case), should have everything needed to render the success state. In our case, there is an action which can be also in loading/error/success state, to we represent it with an inner Outcome.
  • Exposes an eventLiveData, for single use events like displaying a toast or open a new screen. It’s not used for now, but might extend it in the future.
  • All of the useCase’s flows are combined together, so the UI is generated in one single point. If there would be more actions that the user can do in this screen, we would have more useCases combined here. Having one single point to do this allows to easily test this method, but it can get complex with a lot of use cases. That’s why the title of the post say simple to medium screens.
    This single method in charge of rendering is inspired by Redux or MVI. Having a single “render” method allows you to easily identify issues and reproduce them. By logging (or storing) render events we can replay them and reproduce issues.
  • Exposes methods to trigger the use cases. Triggering one of the useCases will cause the UI to regenerate, since one of the flows exposed by the use cases will change. Here is the unidirectional flow of information, a user action will trigger the viewModel, which will trigger a useCase, regenerating the UI.
This is how Flow.combine() bytecode looks like (Photo by Dynamic Wang on Unsplash)

UI layer, nothing fancy

Lastly, the UI layer, where we keep it simple and have a fragment listening to the liveData’s. There is normal XML layout now, but it could be migrated to compose. The success/error&retry/loading state logic has been moved to a base fragment for reutilization, but nothing worth showing. You can take a look to the code below.

Wrapping up

You made it to the end, thanks for that. In this experiment, we’ve tried to apply Clean Architecture principles to have a template which can work for most of medium complexity screens. It takes into account the loading/retry states, but not only for the main request but also for other ones. Logic is divided in useCases and each of the layers have a clear responsibility.

It’s not perfect though, the triggers logic is not beautiful, but it’s pretty well hidden inside the base Use cases. Talking about the use cases, we need a little trick with our `NullableResultUseCase` to being able to combine all the results, because combine is only triggered if all the combined flows have emitted something.

There is also something to explore in the future with single shots operations and if a flow might be a good representation for them.

Other issue is when having too much flows to combine, which might end up in a very complex ui generation, but at least it is in one single method (you could still find ways to improve that logic, like move it to a separate class and split the logic even more).

Last, I’d love to know your opinion. Do you like it or do you see any issues with it? This is an exploration and the most eyes on it the better. Thanks for your time, and if you want to take a look to the repo, find it below.

--

--