Techbase Academy
Android · Lesson 28

Coroutines and Concurrency

10 phasesParallel Price CheckerAndroid Studio
Phase 1 of 10
Lesson Introduction

Tap a button in an app and nothing happens for three seconds. The screen is frozen, a dialog says "App is not responding", and the user leaves a one-star review. The cause is almost always the same: the app did slow work, such as downloading a file or reading a database, on the thread that is supposed to draw the screen.

Modern Android apps avoid that with coroutines, a Kotlin feature that lets you write slow, waiting code in a simple top-to-bottom style without freezing anything. This lesson is the doorway to everything in Unit 5: networking, databases and background work all rely on it.

This lesson covers three items of Pathway 1, Unit 5 of the official Android Basics with Compose course: Connect to the internet (the unit introduction), Introduction to Coroutines in Kotlin Playground and Introduction to Coroutines in Android Studio. You can run almost all the examples in the free Kotlin Playground at https://play.kotlinlang.org without installing anything, which is ideal on a slow connection or an old laptop.

By the end you will be able to:

  • Explain why the main thread must never block, and what ANR means.
  • Explain concurrency versus parallelism.
  • Write suspend functions and call them from launch, async and runBlocking.
  • Choose a dispatcher: Main, IO, Default.
  • Explain structured concurrency: scopes, jobs, parents, children and cancellation.
  • Handle exceptions in coroutines.
  • Launch coroutines safely in Android with viewModelScope and rememberCoroutineScope.
  • Add a Gradle dependency using the version catalog.

Big idea: a coroutine is a job that can pause without blocking a thread, then resume later. A thread is an expensive worker; a coroutine is cheap.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 10
Prerequisite Concepts
  • Lambdas and higher-order functions (lesson 12). launch { ... } takes a lambda.
  • ViewModel (lesson 24). You will launch coroutines from it.
  • State (lesson 17). Results of slow work end up in state that the UI shows.
  • Kotlin Playground. Open https://play.kotlinlang.org, paste code, and press the run button. Note that Playground examples need import kotlinx.coroutines.*.
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 10
Conceptual Understanding: The Main Thread and Why It Must Stay Free

Android runs your UI on one special thread, the main thread (also called the UI thread). It must redraw the screen about 60 to 120 times per second, which means each frame has roughly 8 to 16 milliseconds. If you block that thread with a download that takes two seconds, roughly a hundred frames are skipped, so the app janks, then freezes. If it stays blocked for around five seconds the system shows an ANR ("Application Not Responding") dialog.

Think of a restaurant with one waiter (the main thread). If the waiter walks to the market to buy tomatoes, nobody gets served. Better: the waiter sends a runner for the tomatoes and continues serving; when the runner returns, the waiter picks up the tomatoes. The runner is a background thread or coroutine.

Android helps enforce this rule. Doing network access on the main thread throws NetworkOnMainThreadException, and the strict-mode developer tool can warn about disk reads there.

Concurrency, parallelism, asynchrony

Term Meaning Everyday picture
Concurrency Several tasks are in progress during the same period. They may take turns on one CPU. One cook juggling three pots
Parallelism Several tasks run at the same instant on different CPU cores. Three cooks, three pots
Asynchronous A task starts and you continue without waiting for it; you are told later when it finishes. Order food, sit down, the waiter calls your number

Coroutines give you concurrency easily and can use parallelism when you give them a multi-threaded dispatcher.

Threads versus coroutines

A thread is managed by the operating system and costs about a megabyte of memory. Creating ten thousand threads would crash a phone. A coroutine is managed by Kotlin and costs a few dozen bytes. You can launch a hundred thousand coroutines on a small phone, because they run on top of a few threads and suspend instead of blocking.

text
Thread-based:  [thread 1: ....waiting for network....][thread 2: ....waiting....]
                 each waiting thread is wasted

Coroutine:     [thread: A starts -> A suspends -> B runs -> C runs -> A resumes]
                 the thread is busy with other work while A waits
✏️ Your Task
Practise what you just learned about Conceptual Understanding: The Main Thread and Why It Must Stay Free. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 10
Step-by-Step Build 1: Your First Coroutines in the Playground

