A safer way to collect data streams from Android UIs

In the Android app Kotlin streams usually collected from the user interface to display data updates on the screen. However, when collecting these flows of data, you should ensure that you do not have to do more work than is necessary, waste resources (both CPU and memory), or allow data to leak when the view goes into the background.

In this article, you will learn how the API Lifecycle.repeatOnLifecycle and Flow.flowWithLifecycle protect you from wasting resources and why it is better to use them by default for collecting data streams from the user interface.

… … …

Inefficient use of resources

Recommended provide API Flow from the lower levels of your application’s hierarchy, regardless of the implementation details of the data flow producer. However, you should also collect them safely.

Cold flow supported channel or using buffer operators such as buffer, conflate, flowOn or shareIn, not safe to collect using some of the existing APIs like CoroutineScope.launch, Flow .launchIn or LifecycleCoroutineScope.launchWhenXunless you manually cancel the Job that started the coroutine when the activity goes into the background. These APIs will keep the standard stream producer active while it is emitting items to the buffer in the background, thus wasting resources.

Note: A cold stream is a type of stream that executes a block of producer code on demand when data needs to be collected for a new subscriber.

For example, consider this thread which issues location updates with callbackFlow:

// Implementation of a cold flow backed by a Channel that sends Location updates
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            try { offer(result.lastLocation) } catch(e: Exception) {}
        }
    }
    requestLocationUpdates(createLocationRequest(), callback, Looper.getMainLooper())
        .addOnFailureListener { e ->
            close(e) // in case of exception, close the Flow
        }
    // clean up when Flow collection ends
    awaitClose {
        removeLocationUpdates(callback)
    }
}

Note: Inside callbackFlow uses channelwhich is conceptually very similar to queue locks and has a default capacity of 64 elements.

Collecting this stream from the UI using any of the aforementioned APIs ensures locations are passed even though the view does not render them in the UI! See example below:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Collects from the flow when the View is at least STARTED and
        // SUSPENDS the collection when the lifecycle is STOPPED.
        // Collecting the flow cancels when the View is DESTROYED.
        lifecycleScope.launchWhenStarted {
            locationProvider.locationFlow().collect {
                // New location! Update the map
            } 
        }
        // Same issue with:
        // - lifecycleScope.launch { /* Collect from locationFlow() here */ }
        // - locationProvider.locationFlow().onEach { /* ... */ }.launchIn(lifecycleScope)
    }
}

lifecycleScope.launchWhenStarted pauses execution of the coroutine. New locations are not processed, but the manufacturer callbackFlow nevertheless continues to send locations. Using the API lifecycleScope.launch or launchIn even more dangerous because the view continues to use locations even though it is in the background! Which could potentially cause your application to crash.

To fix this issue with these APIs, you will need to manually cancel the data collection when the view goes into the background to cancel callbackFlow and avoiding a location provider emitting items and wasting resources. For example, you can do something like the following:

class LocationActivity : AppCompatActivity() {

    // Coroutine listening for Locations
    private var locationUpdatesJob: Job? = null

    override fun onStart() {
        super.onStart()
        locationUpdatesJob = lifecycleScope.launch {
            locationProvider.locationFlow().collect {
                // New location! Update the map
            } 
        }
    }

    override fun onStop() {
        // Stop collecting when the View goes to the background
        locationUpdatesJob?.cancel()
        super.onStop()
    }
}

This is a good solution, but it’s boilerplate code, friends! And if there is a general truth about Android developers, then it is that we absolutely hate writing boilerplate code. One of the biggest benefits of not writing boilerplate code is that there is less chance of making a mistake with a little bit of code!

Lifecycle.repeatOnLifecycle

Now that we’ve come to a consensus and know where the problem lies, it’s time to come up with a solution. The solution should be 1) simple, 2) friendly or easy to remember / understand, and more importantly, 3) safe! It should work for all use cases, regardless of the details of the flow implementation.

Without further ado, the API you should be using is Lifecycle.repeatOnLifecycleavailable in the library lifecycle-runtime-ktx

Note: These APIs are available in the library lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 or later.

Take a look at the following code:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Create a new coroutine since repeatOnLifecycle is a suspend function
        lifecycleScope.launch {
            // The block passed to repeatOnLifecycle is executed when the lifecycle
            // is at least STARTED and is cancelled when the lifecycle is STOPPED.
            // It automatically restarts the block when the lifecycle is STARTED again.
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Safely collect from locationFlow when the lifecycle is STARTED
                // and stops collection when the lifecycle is STOPPED
                locationProvider.locationFlow().collect {
                    // New location! Update the map
                }
            }
        }
    }
}

repeatOnLifecycle Is a suspend function that takes Lifecycle.State as a parameter that is used to automatically create and start a new coroutine with the block passed to it when the lifecycle reaches this state, and canceling the current coroutine executing this block when the lifecycle falls below state

This eliminates the need for boilerplate code, since the code to cancel the coroutine when it is no longer needed is automatically executed by the function repeatOnLifecycle… As you might have guessed, it is recommended to call this API in methods onCreate activity or onViewCreated fragment to avoid unexpected behavior. See an example below using snippets:

class LocationFragment: Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // ...
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                locationProvider.locationFlow().collect {
                    // New location! Update the map
                }
            }
        }
    }
}

Important: Fragments always should use viewLifecycleOwner to trigger UI updates. However, this does not apply to DialogFragmentswhich sometimes may not have a View. For DialogFragments can be used lifecycleOwner

Note: These APIs are available in the library lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 or later.

Let’s dig deeper!

