A Jetpack Compose SharedFlow Tutorial

The previous chapter introduced Kotlin flows and explored how these can be used to return multiple sequential values from within coroutine-based asynchronous code. In this tutorial, we will look at a more detailed flow implementation, this time using SharedFlow. The tutorial will also demonstrate how to ensure that flow collection responds correctly to an app switching between background and foreground modes.

About the project

The app created in this chapter will consist of a user interface containing a List composable. A shared flow located within a ViewModel will be activated as soon as the view model is created and will emit an integer value every two seconds. The Main Activity will collect the values from the flow and display them within the List. The project will then be modified to suspend the collection process while the app is placed in the background.

Creating the SharedFlowDemo project

Launch Android Studio and create a new Empty Compose Activity project named SharedFlowDemo, specifying com.example.sharedflowdemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named ScreenSetup which, in turn, calls a function named MainScreen:

@Composable
fun ScreenSetup() {
    MainScreen()
}
 
@Composable
fun MainScreen() {
    
}

Edit the onCreateActivity() method function to call ScreenSetup instead of Greeting and remove the Greeting call from DefaultPreview.

Next, modify the build.gradle (Module: FlowDemo.app) file to add the Compose view model and Kotlin runtime extensions libraries to the dependencies section:

dependencies {
.
.
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
.
.
}

When prompted, click on the Sync Now button at the top of the editor panel to commit to the change.

Adding a view model to the project

For this project, the flow will once again reside in a view model class. Add this model to the project by locating and right-clicking on the app -> java -> com.example.sharedflowdemo entry in the Project tool window and selecting the New -> Kotlin Class/File menu option. In the resulting dialog, name the class DemoViewModel before tapping the keyboard Enter key. Once created, modify the file so that it reads as follows:

package com.example.flowdemo

import androidx.lifecycle.ViewModel

class DemoViewModel : ViewModel() {
}

Return to the MainActivity.kt file and make changes to access an instance of the view model:

.
.
import androidx.lifecycle.viewmodel.compose.viewModel
.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen()
}

Declaring the SharedFlow

The next step is to add some code to the view model to create and start the SharedFlow instance. Begin by editing the DemoViewModel.kt file so that it reads as follows:

package com.example.sharedflowdemo
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
 
class DemoViewModel  : ViewModel() {
 
    private val _sharedFlow = MutableSharedFlow<Int>()
    val sharedFlow = _sharedFlow.asSharedFlow()
 
    init {
        sharedFlowInit()
    }
 
    fun sharedFlowInit() {
    }
}

When the ViewModel instance is created, the initializer will call the sharedFlowInit() function. The purpose of this function is to launch a new coroutine containing a loop in which new values are emitted using a shared flow.

With the flow declared, code can now be added to the sharedFlowInit() function to launch the flow using the view model’s own scope. This will ensure that the flow ends when the view model is destroyed:

fun sharedFlowInit() {
    viewModelScope.launch {
        for (i in 1..1000) {
            delay(2000)
            _sharedFlow.emit(i)
        }
    }
}

Collecting the flow values

Before testing the app for the first time we need to add some code to perform the flow collection and display those values in a LazyColumn composable. As the values are collected from the flow, they will be added to a mutable list state instance which, in turn, will serve as the data source for the LazyColumn content. We also need to pass a reference to the shared flow down to the MainScreen composable. Edit the MainActivity.kt file and make the following changes:

.
.
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.*
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.SharedFlow
import androidx.compose.ui.platform.LocalLifecycleOwner
.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen(viewModel.sharedFlow)
}
 
@Composable
fun MainScreen(sharedFlow: SharedFlow<Int>) {

    val messages = remember { mutableStateListOf<Int>()}

    LazyColumn {
        items(messages) {
            Text(
                "Collected Value = $it",
                style = MaterialTheme.typography.h3,
                modifier = Modifier.padding(5.dp)
            )
        }
    }
}
 
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    SharedFlowDemoTheme {
        val viewModel: DemoViewModel = viewModel()
        MainScreen(viewModel.sharedFlow)
    }
}

