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 method fetchDataFromApi in HomeViewModel [2].
  • HomeViewModel.fetchDataFromApi makes an asynchronous API call [3], and on completion raises an event called fetchEvent declared in  HomeViewState [4]. The event may or may not include some content. In this case the event content is a custom type ApiResponse which is a Data Class containing a Bool indicating whether the API call succeeded, as well as the data received from the API.
  • HomeView observes the event being changed to triggered [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 the fetchEvent has been processed by calling HomeViewModel.fetchEventConsumed [6].
  • The view model has an opportunity to do any processing needed in the method HomeViewModel.fetchEventConsumed, and then resets the fetchEvent state [7], making it ready to be called again at a later time.
App Architecture Diagram
Jetpack Compose Event Flow

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 as ApiResponse 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.