'How to safely (lifecycle aware) .collectAsState() a StateFlow?

I'm trying to follow the official guidelines to migrate from LiveData to Flow/StateFlow with Compose, as per these articles:

A safer way to collect flows from Android UIs

Migrating from LiveData to Kotlin’s Flow

I am trying to follow what is recommended in the first article, in the Safe Flow collection in Jetpack Compose section near the end.

In Compose, side effects must be performed in a controlled environment. For that, use LaunchedEffect to create a coroutine that follows the composable’s lifecycle. In its block, you could call the suspend Lifecycle.repeatOnLifecycle if you need it to re-launch a block of code when the host lifecycle is in a certain State.

I have managed to use .flowWithLifecycle() in this way to make sure the flow is not emmiting when the app goes to the background:

@Composable
fun MyScreen() {

    val lifecycleOwner = LocalLifecycleOwner.current

    val someState = remember(viewModel.someFlow, lifecycleOwner) {
        viewModel.someFlow
            .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
            .stateIn(
                scope = viewModel.viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = null
            )
    }.collectAsState()

}

I find this very "boilerplatey" -there must be something better. I would like to have StateFlow in the ViewModel, instead of Flow that gets converted to StateFLow in the @Composable, and use .repeatOnLifeCycle(), so I can use multiple .collectAsState() with less boilerplate.

When I try to use .collectAsState() inside a coroutine (LaunchedEffect), I obviously get an error about .collectAsState() having to be called from the context of @Composable function.

How can I achieve similar functionality as with .collectAsState(), but inside .repeatOnLifecycle(). Do I have to use .collect() on the StateFlow and then wrap the value with State? Isn't there anything with less boilerplate than that?



Solution 1:[1]

After reading a few more articles, including

Things to know about Flow’s shareIn and stateIn operators

repeatOnLifecycle API design story

and eventually realising that I wanted to have the StateFlow in the ViewModel instead of within the composable, I came up with these two solutions:

1. What I ended up using, which is better for multiple StateFlows residing in the ViewModel that need to be collected in the background while there is a subscriber from the UI (in this case, plus 5000ms delay to deal with configuration changes, like screen rotation, where the UI is still interested in the data, so we don't want to restart the StateFlow collecting routine). In my case, the original Flow is coming from Room, and been made a StateFlow in the VM so other parts of the app can have access to the latest data.

class MyViewModel: ViewModel() {

    //...

    val someStateFlow = someFlow.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = Result.Loading()
    )
    val anotherStateFlow = anotherFlow.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = Result.Loading()
    )
       
    //...
}

Then collected in the UI:

@Composable
fun SomeScreen() {

    var someUIState: Any? by remember { mutableStateOf(null)}
    var anotherUIState: Any? by remember { mutableStateOf(null)}

    LaunchedEffect(true) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            launch {
                viewModel.someStateFlow.collectLatest {
                    someUIState = it
                }
            }
            launch {
                viewModel.anotherStateFlow.collectLatest {
                    anotherUIState = it
                }
            }
        }
    }
}

2. An extension function to alleviate the boilerplate when collecting a single StateFlow as State within a @Composable. This is useful only when we have an individual HOT flow that won't be shared with other Screens/parts of the UI, but still needs the latest data at any given time (hot flows like this one created with the .stateIn operator will keep collecting in the background, with some differences in behaviour depending on the started parameter). If a cold flow is enough for our needs, we can drop the .stateIn operator together with the initial and scope parameters, but in that case there's not so much boilerplate and we probably don't need this extension function.


@Composable
fun <T> Flow<T>.flowWithLifecycleStateInAndCollectAsState(
    scope: CoroutineScope,
    initial: T? = null,
    context: CoroutineContext = EmptyCoroutineContext,
): State<T?> {
    val lifecycleOwner = LocalLifecycleOwner.current
    return remember(this, lifecycleOwner) {
        this
            .flowWithLifecycle(
                lifecycleOwner.lifecycle,
                Lifecycle.State.STARTED
             ).stateIn(
                 scope = scope,
                 started = SharingStarted.WhileSubscribed(5000),
                 initialValue = initial
             )
    }.collectAsState(context)
}

This would then be used like this in a @Composable:

@Composable
fun SomeScreen() {

//...

    val someState = viewModel.someFlow
        .flowWithLifecycleStateInAndCollectAsState(
            scope = viewModel.viewModelScope  //or the composable's scope
        )

    //...
    
}

Solution 2:[2]

Building upon OP's answer, it can be a bit more light-weight by not going through StateFlow internally, if you don't care about the WhileSubscribed(5000) behavior.

@Composable
fun <T> Flow<T>.toStateWhenStarted(initialValue: T): State<T> {
    val lifecycleOwner = LocalLifecycleOwner.current
    return produceState(initialValue = initialValue, this, lifecycleOwner) {
        lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            collect { value = it }
        }
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Pavel Haluza