13 - Coroutines
Kotlin Coroutines
Kotlin Coroutines provide a powerful way to handle asynchronous programming in Android.
They simplify code that executes asynchronously, making it more readable and easier to manage.
Kotlin Coroutines are a design pattern for simplifying code that executes asynchronously.
They allow to write code in a sequential style, even when performing asynchronous operations.
This makes code easier to read, write, and maintain.
When to use
- Network operations: fetching data from an API.
- Database operations: reading and writing to a database.
- Heavy computation tasks: performing tasks that could block the main thread.
- Concurrency: running multiple tasks simultaneously without blocking.
Benefits
- Simplicity: Code reads like sequential code, reducing complexity.
- Performance: Coroutines are lightweight and don’t block threads.
- Structured Concurrency: Coroutines provide a structured way to manage concurrency, making it easier to handle cancellation and errors.
- Integration: Well-integrated with Android and other Kotlin libraries.
Basics
launch
launch
is a function that starts a new coroutine. It doesn't return a result but rather a Job object, which can be used to manage the coroutine (cancel the job).
fun main() {
CoroutineScope(Dispatchers.Main).launch {
// Coroutine code here
}
}
async
async
is a function that starts a new coroutine and returns a Deferred object, which represents a future result. You can call await on the Deferred object to get the result.
fun main() {
CoroutineScope(Dispatchers.Main).launch {
val result = async(Dispatchers.IO) {
// Coroutine code here
"Result"
}.await()
println(result)
}
}
withContext
withContext
is used to switch the context of a coroutine. It is often used to switch between different dispatchers.
suspend fun fetchData(): String {
return withContext(Dispatchers.IO) {
// Coroutine code here
"Data"
}
}
Dispatchers
Dispatchers define the thread pool on which the coroutine runs. The most commonly used dispatchers are:
- Dispatchers.Main: Runs on the main (UI) thread.
- Dispatchers.IO: Optimized for IO operations, such as network and disk operations.
- Dispatchers.Default: Optimized for CPU-intensive work.
fun main() {
CoroutineScope(Dispatchers.Main).launch {
// Main thread
withContext(Dispatchers.IO) {
// IO thread
}
withContext(Dispatchers.Default) {
// Default thread
}
}
}
suspend
functions
A suspend
function is a function that can be paused and resumed later. These functions can execute long-running operations without blocking the thread they're running on.
suspend fun fetchData(): String {
delay(1000) // Simulate network delay
return "Data"
}
Scopes
GlobalScope
GlobalScope
is a global CoroutineScope that lives for the entire duration of the application. It is generally not recommended to use GlobalScope as it makes it difficult to manage the lifecycle of coroutines, leading to potential memory leaks.
GlobalScope.launch {
// Coroutine code here
}
CoroutineScope
CoroutineScope
is a lifecycle-aware scope, which means you can control the lifecycle of coroutines more easily. It’s a best practice to use CoroutineScope
with the appropriate context.
class MyActivity : AppCompatActivity() {
private val myScope = CoroutineScope(Dispatchers.Main + Job())
override fun onDestroy() {
super.onDestroy()
myScope.cancel() // Cancel all coroutines when the activity is destroyed
}
fun fetchData() {
myScope.launch {
// Coroutine code here
}
}
}
viewModelScope
viewModelScope
is a CoroutineScope tied to the lifecycle of a ViewModel. When the ViewModel is cleared, all coroutines in this scope are canceled.
class MyViewModel : ViewModel() {
fun fetchData() {
viewModelScope.launch {
// Coroutine code here
}
}
}
lifecycleScope
lifecycleScope
is a CoroutineScope tied to the lifecycle of a LifecycleOwner (such as an Activity or Fragment). Coroutines in this scope are canceled when the LifecycleOwner is destroyed.
class MyFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleScope.launch {
// Coroutine code here
}
}
}
Setting up
In build.gradle.kts
val coroutinesVersion = "1.9.0"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
Single network call
Making a single network call with Coroutines is straightforward. We use the suspend keyword to create a function that performs the network request.
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
// Simulated network call
suspend fun fetchUserData(): String {
delay(1000) // Simulate network delay
return "User data"
}
fun main() {
// Starting a coroutine in the main thread
CoroutineScope(Main).launch {
try {
val result = withContext(IO) { fetchUserData() }
println(result)
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
}
Multiple network calls
Handling multiple network calls can be done sequentially or concurrently using Coroutines.
Sequential Calls
suspend fun fetchUserProfile(): String {
delay(1000)
return "User Profile"
}
suspend fun fetchUserPosts(): String {
delay(1000)
return "User Posts"
}
fun main() {
CoroutineScope(Main).launch {
try {
val profile = withContext(IO) { fetchUserProfile() }
println(profile)
val posts = withContext(IO) { fetchUserPosts() }
println(posts)
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
}
Concurrent Calls
fun main() {
CoroutineScope(Main).launch {
try {
val profileDeferred = async(IO) { fetchUserProfile() }
val postsDeferred = async(IO) { fetchUserPosts() }
val profile = profileDeferred.await()
val posts = postsDeferred.await()
println(profile)
println(posts)
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
}
Error handling
Proper error handling is crucial in any application. Coroutines provide structured concurrency to handle errors gracefully.
Single Call Error Handling
Use try-catch within the coroutine scope to catch exceptions:
fun main() {
CoroutineScope(Main).launch {
try {
val result = withContext(IO) { fetchUserData() }
println(result)
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
}
Multiple Calls Error Handling
When making multiple network calls, ensure each call is wrapped with try-catch or use a supervisor job to handle exceptions without canceling sibling coroutines.
fun main() {
CoroutineScope(Main).launch {
supervisorScope {
val profileDeferred = async(IO) {
try {
fetchUserProfile()
} catch (e: Exception) {
"Error fetching profile: ${e.message}"
}
}
val postsDeferred = async(IO) {
try {
fetchUserPosts()
} catch (e: Exception) {
"Error fetching posts: ${e.message}"
}
}
val profile = profileDeferred.await()
val posts = postsDeferred.await()
println(profile)
println(posts)
}
}
}
class MyViewModel : ViewModel() {
fun doWork(): List<Result> {
val deferredResults = listOf(
viewModelScope.async {
// Do the work and return the result here
if (errorOccurred) {
throw Exception("Error occurred")
}
return@async result
}
)
return try {
deferredResults.awaitAll()
} catch (e: Exception) {
// Manage the exception here
emptyList()
}
}
}
Timeouts
You can use withTimeout or withTimeoutOrNull to handle long-running operations.
fun main() {
CoroutineScope(Main).launch {
try {
val result = withTimeout(2000) { fetchUserData() }
println(result)
} catch (e: TimeoutCancellationException) {
println("Operation timed out")
}
}
}