Search code examples
androidkotlinandroid-jetpack-composekotlin-flow

In compose collectAsStateWithLifecycle is not working


Problem:

  • I have used collectAsStateWithLifecycle to collect the flow
  • Now once I initiate the flow and minimize the app still the flow is active
  • According to collectAsStateWithLifecycle flow emission should get paused when app is minimized

ViewModel

@HiltViewModel
class CollectAsStateWithLifeCycleVm @Inject constructor(
    @ApplicationContext private val context: Context,
) : ViewModel() {

    companion object {
        const val INITIAL_VALUE = 0
    }

    private var currentTime = INITIAL_VALUE

    private val _data = MutableStateFlow(0)
    val data = _data.asStateFlow()


    init {
        initiate()
    }

    private fun initiate() {
        viewModelScope.launch {
            while(true){
                delay(1000L)
                println("Flow is active current time ---> $currentTime")
                _data.emit(currentTime++)
            }
        }
    }

    val newTimer = flow {
        while(true){
            delay(1000L)
            println("Flow is active current time ---> $currentTime")
            emit(currentTime++)
        }
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L),0)

}

Composable

@Composable
fun CollectAsStateWithLifeCycleDemo(navController: NavHostController){

    val viewModel: CollectAsStateWithLifeCycleVm = hiltViewModel()
    //val time = viewModel.data.collectAsStateWithLifecycle()
    //val time = viewModel.newTimer.collectAsStateWithLifecycle()
    val time: Int by viewModel.data.collectAsStateWithLifecycle()

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {

        Spacer(modifier = Modifier.height(16.dp))

        Text(
            text = time.toString(),
            fontSize = 30.sp
        )

    }

}

Solution

  • Your code is somewhat contrived, but everything works as intended. Let's take a closer look at what actually happens.

    When the composable is displayed for the first time a new instance of the view model is created. As part of the initialization you start a new coroutine with an infinite loop that does two things:

    1. Emit a new value in the _data flow
    2. Increase the view models's property currentTime by one.

    So far so good, the _data flow now emits a new value every second. That you apply asStateFlow on it doesn't matter for the flow's behavior so let's just ignore that. Now, your composable collects the flow and everything works as intended.

    Until you send the app to the background. The activity's lifecycle is no longer in the minimum required state of STARTED so collectAsStateWithLifecycle immediately stops collecting the flow (there are no more screen updates). The StateFlow registers that there are no more collectors and won't send any more updates. The MutableStateFlow, however cannot be stopped in that sense, even though it counts as a hot flow. That is because it isn't even active in the first place: There is no coroutine running it, no coroutine scope is involved. Only when some outside code, outside of the control of the flow, decides to set the value property of the flow it produces a new value. Since no collectors are subscribed, no one will be updated with the new values, though.

    But there are still coming new values in, every second! That's because the coroutine you launched in the view model's init block is still running - in an infinite loop. That's not going to stop just because some unrelated object had some lifecycle changed. The infinite loop runs as long as the scope that it is launched in is still active. Since you chose the viewModelScope that's for the entire lifetime of the view model. As an aside: The view model's lifecycle isn't bound to the lifecycle of an activity. That the app is in the background now isn't even notices by the view model. That means the infinite loop continues to perform steps 1. and 2. each second. Step one just updates a flow that nobody cares about anymore, step two, however increases the counter.

    That's the reason why your code doesn't stop counting, even when the app is in the background. The counting has nothing to do with the flow, it is done by the coroutine you launched. And since you do not stop that, so doesn't stop the counting. When your put your app in the foreground again you will see the result of what the coroutine did in the background, not what the flow did that you stopped.

    But let's take a look at the other flow you provided, the newTimer flow. Now, this is a cold flow. Other than the MutableStateFlow it will produce its own values, but it needs to be actively run in a coroutine that collects its values. Without someone collecting it there is nothing happening. But that's ok, because by calling stateIn you convert this cold flow to a hot flow that is now running n its own, there is no collector needed! This works because you provide it with the viewModelScope. It is used internally by the just created StateFlow to launch a croutine and keeps the underlying flow running, even without any collectors of the StateFlow. The only restriction is the sharing strategy you provided with SharingStarted.WhileSubscribed(5000L). This means the StateFlow will only run the upstream flow in a coroutine (and therefore produce new values) if the StateFlow itself has a collector.

    If you change your composable to collect the newTimer flow now (you have some commented out line in the composable to do this), the StateFlow will immediately start the upsteam flow and you get a new value every second. When the app is then sent to the background the collectAsStateWithLifecycle will immediately stop collecting the flow, and the StateFlow will stop the upstream flow. That is, it will in five seconds, because that's the sharing strategy you provided: WhileSubscribed(5000L) means it will even continue to run the upstream flow even when there are no more subscribers, but just for five seconds. This is useful to bridge configuration changes, for example: If the user rotates the device the entire activity is destroyed and recreated (remember, the view model, and therefore also this flow, is not affected by this). This would cause the StateFlow to stop and immediately resume the underlying flow. By using this sharing strategy you give the activity five seconds time to revive, and only if the activity is not back by that time, then the upstream flow is stopped.

    Since the counting is actually done by the flow here and not by some unrelated coroutine, collectAsStateWithLifecycle will work as intended and stop the counting. Five seconds after the app was minimized, that is.

    There is just one piece of the puzzle left: If you try this, you will see in the log that the counting still continues, way beyond five more seconds. And sure enough, when you put the app in the foreground again, the counter looks as it never stopped. Well, the counter did never stop. But that is no fault of the flow, it's because of the step 2. from the first flow that I mentioned in the beginning: That increases the property currentTime by one. Not some local variable, the same property you use in your newTimer flow as well. You actually have two different counters running at the same time which you will notice when you observe the log carefully: The log messages come two at a time, and even the UI increments in steps of two.

    Just don't call initiate in the init block, and all will be well.