Open https://play.kotlinlang.org and replace the code with:

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
 println("Start: ${Thread.currentThread().name}")
 delay(1000)
 println("After one second")
}

Start: main
After one second

(The thread name may look different in the Playground, for example main @coroutine#1.)

What each piece means:

  • runBlocking { ... } starts a coroutine and blocks the current thread until it finishes. It exists to bridge ordinary code (like main) and coroutines. Use it in the Playground and in tests, never in Android UI code.
  • delay(1000) suspends the coroutine for one second without blocking its thread. Compare with Thread.sleep(1000), which blocks the thread.

Suspend functions

A function marked suspend can pause and resume. It can only be called from another suspend function or from a coroutine.

kotlin
import kotlinx.coroutines.*
suspend fun fetchGreeting(): String {
 delay(1000) // pretend this is a network call
 return "Hello from the server"
}
fun main() = runBlocking {
 val greeting = fetchGreeting()
 println(greeting)
}

The magic: val greeting = fetchGreeting() looks like a normal call that returns a value, but the coroutine pauses at that line while the "network" works, and the thread is free meanwhile. There are no callbacks and no nesting. The compiler rewrites it behind the scenes into a state machine.

Doing two things at once with launch

launch starts a new coroutine and gives you a Job. It is fire-and-forget: it returns no result.

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
 launch {
 delay(1000)
 println("Task A done")
 }
 launch {
 delay(500)
 println("Task B done")
 }
 println("Waiting...")
}

Waiting...
Task B done
Task A done

runBlocking does not finish until both children finish. The two tasks take turns while waiting, so the total time is about one second, not one and a half.

Getting results with async and await

async starts a coroutine that returns a value wrapped in a Deferred. await() suspends until it is ready.

kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
suspend fun getPrice(): Int { delay(1000); return 500 }
suspend fun getDeliveryFee(): Int { delay(1000); return 200 }
fun main() = runBlocking {
 val sequentialTime = measureTimeMillis {
 val a = getPrice()
 val b = getDeliveryFee()
 println("Sequential total: ${a + b}")
 }
 println("Sequential took about $sequentialTime ms")
 val parallelTime = measureTimeMillis {
 val a = async { getPrice() }
 val b = async { getDeliveryFee() }
 println("Concurrent total: ${a.await() + b.await()}")
 }
 println("Concurrent took about $parallelTime ms")
}

Sequential total: 700
Sequential took about 2000 ms
Concurrent total: 700
Concurrent took about 1000 ms

(The millisecond figures vary slightly on each run; the roughly two-to-one ratio is the point.) Use async when two independent pieces of work can overlap and you need both results.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Your First Coroutines in the Playground. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 10
Step-by-Step Build 2: Dispatchers

A dispatcher decides which thread or threads a coroutine runs on. You pass one to launch, async or withContext.

Dispatcher Threads Use for
Dispatchers.Main The Android main thread Updating UI, light work, calling suspend functions
Dispatchers.IO A large shared pool (64 threads by default) Waiting on network, disk, databases
Dispatchers.Default As many threads as CPU cores Heavy computation: sorting, parsing, image processing

Dispatchers.Main needs the kotlinx-coroutines-android artifact, which the Android libraries bring in. It does not exist in the Playground.

Switch dispatchers inside a function with withContext:

kotlin
import kotlinx.coroutines.*
suspend fun readBigFile(): String = withContext(Dispatchers.IO) {
 // blocking disk or network calls are fine here
 delay(800)
 "file contents"
}
fun main() = runBlocking {
 println("Calling from: ${Thread.currentThread().name}")
 val text = readBigFile()
 println("Got: $text")
}

The golden rule of suspend functions: a suspend function should be main-safe, meaning it is safe to call from the main thread because it switches to the right dispatcher itself. A caller should not need to know which thread to use. Libraries such as Retrofit and Room (lessons 29 and 34) already give you main-safe functions, so you will often not need withContext(Dispatchers.IO) yourself. Add it when you call something blocking, like reading a file with plain Java APIs.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Dispatchers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 10
Step-by-Step Build 3: Structured Concurrency

