Android Jetpack Compose View Model Eventing Architecture
A common Android architectural pattern is to consume events in a UI View that are triggered from asynchronous web service code in a View Model. While there are many approaches to solving this problem, I wanted to share the approach I often use. You may have another approach that works even better, but this approach has been useful for me so I thought I'd share it.
In this article I'll walk through how this approach works conceptually, then discuss how I use the eventing components, and finally provide some detailed code examples.
Acknowledgement
I didn't invent this idea. It uses a component authored by Leonard Palm, and open sourced on his GitHub repo here. Props to Leonard!
App Architecture
The app I'm building has the following architecture:
- Jetpack Compose-based UI
- A view model injected into the View using Hilt
- A shared view state updated via MutableStateFlow
- Leonard's compose-state-events library to communicate events from the view model to the view, and signal handled events back to the view model.
The main topic of this post--the event feature--can work just fine with a less complex app architecture (e.g. if you don't use Hilt), but I wanted to demonstrate how I use this technique in a real-world application architecture so I included everything in the repo.
Setting up the app
This app is based on a simple Jetpack Compose UI application template. I created a single UI View called HomeView
, which has a view model named HomeViewModel
injected into it by Hilt.
I won't go through the entire project setup steps since that isn't the main focus of this post. If you'd like to review the code for the project as you read along, you can download it from my GitHub repo here.
The problem we're going to solve
I'm going to demonstrate how to use the eventing architecture by solving a specific UI design objective (reference below diagram):
- A user taps a button [1] in the Jetpack Compose
HomeView
that fires a methodfetchDataFromApi
inHomeViewModel
[2]. HomeViewModel.fetchDataFromApi
makes an asynchronous API call [3], and on completion raises an event calledfetchEvent
declared inHomeViewState
[4]. The event may or may not include some content. In this case the event content is a custom typeApiResponse
which is a Data Class containing aBool
indicating whether the API call succeeded, as well as the data received from the API.HomeView
observes the event being changed totriggered
[5], and reacts by updating the view hierarchy in some way, such as displaying the content received from the API call result in a LazyColumn.- The view signals to
HomeViewModel
that thefetchEvent
has been processed by callingHomeViewModel.fetchEventConsumed
[6]. - The view model has an opportunity to do any processing needed in the method
HomeViewModel.fetchEventConsumed
, and then resets thefetchEvent
state [7], making it ready to be called again at a later time.
Defining the Event
An event in this architecture can be designed to send content to its observer, or simply be an event with no associated content.
- In the view state class below,
initializedEvent
is a simple event that is raised but provides no content to its observer. fetchEvent
is an event that, when raised, provides some object as content. What's provided can be a simple type (Int
,Boolean
, etc.), or can be a user defined type, such asApiResponse
in this example.- Event states are initialized as
consumed
, which is equivalent to say they are idle and not triggered.
data class HomeViewState (
val initializedEvent: StateEvent = consumed,
val fetchEvent: StateEventWithContent<ApiResponse> = consumed(),
// There will usually be other properties in view state as well
val customerList: List<String>? = null
)
Observe changes in event state
The view observes changes to the view state using androidx.lifecycle.flow. The events are simply part of a flow which may contain other objects updated by the HomeViewModel
@Composable
fun HomeView(viewModel: HomeViewModel) {
val viewState: HomeViewState by viewModel.viewState.collectAsStateWithLifecycle()
}
In order to fire code in the view when events are raised, a custom side effect is created in HomeView
that executes view code when fetchEvent
experiences a state change.
@Composable
fun HomeView(viewModel: HomeViewModel) {
val viewState: HomeViewState by viewModel.viewState.collectAsStateWithLifecycle()
EventEffect(
event = viewState.fetchEvent,
onConsumed = viewModel::fetchEventConsumed
) { data ->
Log.d("VIEW", "Event fired with event data: $data")
}
}
Raising the event in the view model
To cause the view's EventEffect
to be fired, HomeViewModel
simply updates fetchEvent
to triggered
. Since this event is defined to pass data payload, it must provides an ApiResponse
object.
fun fetchDataFromApi() {
viewModelScope.launch {
// Simulate receiving data from Web API call after 2 seconds
delay(2000L)
// Simulate reading a response, e.g. from a JSON payload
val data = ApiResponse(true, listOf("Company A", "Company B", "Company C"))
// Save data in view state & raise event with payload
_viewState.update {
it.copy(
customerList = data.customerList,
fetchEvent = triggered(data)
)
}
}
}
Signaling completion to the view model
In the EventEffect
declaration in HomeView
, there was this line I didn't mention before:
onConsumed = viewModel::fetchEventConsumed
The onConsumed
property declares code that is to be called when the raised event has been processed by the event observer. In this case we declared a method fetchEventConsumed
in HomeViewModel
. This method has the opportunity to perform some additional code (e.g. setting a network wait indicator signal to false
), and then resets the event state back to consumed
.
fun fetchEventConsumed() {
// Could add additional logic here
_viewState.update { it.copy(fetchEvent = consumed()) }
}
Download the code
Hopefully this post was helpful, and if you'd like to download and review the code, you can find it here on my GitHub repo.