Safer way to collect streams from Android UI

logo

Safer way to collect streams from Android UI

In Android applications, Kotlin flowsdata updates are usually collected from the UI layer to display on the screen. However, to make sure you're not doing too much work, wasting resources (both CPU and memory), or leaking data when the view goes to the background, you need to collect these flows.

In this article, you'll learn how to use Lifecycle.repeatOnLifecycleand Flow.flowWithLifecycleAPIs to protect resources, and why they're flowgood defaults for UI layer collections.

Waste of resources
recommends exposing APIs from lower layers of the application hierarchy Flow<T>without regard to flowproducer implementation details. However, you should also collect them safely.

Operators that use channel backing cold flowor use buffers (eg buffer, conflate, flowOnor shareIn) are not safe to collect with some existing APIs (eg CoroutineScope.launch, Flow<T>.launchInor LifecycleCoroutineScope.launchWhenX) unless the coroutine is manually uninitiated when the activity goes to the background Job. These APIs will keep the underlying flowproducer alive while emitting items to the buffer in the background, wasting resources.

Note: cold flowis a type flowthat will execute code blocks on demand when new subscribers are collected.

For example, consider using callbackFlowthe following that emits location updates flow:

// 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: Internally, callbackFlowa channel is used, which is conceptually very similar to a blocking queue, and has a default capacity of 64 elements.

Gather this from the UI layer using any of the aforementioned APIs flow, and the positions will be continuously emitted even if the view is not displaying them in the UI! See the 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)
    }
}

Use lifecycleScope.launchWhenStartedsuspend coroutine execution. The new position will not be processed, but callbackFlowthe producer will still send the position. And using lifecycleScope.launchor launchIn API is more dangerous, because even if the view is running in the background, it will still continue to consume position. This may cause the application to crash.

To fix the problem with these APIs, you need to manually cancel the collection to cancel when the view goes into the background callbackFlow, and avoid the location provider emitting items and wasting resources. For example, you can do 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()
    }
}

That's a nice solution, but that's boilerplate code, my friends! If there's one universal truth about Android developers, it's that we absolutely hate writing boilerplate code. One of the biggest benefits of not having to write boilerplate code is that the amount of code is reduced, which reduces the chance of errors!

Lifecycle.repeatOnLifecycle

Now that we all understand the problem, it's time to come up with a solution. The solution needs to meet three criteria: 1) simple and easy to implement, 2) user friendly or easy to remember/understand, 3) more importantly: safe! Regardless of the specific implementation details, it should work for all use cases.

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

Please note: These APIs require lifecycle-runtime-ktx library version 2.4.0 or higher to use.

Take a look at the code below:

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.
            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
                }
            }
        }
    }
}

repeatOnLifecycleis a suspend function that takes as Lifecycle.Statea parameter, when the lifetime reaches this state, it automatically creates and starts a new coroutine, and cancels the coroutine that is executing the block, when the lifetime falls below this state.

repeatOnLifecycleThis avoids any boilerplate code, as the code associated with canceling the coroutine is automatically executed when the coroutine is no longer needed . As you can guess, it is recommended to call this API in the activity's onCreateor fragment's onViewCreatedmethods to avoid unexpected behavior. Please refer to the following example using fragments:

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 reminder : Should always be used when triggering UI updates in Fragments viewLifecycleOwner, but DialogFragmentssometimes the View may not exist. For DialogFragments, you can use lifecycleOwner.

Please Note : These APIs are provided in the androidx.lifecycle:lifecycle-runtime-ktx:2.4.0 library and higher.

