Launching Coroutines, Collecting Flow with Lifecycle scope in a correct way

Nam Nguyen on 2024-01-01

Launching Coroutines, Collecting Flow with Lifecycle scope in a correct way

Coroutines have become very popular among Android developers in recent years, as they offer a simpler and more concise way to write asynchronous code. According to a survey by Google, over 50% of professional developers who use coroutines have reported seeing increased productivity. Moreover, coroutines are fully interoperable with Java and can be used alongside other asynchronous solutions, such as RxJava or callbacks.

In Android world, Coroutines help to manage long-running tasks that might block the main thread and cause your app to become unresponsive.

In this article, we will focus on how to launch coroutines and collect flows in an efficient way. I assume that you’re already familiar with coroutine concept and Flow before. If not, you can find out more:

Coroutine: https://developer.android.com/kotlin/coroutines

Flow: https://developer.android.com/kotlin/flow

OK, let’s get started.

Have you ever launched a coroutine in a lifecycle-aware component like Activity, Fragment,…

If so, I bet the block of code below looks familiar to you:

lifecycleScope.launch { 
    // do some asynchronous task
    doWork()

    // Or collect a flow
    viewModel.dataFlow.collect {
      // do some work with the value collected.
    }
}

Google provides this extension function in the androidx.lifecycle:lifecycle-runtime-ktx library, which is part of the Android Jetpack libraries. The lifecycleScope.launch {} function allows you to to launch a coroutine that is automatically canceled when the Lifecycle is destroyed. You can also use viewLifecycleOwner.lifecycleScope.launch {} in a Fragment to launch a coroutine that is tied to the lifecycle of a Fragment.

That means you don’t need to cancel a job manually when an Activity or a Fragment is destroyed. This helps you to avoid memory leaks and unnecessary work that can lead to wasted resources.

Is it great, isn’t it?

But what if you want to pause or suspend the job when the app goes to the background or the user navigates to another screen. We all know that in this case, with lifecycleScope.launch {}, the job inside launch {} is still running because the activity is NOT destroyed yet. So how can you deal with this situation? Of course you can handle it manually by checking the activity’s state before launching a coroutine and re-launching it when the activity becomes active again. It’s quite complicated. But Google provides a simpler, more efficient way to deal with that. It’s called launchWhenStarted. According to Google docs:

launchWhenStarted launches and runs the given block when the Lifecycle controlling this LifecycleCoroutineScope is at least in Lifecycle.State.STARTED state. The returned Job will be cancelled when the Lifecycle is destroyed.

The advantage of lifecycleScope.launchWhenStarted over lifecycleScope.launch is that it suspends the execution of the coroutine when the Lifecycle is not in the STARTED or RESUMED state. This means that the coroutine does not perform any work or consume any resources (with Flow) until the Lifecycle becomes active again.

This can help you to avoid unnecessary work, memory leaks, or even crash app. Now, our code becomes like this:

lifecycleScope.launchWhenStarted {
    // do some asynchronous task
    doWork()

    // Or collect a flow
    viewModel.dataFlow.collect {
      // do some work with the value collected.
      println("Collect $it")
    }
}

I’m sure that some of you already know it and have been using it a lot in your projects.

Unfortunately, this function is now DEPRECATED. Why? The reason is that it could lead to wasted resources in some cases. If you take a look at its documentation (using Ctrl+Click), this is what you see:

Not only launchWhenStarted, other launchWhenX (launchWhenResumed, launchWhenCreated) functions are now deprecated as well.

https://developer.android.com/topic/libraries/architecture/coroutines

As you can see, those functions will be removed in a future release. This means that if you use a new version of Coroutine in the future, you may not use those functions any more.

So what are we going to do now?

Google recommends us to use the Lifecycle.repeatOnLifecycle API instead and you should use it and replace your existing launchWhenX in your source code as soon as possible. There are 2 significant advantages that you should be aware of:

  1. launchWhenX (launchWhenStarted, launchWhenCreated, launchWhenResumed) are now deprecated and they will be removed in a future release. That means in the future, when you upgrade your coroutine version to use its new features in your project, those functions might be no longer available.
  2. Even when you have no intend on upgrading new version of Coroutines, I strongly recommend you to use and replace existing launchWhenX functions by repeatOnLifecycle. As you can see, you know the reasons why the launchWhenX APIs become deprecated as it can lead to wasted resources in some cases or even cause crash app. That why you should use repeatOnLifecycle for better performance optimization.
// Create a new coroutine in the lifecycleScope
lifecycleScope.launch {
    // repeatOnLifecycle launches the block in a new coroutine every time the
    // lifecycle is in the STARTED state and cancels it when it's STOPPED.
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        // Start listening for values.
        // This happens when lifecycle is STARTED and stops
        // collecting when the lifecycle is STOPPED
        viewModel.dataFlow.collect {
            // do some work with the value collected.
        }
    }
}

If you are using flows and collecting them in lifecycleScope, there are 2 recommended ways.

  1. If you only need to collect a single flow, you can use the flowWithLifecycle() in your code like this:

flowWithLifecycle is an extension function of Flow

2. If you need to collect multiple flows in parallel, you should use repeatOnLifecycle and collect each flow in different coroutines as it’s faster and more efficient like this:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        // collect multiple flows in parallel
        launch {
            myFlow1.collect {
              // do some work with the value collected.
            }
        }
        launch {
            myFlow2.collect {
              // do some work with the value collected.
            }
        }
    }
}

