Search code examples
androidkotlinandroid-jetpack-composekotlin-flow

How to update a flow on interval when app is in foreground and not when in background


I have a flow in a view model which emits the user's location every 15 seconds

    val locations = MutableStateFlow<List<StoreLocation>>(emptyList())

    private val _locations = MutableStateFlow<List<SortableLocation>>(emptyList())
    val locations: StateFlow<List<SortableLocation>>
        get() = _locations.asStateFlow()

    private val userLocation: StateFlow<Location?> = flow {
        while (currentCoroutineContext().isActive) {
            Timber.v("Updating user location")
            emit(getLocation())
            delay(15000L)
        }
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5000L),
        null
    )

    init {
        viewModelScope.launch {
            combine(userLocation, cachedLocations) { androidLocation, cachedLocations ->
                Timber.v("Updating sortable locations")
                _locations.value = getSortedLocations(androidLocation, cachedLocations)
            }.collect()
        }
    }

    fun updateLocations() {
        viewModelScope.launch {
            Timber.v("Updating sortable locations")
            val androidLocation = getLocation()
            val cachedLocations = cachedLocations.value
            _locations.value = getSortedLocations(androidLocation, cachedLocations)
        }
    }

In my ui layer, I collect with lifecycle.

@Composable
fun Screen() {
    val locations by viewModel.locations.collectAsStateWithLifecycle()
}

When the app is backgrounded the while loop in the userLocation flow continues to run. How can I update the user location every 15 seconds using flows but only when the app is foregrounded? Do I need to use a Lifecycle scope in the ui layer?


Solution

  • You declared locations twice, one as a MutableStateFlow and the other as a StateFlow. The first doesn't seem to be relevant for your problem since it is not read anywhere. Its value is only set in updateLocations which seems the purpose of that function. In the following I will therefore just ignore both, the MutableStateFlow and updateLocations.

    There are sill multiple issues left with your flow handling. What most likely causes your problem, though, is that you collect flows in the view model. Flows should only be transformed in the view model, not collected.

    The following should suffice:

    val locations: StateFlow<List<SortableLocation>> =
        combine(userLocation(), cachedLocations) { androidLocation, cachedLocations ->
            Timber.v("Updating sortable locations")
            getSortedLocations(androidLocation, cachedLocations)
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList(),
        )
    
    private fun userLocation(delay: Duration = 15.seconds): Flow<Location> = flow {
        while (true) {
            Timber.v("Updating user location")
            emit(getLocation())
            delay(delay)
        }
    }
    

    Now you only have one StateFlow. Although you should only expose StateFlows in the view model, they should be just created at the latest possible moment. This way they can be more easily stopped when they are not needed. userLocation therefore now only returns a regular cold flow.

    Also, userLocation is now a function. This way it is a bit more clear that each time you access userLocation you get a new flow. This would also happen with your previous code if the flow would have been collected multiple times. That's how cold flows work:

    The flow being cold means that the block [from the flow builder] is called every time a terminal operator is applied to the resulting flow.

    But you would only want the block be executed once. That's made more clear by having a function return the flow instead of just saving it in a property. In addition you can now parameterize the flow, as I did with the delay parameter.

    Note that you do not need to check for isActive in your while loop. That's already done by emit and delay.

    And finally you should directly assign the StateFlow to its property, do not use get() or you will create a new StateFlow each time the property is accessed. That's not what you want. It should always be the same flow, just the content will change.

    Now when your collector in the UI stops collecting (as it does when your app is sent to the background, thanks to collectAsStateWithLifecycle), the StateFlow will after 5 seconds stop all upstream flows. That is the combined flow, which in turn will cancel its upstream flows. This will make the delay instantly throw a CancellationException which will interrupt your while loop and stop the flow.

    As soon as the StateFlow is collected again, it restarts its upstream flows, so the combined flow is executed again which in turn calls userLocation() and your while loop is running again, reporting the location every 15 seconds.