repeatOnLifecycle suspends the calling coroutine, reruns the block when the lifecycle goes to target state and from there to a new coroutine, and resumes the calling coroutine when the Lifecycle is destroyed. The last point is very important: the caller that calls repeatOnLifecycle, will not resume execution until the lifecycle is destroyed.

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Create a coroutine
        lifecycleScope.launch {
            
            lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
                // Repeat when the lifecycle is RESUMED, cancel when PAUSED
            }

            // `lifecycle` is DESTROYED when the coroutine resumes. repeatOnLifecycle
            // suspends the execution of the coroutine until the lifecycle is DESTROYED.
        }
    }
}

Visual diagram

Going back to the start, collecting locationFlow directly from the coroutine launched with lifecycleScope.launchwas dangerous as it continued even when the view was in the background.

repeatOnLifecycle Prevents resource waste and application crashes by stopping and restarting thread collection when the lifecycle transitions to and from the target state.

Difference between using and not using the repeatOnLifecycle API
Difference between using and not using the repeatOnLifecycle API

Flow.flowWithLifecycle

You can also use the operator Flow.flowWithLifecyclewhen you only have one thread to collect. This API uses repeatOnLifecycle, emits items and cancels the default producer when the Lifecycle enters and exits the target state.

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Listen to one flow in a lifecycle-aware manner using flowWithLifecycle
        lifecycleScope.launch {
            locationProvider.locationFlow()
                .flowWithLifecycle(this, Lifecycle.State.STARTED)
                .collect {
                    // New location! Update the map
                }
        }
        
        // Listen to multiple flows
        lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // As collect is a suspend function, if you want to collect
                // multiple flows in parallel, you need to do so in 
                // different coroutines
                launch {
                    flow1.collect { /* Do something */ }   
                }
                
                launch {
                    flow2.collect { /* Do something */ }
                }
            }
        }
    }
}

Note: This API uses the operator Flow.flowOn(CoroutineContext) as a precedent since Flow.flowWithLifecycle changes CoroutineContextused to collect the upstream while leaving the downstream unaffected. Also, like flowOn, Flow.flowWithLifecycle adds a buffer in case the consumer can’t keep up with the producer. This is because its implementation uses callbackFlow

Configuring the standard stream producer

If you are using these APIs, beware hot streams that can waste resources, although no one collects these streams! Keep in mind that there are several suitable use cases for these, and document if necessary. Having a standard thread producer active in the background, even if it is wasting resources, can be useful for some use cases: you get fresh data instantly, rather than catching up and temporarily showing stale data. Depending on the use case, decide whether the manufacturer should always be active or not.

API MutableStateFlow and MutableSharedFlow provide a field subscriptionCountwhich can be used to stop the standard flow producer when subscriptionCount is equal to zero. By default, they will keep the producer active as long as the object containing the stream instance is in memory. However, there are several suitable use cases for this, for example, UiStatetransmitted from ViewModel in the UI with StateFlow… This is normal! This use case requires that ViewModel always provided the latest UI state to the view.

Likewise, the operators Flow.stateIn and Flow.shareIn can be configured for this with sharing launch rulesWhileSubscribed () will stop the standardstream producer if there are no active observers! On the contrary, Eagerly or Lazily will keep the underlying manufacturer active as long as the CoroutineScope they are using is active.

Note: The APIs shown in this article are a good default for collecting threads from the UI and should be used regardless of the thread’s implementation details. These APIs do what they should: stop collecting if the user interface is not visible on the screen. It depends on the implementation of the stream, whether it should always be active or not.

Collecting Flow Safely in Jetpack Compose

Function Flow.collectAsState used in Compose to collect streams from composable objects and represent values ​​as State to update the user interface (UI) Compose… Even Compose does not reflow the UI when host or fragment activity is in the background, the thread producer is still active and may be wasting resources. Compose may be experiencing a similar problem as the View system.

When collecting streams in Compose use operator Flow.flowWithLifecycle in the following way:

@Composable
fun LocationScreen(locationFlow: Flow<Flow>) {

    val lifecycleOwner = LocalLifecycleOwner.current
    val locationFlowLifecycleAware = remember(locationFlow, lifecycleOwner) {
        locationFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
    }

    val location by locationFlowLifecycleAware.collectAsState()

    // Current location, do something with it
}

Please note that you required flow rememberwho knows about the life cycle, with locationFlow and lifecycleOwner as keys to always use the same stream, unless one of the keys changes.

In Compose, side effects must be executed in controlled environment… To do this, use LaunchedEffectto create a coroutine that follows the lifecycle of the composite component. In her block, you can cause a suspension Lifecycle.repeatOnLifecycleif you need it to rerun a block of code when the host lifecycle is in a specific State

Comparison with LiveData

You may have noticed that this API behaves similarly LiveData, and indeed it is! LiveData knows about Lifecycle, and the ability to restart makes it ideal for observing data streams from the user interface. This is also true for the API Lifecycle.repeatOnLifecycle and Flow.flowWithLifecycle!

Collecting streams using these APIs is a natural replacement LiveData in applications running Kotlin only… If you are using these APIs to collect streams, LiveData has no advantage over coroutines and streams. Moreover, streams are more flexible, since they can be collected from any Dispatcherand they can work with all of it operators… Unlike LiveDatawhich has a limited number of operators available and whose values ​​are always observable from the UI thread.

StateFlow data binding support

On the other hand, one of the reasons you might be using LiveData is because it is supported in data binding. So, StateFlow also has such support! For more information on support StateFlow in data binding check out the official documentation

… … …

Use the API Lifecycle.repeatOnLifecycle or Flow.flowWithLifecycle to securely collect data streams from UI in Android.


The translation of the material was prepared on the eve of the start of the course “Android Developer. Basic”

Similar Posts

Leave a Reply