The essence is : repeatOnLifecyclewill suspend the calling coroutine, restart the new coroutine of the block when the life cycle enters and leaves the target state, and resume the calling coroutine when the life cycle is destroyed. The last point is very important: only when the lifecycle is destroyed, the calling repeatOnLifecyclecoroutine will resume execution.

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

        // Create a coroutine
        lifecycleScope.launch {
    
    
            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 chart

Back to square one, collecting directly by using lifecycleScope.launcha launched coroutine locationFlowis dangerous because the collection will continue to happen even while the View is running in the background.
repeatOnLifecyclePrevents process collection from being stopped and restarted due to resource waste and application crashes when lifecycles enter and exit target states.

The difference between using and not using the repeatOnLifecycle API

Flow.flowWithLifecycle

The Flow.flowWithLifecycle operator can also be used when you have only one Flow to collect. This API uses the repeatOnLifecycle API behind the scenes and emits items and cancels the underlying producer when the Lifecycle moves to a 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(lifecycle, Lifecycle.State.STARTED)
                .collect {
    
    
                    // New location! Update the map
                }
        }
        
        // Listen to multiple flows
        lifecycleScope.launch {
    
    
            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 name depends on Flow.flowOn(CoroutineContext)the operation , since Flow.flowWithLifecyclechanges are used to collect upstream streams CoroutineContextwithout affecting downstream. Similarly flowOn, Flow.flowWithLifecyclea buffer is added in case the user is not keeping pace with the producer. This is due to its implementation using the callbackFlow.

Configure the underlying producer

Even with these APIs, be aware of hot streams that can waste resources, even if no one collects them! There are some legitimate use cases as well, but keep them in mind and document them as necessary. Even though it is a waste of resources, keeping the underlying stream producer alive can be beneficial for some use cases: fresh data can be immediately available instead of catching up and showing stale data temporarily. Depending on your use case decide if the producer needs to be active all the time.

MutableStateFlowand MutableSharedFlow API exposes a subscriptionCount field that you can use to subscriptionCountstop the underlying producer when is zero. By default, they keep producers alive as long as the object holding the stream instance is in memory. There are some legitimate use cases though, eg exposing from ViewModel to UI via StateFlow UiState. This is possible! This use case requires that the View is ViewModelalways provided with the latest UI state.

Similarly, the Flow.stateInand Flow.shareInoperator can configure a share start policy for this. WhileSubscribed()Will stop the underlying producer when there are no active observers! Conversely, Eagerly or Lazily will keep the underlying producers alive as long as the ones they consume CoroutineScopeare active.

Note: The APIs presented in this article are good defaults for collecting streams from the UI and should be used regardless of stream implementation details. These APIs do what they're supposed to do: stop collecting if the UI isn't visible on the screen. It's up to the stream implementation if it should always be active.

Safe Flow Collection in Jetpack Compose

If you're building an Android application with Jetpack Compose, use collectAsStateWithLifecyclethe API to collect streams from the UI in a lifecycle-aware manner.

collectAsStateWithLifecycleis a composable function that collects values ​​from a stream in a lifecycle-aware manner and represents the latest value as a Compose State. The value of this State object is updated whenever a new stream emission occurs. This causes all uses State.valuein to be rearranged.

By default, values ​​are collected from the stream collectAsStateWithLifecycle using start and stop. Lifecycle.State.STARTEDThis happens as lifecycle moves enter and exit target states. This lifecycle state is something you can configure in minActiveStatethe parameter .

The following code snippet shows this API in action:

@Composable
fun LocationUI(locationFlow: Flow<Location>) {
    
    

    val location by locationFlow.collectAsStateWithLifecycle()

    // Current location, do something with it
}

Comparison with LiveData

You may have noticed that this API behaves like LiveData, and you're right! LiveData understands Lifecycle, and its restart behavior makes it ideal for observing data flow from the UI. Lifecycle.repeatOnLifecycleThe same is true for and Flow.flowWithLifecycle!

LiveDataUsing these APIs to collect flows is a natural replacement for Kotlin-only applications . If you use these APIs to collect streams, LiveData has no advantages over coroutines and flows. Also, flows are more flexible, as they can be collected from any scheduler and run with all its operators. In contrast to LiveData, which has limited operators available and its value is always observed from the UI thread.

Support for StateFlow in data binding

On the other hand, the reason you might be using LiveData is that it is backed by data binding. Well, so is StateFlow! For more information on StateFlow's support in data binding, check out the official documentation.

https://developer.android.com/topic/libraries/data-binding/observability#stateflow

Use Lifecycle.repeatOnLifecycleor Flow.flowWithLifecycleAPI to safely collect flows from Android's UI layer.

Guess you like

Origin blog.csdn.net/u011897062/article/details/131128092