With these changes made we are ready to collect the values emitted by the shared flow and display them. Since the flow collection will be taking place in a coroutine and outside the scope of the MainScreen composable, the launch code needs to be placed within a LaunchedEffect call (a topic covered in the chapter titled “Coroutines and Side Effects in Jetpack Compose”. Add a LaunchedEffect call to the MainScreen composable as follows to collect from the flow:

.
.
import kotlinx.coroutines.flow.collect
.
.
@Composable
fun MainScreen(sharedFlow: SharedFlow<Int>) {
 
    val messages = remember { mutableStateListOf<Int>()}
    val lifecycleOwner = LocalLifecycleOwner.current
 
    LaunchedEffect(key1 = Unit) {
        sharedFlow.collect {
             messages.add(it)
        }
    }
.
.

This code accesses the shared flow instance within the view model and begins collecting values from the stream. Each collected value is added to the messages mutable list. This will cause a recomposition and the new value will appear at the end of the LazyColumn list.

Testing the SharedFlowDemo app

Compile and run the app on a device or emulator and verify that values appear within the LazyColumn as they are emitted by the shared flow. Rotate the device into landscape orientation to trigger a configuration change and confirm that the count continues without restarting from zero:

Figure 1-1

With the app now working, it is time to look at what happens when it is placed in the background.

Handling flows in the background

In our app, we have a shared flow that feeds values to the user interface in the form of a LazyColumn. By performing the collection in a coroutine scope, the user interface remains responsive while the flow is being collected (you can verify this by scrolling up and down within the list of values while the list is updating). This raises the question of what happens when the app is placed in the background. To find out, we can add some diagnostic output to both the emitter and collector code. First, edit the MainViewModel.kt file and add a println() call within the body of the emission for loop:

fun sharedFlowInit() {
    viewModelScope.launch {
        for (i in 1..1000) {
            delay(2000)
            println("Emitting $i")
            _sharedFlow.emit(i)
        }
    }
}

Make a similar change to the collection code block in the MainFragment.kt file as follows:

.
.
LaunchedEffect(key1 = Unit) {
        sharedFlow.collect {
        println("Collecting $it")
        messages.add(it)
    }
}
.
.

Once these changes have been made, display the Logcat tool window, enter System.out into the search bar, and run the app. As the list of values updates, output similar to the following should appear in the Run tool window:

Emitting 1
Collecting 1
Emitting 2
Collecting 2
Emitting 3
Collecting 3
.
.

Now place the app in the background and note that both the emission and collection operations continue to run, even though the app is no longer visible to the user. The continued emission is to be expected and is the correct behavior for a shared flow residing within a view model. It is wasteful of resources, however, to be collecting data and updating a user interface that is not currently visible to the user. We can resolve this problem by executing the collection using the repeatOnLifecycle function.

The repeatOnLifecycle function is a suspend function that runs a specified block of code each time the current lifecycle reaches or exceeds one of the following states:

  • Lifecycle.State.INITIALIZED
  • Lifecycle.State.CREATED
  • Lifecycle.State.STARTED
  • Lifecycle.State.RESUMED
  • Lifecycle.State.DESTROYED

Conversely, when the lifecycle drops below the target state, the coroutine is canceled.

In this case, we want the collection to start each time Lifecycle.State.STARTED is reached and to stop when the lifecycle is suspended. To implement this, modify the collection code as follows:

.
.
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
.
.
LaunchedEffect(key1 = Unit) {
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        sharedFlow.collect {
            println("Collecting $it")
            messages.add(it)
        }
    }
}

Run the app once again, place it in the background and note that only the emission diagnostic messages appear in the Logcat output confirming that the main activity is no longer collecting values and adding them to the

RecyclerView list. When the app is brought to the foreground, the collection will resume at the latest emitted value since replay was not configured on the shared flow.

Summary

In this chapter, we created a SharedFlow instance within a view model. We then collected the streamed values within the main activity and used that data to update the user interface. We also outlined the importance of avoiding unnecessary flow-driven user interface updates when an app is placed in the background, a problem that can easily be resolved using the repeatOnLifecycle function. This function can be used to cancel and restart asynchronous tasks such as flow collection when a target lifecycle state is reached by the containing lifecycle.