Okay, now we know why and when we should use flowWithLifecycle, repeatOnLifecycle. We are ready to apply them for both new features and existing features in our project. In industry-level app, you may collect flows in so many places in your source code. Image that your app has been developing for a long time, there are 100, 200 collect {} functions being called. It really takes you and your team much time replacing all launchWhenX functions by repeatOnLifecycle/flowWithLifecycle. And some of you only want to apply to next collect {} calls, and don’t want to touch existing codebase.

So my solution to this problem is that you should create a function (e.g: safeCollectFlow). This function is responsible for launching a coroutine, collecting a flow and maybe do some jobs that you might need. Whenever you want to collect a flow, you just need to call that function and that function do all the work for you.

You can create an extension function like this:

fun <T> LifecycleOwner.safeCollectFlow(flow: Flow<T>, result: (T) -> Unit) {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            flow.collect {
                result.invoke(it)
            }
        }
    }
}

Or in some projects, you may have BaseActivity/BaseFragment. You can also write a function like that in your BaseActivity/BaseFragment like this:

abstract class BaseFragment() : Fragment() {
  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View? {}

  fun <T> safeCollectFlow(flow: Flow<T>, result: (T) -> Unit) {
      lifecycleScope.launch {
          repeatOnLifecycle(Lifecycle.State.STARTED) {
              flow.collect {
                  result.invoke(it)
              }
          }
      }
   }
}

In your activities/fragments that you want to collect a flow, all you need is calling safeCollectFlow like this:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        safeCollectFlow(flow = getProgressFlow()) { data ->
            // process data collected
        }

    }

    private fun getProgressFlow() = flow {
        var progress = 0
        while (true) {
            emit(progress)
            delay(1.seconds)
            progress++
        }
    }
}

By using this approach, you can achieve 3 goals:

1. Reducing boilerplate code: This helps you reduce boilerplate code that you need to collect a flow. Whenever you need to collect a flow, you have to launch a coroutine, then start collecting it and and add repeatOnLifecycle

lifecycleScope.launch {
    repeatxOnLifecycle(Lifecycle.State.STARTED) {
        flow.collect {
            result.invoke(it)
        }
    }
}

You may repeat this block of code over and over again in the whole project.

2. Code faster With this approach I mentioned above, you’ll code faster. Just call this function inside Lifecycle-aware component (Activity/Fragment) whenever you need to collect a flow:

safeCollectFlow(flow = getProgressFlow()) { data ->
  // process data collected
}

Imagine that you have to collect flows 100, 200 times in your project, how much time and effort it save you. Great!

3. Easy to maintain and scale This is one of the important reasons you should consider. It solves the problem I mentioned above. What if on a nice day in the future, Google announces that repeatOnLifecycle has some issues and becomes deprecated. For example, repeatOnLifecycle {} would be replaced by a new function called launchWithNewAPI {}. And Google recommends us to use the new function. All you need to do is modify just ONE function safeCollectFlow like this:

fun <T> safeCollectFlow(flow: Flow<T>, result: (T) -> Unit) {
  lifecycleScope.launch {
    launchWithNewAPI {
      // replace repeatOnLifecycle(Lifecycle.State.STARTED) by launchWithNewAPI
      flow.collect {
          result.invoke(it)
      }
    }
  }
}

You don’t have to modify/refactor your existing code much.

Cool, isn’t it? But it’s not the end.

Jetpack Compose is becoming more popular these days. It is Android’s recommended modern toolkit for building native UI. You can learn more here. So if you are using Compose, you can collect flows using collectAsState() like this:

val data = viewModel.dataFlow.collectAsState()
Column(
    modifier = Modifier.fillMaxSize()
) {
    Text(text = "Data: data")
}

You maybe notice that in the examples above, I use lifecycleScope to collect flows with lifecycle-awareness. But in the code above using Compose, I don’t use lifecycleScope anymore. Does it disappear in Compose world or does the collectAsState() handle lifecycle-awareness behind the scene? The answer is that the collectAsState() does NOT handle lifecycle-awareness. That’s why we have another function called collectAsStateWithLifecycle(). As its name says, you probably know what it does.

“Collects values from this StateFlow and represents its latest value via State in a lifecycle-aware manner.”

To achive this, you need 2 steps:

First, import dependency in build.gradle file (app-level)

// Compose with lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") // replace with your version

Second, use collectAsStateWithLifecycle() when collecting flows, now our code becomes:

val data = viewModel.dataFlow.collectAsStateWithLifecycle()
Column(
    modifier = Modifier.fillMaxSize()
) {
    Text(text = "Data: data")
}

Conclusion

Android world is always changing and evolving. It offers many opportunities and challenges for developers like us. Google is constantly developing new techniques and tools to help developers create better apps that meet the needs and expectations of users. Some of these techniques and tools include Kotlin coroutines, Flow, Jetpack libraries, and app quality guidelines. By following the best practices and adopting the modern Android approach, developers can improve their productivity, reduce their errors, and focus on what makes their apps unique.

And remember, always keep yourself up-to-date.

Wish you all the best!

Coroutine: https://developer.android.com/kotlin/coroutines

Flow: https://developer.android.com/kotlin/flow

https://developer.android.com/kotlin/coroutines/coroutines-best-practices

https://developer.android.com/topic/libraries/architecture/coroutines