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
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
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.repeatOnLifecycle
available 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 DialogFragments
which 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.launch
was 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.
Flow.flowWithLifecycle
You can also use the operator Flow.flowWithLifecycle
when 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 sinceFlow.flowWithLifecycle
changesCoroutineContext
used to collect the upstream while leaving the downstream unaffected. Also, likeflowOn
,Flow.flowWithLifecycle
adds a buffer in case the consumer can’t keep up with the producer. This is because its implementation usescallbackFlow
…
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 subscriptionCount
which 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, UiState
transmitted 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 rules… WhileSubscribed () 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 StateCompose
… 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.repeatOnLifecycle
if 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 Dispatcher
and they can work with all of it operators… Unlike LiveData
which 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”…