Background
Back in 2016, I volunteered to help develop two Android applications. This was before Kotlin became the go-to language for Android development—everything was Java, and managing background tasks was not something that was at the forefront of those applications.
Fast forward to today—Android development has changed dramatically. With Kotlin, new design patterns, and modern frameworks, I wanted to revisit native mobile development—this time, fully embracing the latest tools. While diving into Kotlin, I came across coroutines, a powerful tool for handling asynchronous tasks. At first, they reminded me of traditional threads, but I quickly realized they offered much more flexibility and efficiency.
What are Coroutines?
Unlike traditional threads, coroutines are suspendable—they can pause execution and resume later without blocking their thread. This makes them particularly useful for tasks like network requests, database operations, and other I/O-bound tasks.
Threads Versus Coroutines
While both enable asynchronous execution, threads are tightly linked to native OS threads, whereas coroutines are lightweight and run within threads. Unlike threads, which rely on the OS kernel for scheduling, coroutines are cooperatively scheduled and give the user more control over execution (e.g. freedom to pause and resume), and can run in different threads if needed.
Error handling is another key difference. An unhandled exception in a thread can crash the entire process. Coroutines, however, benefit from structured concurrency, meaning they are scoped within a CoroutineScope, making failures easier to manage.
Scopes
Coroutines can be run inside the following scopes:
Coroutine scope
CoroutineScope provides a structured environment for launching coroutines. It prevents coroutines from running indefinitely by ensuring they are canceled when the scope is canceled.
class MyClass { private val scope = CoroutineScope(Dispatchers.IO) fun fetchData() { scope.launch { val data = fetchDataFromNetwork() println(data) } } }
Global scope
This scope lasts for the entire lifespan of the application, from launch to termination. Coroutines launched in GlobalScope are never canceled automatically, meaning they continue running even if the UI they were associated with is destroyed. Coroutines launched in this scope run until the app is killed.import kotlinx.coroutines.* fun main() { GlobalScope.launch { delay(2000) println("Task completed in GlobalScope") } Thread.sleep(3000) // Prevents main thread from exiting immediately }
⚠️ The Dangers of GlobalScope in UI Code:
❌ Memory Leaks – Coroutines continue running even if the user navigates away.
❌ App Crashes – If a coroutine tries to update a destroyed UI element, it causes IllegalStateException.
❌ No Lifecycle Awareness – Unlike rememberCoroutineScope(), GlobalScope doesn’t track UI state changes.
Lifecycle scope
Tied to the lifecycle of an Activity or Fragment, lifecycleScope ensures that coroutines launched within it are automatically canceled when the component is destroyed. This makes it perfect for tasks that are tied directly to UI updates, such as performing background work in response to user interactions.class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { val data = fetchData() println("Fetched Data: $data") } } private suspend fun fetchData(): String { delay(2000) // Simulate network request return "Data from lifecycleScope" } }
ViewModel scope
This scope is associated with the lifecycle of a ViewModel. Coroutines launched within viewModelScope are automatically canceled when the ViewModel is cleared, which typically occurs when the associated UI is destroyed. This is ideal for background tasks that must persist through configuration changes or UI lifecycle events.class MyViewModel : ViewModel() { private val _data = MutableLiveData<String>() val data: LiveData<String> get() = _data fun fetchData() { viewModelScope.launch { _data.value = fetchFromNetwork() } } private suspend fun fetchFromNetwork(): String { delay(2000) // Simulating a network request return "Data Loaded in ViewModel" } }
TestCoroutine scope
Used for unit testing coroutines, TestCoroutineScope gives you more control over coroutine execution during tests. It allows you to simulate delays, pause coroutines, and test how they behave in different scenarios@ExperimentalCoroutinesApi class ViewModelTest { private val testScope = TestCoroutineScope() private val viewModel = MyViewModel() @Test fun `test fetchData updates LiveData`() = testScope.runBlockingTest { viewModel.fetchData() assertEquals("Data Loaded in ViewModel", viewModel.data.value) } }
rememberCoroutineScope (For Jetpack Compose)
With Jetpack Compose, coroutines can be launched within a Composable’s lifecycle, enabling UI-related tasks (such as data fetching and animations) to run asynchronously while remaining lifecycle-aware. However, Jetpack Compose frequently recomposes UI elements, meaning any coroutine started in a previous composition could be lost. This is where rememberCoroutineScope() comes in.
When a composable recomposes, it is destroyed and recreated, potentially invalidating coroutines tied to the previous composition. rememberCoroutineScope keeps the coroutine scope valid across recompositions, preventing unnecessary work and memory leaks.class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { CoroutineDemoScreen() } } } @Composable fun CoroutineDemoScreen() { val coroutineScope = rememberCoroutineScope() // Create a coroutine scope tied to the Composable lifecycle var data by remember { mutableStateOf("Click button to fetch data") } Column( modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = data, style = MaterialTheme.typography.h6) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { coroutineScope.launch { data = "Loading..." val fetchedData = fetchData() // Call suspend function to simulate data fetching data = fetchedData } }) { Text("Fetch Data") } } } suspend fun fetchData(): String { delay(2000) // Simulate network delay return "Fetched Data from Composable Coroutine Scope" }
A Simple Coroutine Example
Let’s look at a basic example to better understand coroutines in action. Suppose we want to fetch some data from a server and update the UI once the data is retrieved. Here’s how we can use coroutines to simplify this task:
// Launching a coroutine in GlobalScope
GlobalScope.launch {
val data = fetchDataFromNetwork() // This is a suspend function
updateUI(data)
}
// Simulating a suspend function to fetch data
suspend fun fetchDataFromNetwork(): String {
delay(1000) // Simulate network delay
return "Data from network"
}
// Simulating UI update
fun updateUI(data: String) {
println(data) // In an actual app, you would update a TextView or RecyclerView
}
Why Should You Use Coroutines?
Coroutines make Android development easier by providing several key benefits:
Efficiency: Coroutines are lightweight and consume far less memory than threads, making them ideal for managing multiple tasks concurrently without taxing system resources.
Non-blocking: You can run long-running tasks (like networking or database queries) without blocking the main thread, keeping your UI responsive.
Structured concurrency: The ability to scope coroutines helps prevent issues like memory leaks and unnecessary tasks running after their associated UI components are destroyed.
Simplified code: Coroutines let you write asynchronous code in a way that looks synchronous, reducing the need for complex callback chains or thread management.
A Comprehensive Coroutine Example
Let’s make a basic Android project where we make use of all the scopes and leverage coroutines (you can also checkout the code here). First generate a project with an empty activity. Thereafter, import the following dependencies:
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.coroutines.android)
Thereafter, create a data directory, and within there, create a Repository.kt file. Add the following code:
package com.example.coroutinesample
import kotlinx.coroutines.delay
object Repository {
suspend fun fetchData(): String {
delay(2000) // Simulate network request
return "Fetched Data Successfully!"
}
}
Here, we implemented a dummy function that makes use of the suspend keyword to indicate that it is asynchronous and can only run within a coroutine scope.
Now let’s create a dummy ViewModel file where we will house code to make use of the fetchData function in Repository.kt.
package com.example.coroutine_example.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.coroutine_example.data.Repository
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class MyViewModel : ViewModel() {
private val _data = MutableStateFlow("")
val data: StateFlow<String> = _data
fun fetchData() {
viewModelScope.launch {
_data.value = "Loading..."
_data.value = Repository.fetchData() + " via viewModelScope."
}
}
}
Here, we call the fetchData method from Repository.kt within the ViewModel scope.
Now, let’s create the UI using Composable in MainActivity.kt.
package com.example.coroutine_example
import android.app.Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.example.coroutine_example.viewmodel.MyViewModel
import androidx.compose.ui.unit.dp
import androidx.lifecycle.LifecycleOwner
import com.example.coroutine_example.data.Repository
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CoroutineDemoScreen(myViewModel = MyViewModel())
}
}
}
@Composable
fun CoroutineDemoScreen(myViewModel: MyViewModel) {
var data by remember { mutableStateOf("Click a button to fetch data") }
val composableScope = rememberCoroutineScope()
val context = LocalContext.current
val activity = context as? Activity // Get Activity context if possible
val viewModelData by myViewModel.data.collectAsState()
LaunchedEffect(viewModelData) {
if (viewModelData.isNotEmpty() && data != viewModelData) {
data = viewModelData
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = data, style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
myViewModel.fetchData()
}) {
Text("Fetch Data with ViewModel")
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
GlobalScope.launch {
data = "Loading..."
val result = Repository.fetchData()
data = "$result via GlobalScope"
}
}) {
Text("Fetch Data from global scope")
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
(activity as? LifecycleOwner)?.lifecycleScope?.launch {
data = "Loading..."
val result = Repository.fetchData()
data = "$result from lifecycle scope"
}
}) {
Text("Fetch Data from lifecycle scope")
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
composableScope.launch {
data = "Loading..."
val result = Repository.fetchData()
data = "$result from Composable scope"
}
}) {
Text("Fetch Data from Composable scope")
}
}
}
For testing, you can write the following for a UI test.
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.example.coroutine_example.CoroutineDemoScreen
import com.example.coroutine_example.viewmodel.MyViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class CoroutineDemoScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
// Test coroutine dispatcher
private val testDispatcher = TestCoroutineDispatcher()
private val testScope = TestCoroutineScope(testDispatcher)
@Before
fun setup() {
// Set the main dispatcher to the test dispatcher
Dispatchers.setMain(testDispatcher)
}
@Test
fun testComposableCoroutineScope() = testScope.runBlockingTest {
val myViewModel = MyViewModel()
// Start testing the composable
composeTestRule.setContent {
CoroutineDemoScreen(myViewModel = myViewModel)
}
// Perform the button click and trigger the data fetch
composeTestRule.onNodeWithText("Fetch Data from Composable scope")
.performClick()
testScope.advanceUntilIdle()
composeTestRule.onNodeWithText("Loading...") // Check for intermediate loading state
composeTestRule.onNodeWithText("Fetched Data Successfully from Composable scope") // Check for final state
}
@After
fun tearDown() {
// Reset the main dispatcher after the test
Dispatchers.resetMain()
}
}
Conclusion
Coroutines have revolutionized asynchronous programming in Kotlin, making it more efficient, readable, and manageable. By leveraging coroutine scopes like viewModelScope, lifecycleScope, and rememberCoroutineScope, we can write cleaner, more maintainable code while preventing memory leaks and keeping our UI responsive.
If you’re new to coroutines, start experimenting with small examples and gradually incorporate them into your projects. Understanding structured concurrency and the various coroutine scopes will help you write more efficient and scalable Android applications.