Starting coroutines is easy. The hard part is cleaning up: if the user leaves the screen while a download is running, you must cancel it, or it keeps wasting battery and may crash when it tries to update a screen that no longer exists.

Kotlin solves this with structured concurrency: every coroutine lives inside a scope, and the scope tracks its children.

text
CoroutineScope (for example, viewModelScope)
   |
   +-- launch  (Job A)
   |     +-- launch (child A1)
   |     +-- async  (child A2)
   |
   +-- launch  (Job B)

Cancel the scope  -->  A, A1, A2 and B are all cancelled.
A child fails     -->  the parent is cancelled, and so are its other children.
A parent waits for all its children before it completes.

The rules:

  1. Scope = lifetime. When the scope ends, all its coroutines are cancelled.
  2. A parent waits for its children.
  3. Cancellation flows down, failure flows up (by default).

Cancellation in practice

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
 val job = launch {
 repeat(10) { i ->
 println("Working $i")
 delay(300)
 }
 }
 delay(1000)
 println("Cancelling")
 job.cancelAndJoin()
 println("Done")
}

Working 0
Working 1
Working 2
Working 3
Cancelling
Done

cancelAndJoin() cancels the job and waits until it has actually finished. Cancellation is cooperative: it happens at suspension points such as delay. A coroutine stuck in a tight loop with no suspension point will not stop. Check isActive or call ensureActive() inside long loops:

kotlin
import kotlinx.coroutines.*
suspend fun crunch(): Long = withContext(Dispatchers.Default) {
 var sum = 0L
 for (i in 1..1_000_000_000L) {
 if (i % 10_000_000L == 0L) ensureActive() // throws CancellationException if cancelled
 sum += i
 }
 sum
}

A cancelled coroutine throws a CancellationException. This is normal. If you wrap code in try/catch (e: Exception), either rethrow CancellationException or catch narrower types, otherwise you swallow the cancellation and the coroutine refuses to stop.

Exceptions

An uncaught exception in a coroutine cancels its parent, which can crash an app. You handle errors with ordinary try/catch inside the coroutine:

kotlin
import kotlinx.coroutines.*
suspend fun riskyCall(): String {
 delay(200)
 throw IllegalStateException("Server said no")
}
fun main() = runBlocking {
 launch {
 try {
 val result = riskyCall()
 println(result)
 } catch (e: IllegalStateException) {
 println("Caught: ${e.message}")
 }
 }
}

Caught: Server said no

Two important details. First, wrapping the launch call itself in try/catch does not work, because the exception happens later, inside the new coroutine. Second, with async the exception is thrown when you call await(). For independent children where one failure should not cancel the others, use supervisorScope { }. A CoroutineExceptionHandler is a last-resort logger for uncaught exceptions in launch, not a replacement for try/catch.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Structured Concurrency. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 10
Step-by-Step Build 4: Coroutines in Android Studio

Time to use them in an app, as in the official Introduction to Coroutines in Android Studio codelab. You need the coroutines in your project. The Compose and Lifecycle libraries pull in kotlinx-coroutines-android for you, so viewModelScope and launch work already. If you ever need them directly, this is how a dependency is added with the version catalog:

libs.versions.toml
[versions]
coroutines = "<latest stable>"
[libraries]
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
build.gradle.kts
dependencies {
 implementation(libs.kotlinx.coroutines.android)
 testImplementation(libs.kotlinx.coroutines.test)
}

