Real-time lifecycle-aware updates in Jetpack Compose

This is a journey into real-time updates, flows creation, cancelation, and proper lifecycle scoping. We’ll be exploring all of this using two examples:

1. Imitating podcast download progress. (on the right)
2. Imitating currency updates for visible items. (on the left)

Podcast downloads

We’re using getPodcasts() to populate list of podcasts, which we then put into our _podcasts, which is a MutableStateFlow. We’ll be observing podcasts from our UI layer to build a LazyColumn of cards. downloadQueue map will be used to store active downloads.

Observes viewModel.podcasts containing with the help of .collectAsState(). This means that list of cards will be diffed & recomposed each time the value of viewModel.podcasts changes.

Whenever user taps on the download icon, we invoke the onDownloadPodcastClicked(), which we’ll declare in the next step in the viewModel.

Composable that contains podcast title & download status.

Let’s add these 3 functions into the PodcastsViewModel.

  1. Checks whether downloadQueue already has specified podcastId inside. If it does - we do nothing, since this means that we already have an active download for that podcast.
  2. Creates the download flow using provideDownloadFlow():Flow<Int> to imitate the download & puts it in our downloadQueue map using podcastId as a key.
  3. Starts observation of the download progress by calling observeDownload().
  1. Creates a flow which emits random numbers in range between 10 & 25 representing the download percentage in 0.5 second intervals.
  2. Whenever progress is 100 — it removes itself from the downloadQueue, and cancels the producing flow.
  1. Observes the provided downloadFlow, and on each progress change updates the _podcasts list. Since we are observing podcasts from the UI layer, compose diffs the changes and recomposes the parts of UI whose values have been changed.
  2. This flow will be automatically canceled whenever the viewModel will be cleared, since we are launching it in viewModelScope . This is the behaviour we’d usually want here, since we don’t care whether the card tapped is still visible, or user scrolled somewhere.

This results into the progress bar updates, and whenever progress hits 100 — we change the icon+ colour to indicate that podcast download is finished.

I’d advice to use WorkManager if you want to download files reliably, without scoping to screen/logical flow of screens.

Currency updates

Logic is similar to PodcastsViewModel, except we’ll use map<Int, Job> instead of map<Int, Flow<Int>>.

We want to subscribe the card to updates when it becomes visible to user, and unsubscribe + cancel the flow whenever card goes outside of the visible screen bounds.

Having job here allows us to stop the currencyPriceUpdateFlow whenever we want, since it’s bound to the coroutineScope it’s being launched in.

It’s same as PodcastsScreen, apart from different data source & composable inside. We’ll be invoking the viewModel.onCardActive() whenever the card is visible, and viewModel.onCardDisposed() when it’s outside of the visible area.

  • LaunchedEffect(Unit) is invoked when composable is being composed for the first time. That’s exactly where we want the subscription to price updates to start.
  • DisposableEffect(Unit) is invoked when the composable is being outside of visible screen bounds. We’ll use this callback to cancel the subscription.

Composable that contains currency title, current price & animation logic.

Let’s add four functions to the viewModel:

  1. Checks if producers already have the specified currencyId subscribed.
  2. Creates currencyPriceUpdateFlow that emits currency price updates.
  3. Creates currencyUpdateJob, which is being used as a scope to launch the currencyUpdateFlow in.
  4. Adds the job in producers map using currencyId as a key.

When the card is not visible to the user — we want to unsubscribe from the updates, and cancel the flow.

Creates a flow that emits random numbers in random intervals of time.

Observes the flow. On each emission we update the _currencyPrices, which results into UI diffing, recomposing & triggering animations that indicate whether the price went up or down, from the last known price value.

Lack of proper lifecycle-awareness

We are subscribing, unsubscribing, everything works just fine. But there is a subtle issue. Remember the scope we’ve used to launch the flow collection in? — viewModelScope. And that’s the problem.

Whenever we put the app in background by switching to a different app, or switching the screen off, price updates are still coming. UI won’t be recomposed in this scenario, but producers are still active and emit new values even when we can’t show them to the user.

Let’s look on what we have to do in order to fix this.

Currency updates with proper lifecycle-awareness

We’ve removed the flow collection logic from the viewModel, since we don’t want to use the viewModelScope anymore.

Invoked when we have a new price incoming for the currency updates flow. Mutates the list, updates it and updates the currencyPrices.

The only thing worth mentioning here is that we provide the currencyUpdateFlow from the viewModel for each item visible, for samples sake.

  1. lifecycleOwner — our current composable lifecycle. Using it will allow us to properly pause producers launched in it, when app goes to background.
  2. lifecycleAwareCurrencyPriceFlow — is a modified currencyPriceUpdateFlow, scoped to lifecycle of this composable. Achieved using .flowWithLifecycle().
  3. LaunchedEffect(Unit) is used to collect the flow updates. The beauty of it allows us to not worry about coroutineScope cancelation anymore, since:

When LaunchedEffect enters the composition it will launch block into the composition’s CoroutineContext. The coroutine will be cancelled and re-launched when LaunchedEffect is recomposed with a different keys. The coroutine will be cancelled when the LaunchedEffect leaves the composition.

This was written when compose was in 1.0.0-beta04.

Full example can be found here

Android Developer

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store