Always take the latest stable version from the kotlinx.coroutines releases page (https://github.com/Kotlin/kotlinx.coroutines/releases). Where a BOM or another library already declares the version, do not add a conflicting one.

Launching from a ViewModel: viewModelScope

viewModelScope is a scope tied to the ViewModel. It uses Dispatchers.Main.immediate and is cancelled automatically when the ViewModel is cleared, which gives you structured concurrency for free.

QuoteViewModel.kt
package com.example.quotes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class QuoteUiState(
 val isLoading: Boolean = false,
 val quote: String = "Press the button",
 val error: String? = null
)
class QuoteViewModel : ViewModel() {
 private val _uiState = MutableStateFlow(QuoteUiState())
 val uiState: StateFlow<QuoteUiState> = _uiState.asStateFlow()
 fun loadQuote() {
 if (_uiState.value.isLoading) return // ignore double taps
 viewModelScope.launch {
 _uiState.update { it.copy(isLoading = true, error = null) }
 try {
 delay(1_500) // stand-in for a network call
 _uiState.update { it.copy(isLoading = false, quote = "Small steps still move you forward.") }
 } catch (e: Exception) {
 _uiState.update { it.copy(isLoading = false, error = "Could not load") }
 }
 }
 }
}

(In real code you would catch narrower exceptions, or rethrow CancellationException, as discussed.) Notice how this reads: set loading, wait, set result. No callbacks. The UI collects uiState exactly as in lesson 24:

kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun QuoteScreen(modifier: Modifier = Modifier, viewModel: QuoteViewModel = viewModel()) {
 val state by viewModel.uiState.collectAsStateWithLifecycle()
 Column(
 modifier = modifier.fillMaxSize().padding(24.dp),
 verticalArrangement = Arrangement.Center,
 horizontalAlignment = Alignment.CenterHorizontally
 ) {
 if (state.isLoading) CircularProgressIndicator() else Text(state.quote)
 state.error?.let { Text(it) }
 Button(onClick = viewModel::loadQuote, modifier = Modifier.padding(top = 16.dp)) {
 Text("New quote")
 }
 }
}

Run it. While waiting the spinner turns; the UI stays responsive (try rotating mid-load: the ViewModel and its coroutine survive).

Launching from a composable: rememberCoroutineScope

Sometimes an event in the UI must start a suspend call that belongs to the UI, for example scrolling a list to the top, which is a suspend function. Use rememberCoroutineScope(), a scope tied to the composable's life:

kotlin
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun ScrollToTopDemo() {
 val listState = rememberLazyListState()
 val scope = rememberCoroutineScope()
 Column {
 Button(onClick = {
 scope.launch { listState.animateScrollToItem(0) }
 }) { Text("Top") }
 LazyColumn(state = listState) {
 items(100) { Text("Row $it") }
 }
 }
}

(Add import androidx.compose.foundation.lazy.items.) The scope is cancelled when the composable leaves the screen. Rules of thumb: business work goes in viewModelScope; work that is purely about UI elements goes in rememberCoroutineScope; and work that must start when a composable appears uses LaunchedEffect (lesson 55).

Never use GlobalScope

GlobalScope.launch { ... } starts a coroutine that lives as long as the whole app, belongs to nobody and is never cancelled. It breaks structured concurrency and leaks work. Avoid it. If work must outlive a screen, use WorkManager (lesson 36) or an application-level scope that you create and own deliberately.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Coroutines in Android Studio. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 10
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Race and pick the winner

In the Playground, write three suspend functions that each delay for a different time and return the name of a "server" (A 800 ms, B 300 ms, C 500 ms). Call all three with async and print the total time and the result of each. Then change the code to run them one after another and compare the times.

kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
suspend fun serverA(): String { delay(800); return "A" }
suspend fun serverB(): String { delay(300); return "B" }
suspend fun serverC(): String { delay(500); return "C" }
fun main() = runBlocking {
 val concurrent = measureTimeMillis {
 val a = async { serverA() }
 val b = async { serverB() }
 val c = async { serverC() }
 println("Results: ${a.await()}, ${b.await()}, ${c.await()}")
 }
 println("Concurrent: about $concurrent ms") // close to 800
 val sequential = measureTimeMillis {
 println("Results: ${serverA()}, ${serverB()}, ${serverC()}")
 }
 println("Sequential: about $sequential ms") // close to 1600
}

Concurrent time is about the longest task (800 ms); sequential time is the sum (1,600 ms). Timings vary slightly each run.

🎯 Your Challenge

Exercise 2: Timeout and cancellation

Write a suspend function slowSearch() that takes five seconds. Call it with withTimeoutOrNull(2000) and print Timed out when it returns null. Explain what happens to the search coroutine.

kotlin
import kotlinx.coroutines.*
suspend fun slowSearch(): String {
 delay(5_000)
 return "Result"
}
fun main() = runBlocking {
 val result = withTimeoutOrNull(2_000) { slowSearch() }
 println(result ?: "Timed out")
}

Timed out

After two seconds withTimeoutOrNull cancels the block. delay is a cancellable suspension point, so slowSearch stops immediately instead of wasting three more seconds. withTimeout (without OrNull) throws TimeoutCancellationException instead of returning null.

🎯 Your Challenge

Exercise 3: Fix the freeze

This ViewModel function freezes the screen. Explain why and fix it.

kotlin
fun importContacts() {
 val text = java.net.URL("https://example.com/contacts.txt").readText()
 _uiState.update { it.copy(contacts = text) }
}

readText() is a blocking network call that runs on the main thread (the caller's thread), causing NetworkOnMainThreadException or a frozen UI. Wrap it in a coroutine and move the blocking part to the IO dispatcher:

kotlin
fun importContacts() {
 viewModelScope.launch {
 val text = withContext(Dispatchers.IO) {
 java.net.URL("https://example.com/contacts.txt").readText()
 }
 _uiState.update { it.copy(contacts = text) }
 }
}

Also remember to catch IOException and add the INTERNET permission, which lesson 29 covers. In practice you would use Retrofit rather than URL.readText().

✏️ Your Task

In the Playground, write a coroutine that prints a message every 200 ms, cancel it after one second, and print how many messages appeared. Then remove the delay and put a busy loop instead; watch cancellation fail to stop it, and fix it with ensureActive().

Phase 9 of 10
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: runBlocking in app code. What happens: it blocks the main thread and freezes the UI. The fix: viewModelScope.launch or rememberCoroutineScope. Use runBlocking only in main() in the Playground and in some tests.

Mistake #2: Thread.sleep instead of delay. What happens: the thread is blocked. The fix: delay.

Mistake #3: GlobalScope.launch. What happens: leaked work that is never cancelled. The fix: use a lifecycle-bound scope.

Mistake #4: Swallowing CancellationException. The wrong code: catch (e: Exception) { log(e) } around a suspend call. What happens: the coroutine ignores cancellation. The fix: catch specific exceptions, or rethrow CancellationException.

Mistake #5: Wrapping launch in try/catch. What happens: the exception escapes anyway. The fix: put the try/catch inside the coroutine.

Mistake #6: Blocking call inside Dispatchers.Main. What happens: the UI freezes even though you are "in a coroutine". The fix: withContext(Dispatchers.IO) for blocking calls, or use main-safe libraries.

Mistake #7: Forgetting await(). What happens: the result is never used and errors are ignored. The fix: every async should be awaited.

Mistake #8: Updating UI state from a background thread. What happens: unpredictable crashes with plain mutable state. The fix: StateFlow.update is thread-safe; with Compose mutableStateOf prefer to update from the main dispatcher.

Mistake #9: Starting the same work twice. What happens: double taps launch two downloads. The fix: a guard on the loading flag, as in loadQuote, or cancel the previous Job before starting a new one.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 10
Knowledge Check

Question 1. Why must the main thread not block?

It draws the UI and handles input. Blocking it makes the app skip frames, freeze, and eventually trigger an ANR dialog.

Question 2. What is the difference between launch and async?

launch starts a coroutine that returns a Job and no value. async returns a Deferred whose result you obtain with await().

Question 3. What does suspend mean?

The function can pause without blocking its thread and resume later. It may only be called from another suspend function or a coroutine.

Question 4. What are the three common dispatchers and their uses?

Main for UI work, IO for blocking input and output such as network and disk, and Default for CPU-heavy computation.

Question 5. What does structured concurrency guarantee?

Coroutines live inside scopes. Cancelling a scope cancels all its children, a parent waits for its children, and failures propagate upward, so work is not leaked.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Parallel Price Checker

Project goal: An app that checks the "prices" of three shops concurrently and shows the cheapest, with a loading state, error handling, a cancel button and no UI freezing.

Requirements:

  1. Three suspend functions shopA, shopB, shopC simulating delays of 1.2 s, 0.8 s and 1.5 s and returning different prices. Make one of them throw an exception on demand (a switch in the UI).
  2. A ViewModel function compare() that starts all three with async inside viewModelScope, waits for them, and exposes the cheapest price.
  3. Loading, success and error states using a sealed UI state.
  4. A Cancel button that cancels the running job and shows Cancelled.
  5. Prevent starting a second comparison while one is running.
  6. A unit test (using the dispatcher rule from lesson 25) proving the total virtual time is about 1.5 s, not 3.5 s.

Stretch goal: make a failing shop not cancel the others; show the cheapest of the successful ones.

PriceViewModel.kt
package com.example.pricecheck
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
sealed interface PriceUiState {
 data object Idle : PriceUiState
 data object Loading : PriceUiState
 data class Success(val cheapestShop: String, val price: Int) : PriceUiState
 data class Error(val message: String) : PriceUiState
 data object Cancelled : PriceUiState
}
class PriceViewModel : ViewModel() {
 private val _state = MutableStateFlow<PriceUiState>(PriceUiState.Idle)
 val state: StateFlow<PriceUiState> = _state.asStateFlow()
 var shopBShouldFail = false
 private var job: Job? = null
 private suspend fun shopA(): Int { delay(1_200); return 5200 }
 private suspend fun shopB(): Int {
 delay(800)
 if (shopBShouldFail) throw IllegalStateException("Shop B is offline")
 return 4900
 }
 private suspend fun shopC(): Int { delay(1_500); return 5100 }
 fun compare() {
 if (job?.isActive == true) return // already running
 job = viewModelScope.launch {
 _state.value = PriceUiState.Loading
 try {
 val result = supervisorScope {
 // supervisorScope: one failing shop does not cancel the others
 val deferred = mapOf(
 "Shop A" to async { shopA() },
 "Shop B" to async { shopB() },
 "Shop C" to async { shopC() }
 )
 deferred.mapNotNull { (name, d) ->
 try { name to d.await() } catch (e: CancellationException) { throw e }
 catch (e: Exception) { null }
 }
 }
 val cheapest = result.minByOrNull { it.second }
 _state.value = if (cheapest == null) PriceUiState.Error("No shop answered")
 else PriceUiState.Success(cheapest.first, cheapest.second)
 } catch (e: CancellationException) {
 _state.value = PriceUiState.Cancelled
 throw e
 }
 }
 }
 fun cancel() { job?.cancel() }
}
PriceViewModelTest.kt
package com.example.pricecheck
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class PriceViewModelTest {
 @get:Rule val mainRule = MainDispatcherRule() // from lesson 25
 @Test
 fun compare_runsShopsConcurrently() = runTest {
 val vm = PriceViewModel()
 vm.compare()
 advanceUntilIdle()
 assertEquals(1_500, currentTime) // longest shop, not the 3,500 ms sum
 val state = vm.state.value
 assertTrue(state is PriceUiState.Success)
 assertEquals("Shop B", (state as PriceUiState.Success).cheapestShop)
 }
}

Notes: supervisorScope makes each async independent, so with shopBShouldFail = true the answer is the cheapest of A and C. The test relies on runTest and the rule sharing one scheduler, as lesson 25 explained; if currentTime reports zero in your setup, make sure MainDispatcherRule uses the same scheduler as runTest (StandardTestDispatcher(testScheduler) pattern from the coroutines test documentation). The cancel path rethrows CancellationException, which is the rule from the lesson. The UI is a button row and a when (state) over the sealed type, as in lesson 29.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 28 project”.

3

On github.com click New repository, name it android-lesson-28-coroutines-and-concurrency, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 28 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: The Main Thread and Why It Must Stay Free
  • Step-by-Step Build 1: Your First Coroutines in the Playground
  • Step-by-Step Build 2: Dispatchers
  • Step-by-Step Build 3: Structured Concurrency
  • Step-by-Step Build 4: Coroutines in Android Studio
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close