How it works
The explanation, with examples you can run.
Dispatchers decide the thread
class ArticleRepository( private val api: ArticleApi, private val io: CoroutineDispatcher = Dispatchers.IO, // inject it so tests can replace it) { suspend fun load(id: String): Article = withContext(io) { val raw = api.fetch(id) // blocking call: fine here, we're on IO parse(raw) }}Dispatchers.Main: the main thread.viewModelScopeandlifecycleScopeuse it (strictly,Dispatchers.Main.immediate).Dispatchers.IO: a large pool for blocking I/O.Dispatchers.Default: one thread per core, for CPU work like sorting or parsing.
The convention is that suspend functions are main-safe: the function that blocks is the one that switches. Libraries like Retrofit and Room already do this for their suspend APIs.
Structured concurrency
Coroutines are launched in a CoroutineScope, and child coroutines belong to their parent. Cancel the scope and everything in it is cancelled; if a child fails, by default the failure cancels its siblings and the parent too. On Android the scopes come with the objects that own the work:
class ArticleViewModel(private val repo: ArticleRepository) : ViewModel() { private val _state = MutableStateFlow<ArticleState>(ArticleState.Loading) val state: StateFlow<ArticleState> = _state fun open(id: String) { viewModelScope.launch { // cancelled in onCleared() _state.value = try { ArticleState.Loaded(repo.load(id)) } catch (e: IOException) { ArticleState.Failed } } }}Avoid GlobalScope: nothing cancels it, and it can't be controlled in tests. Work that must outlive the screen goes to WorkManager or an application-level scope you create and inject. Cancellation is cooperative: suspending calls check for it, but a tight CPU loop should call ensureActive() now and then, and catching CancellationException without rethrowing breaks it.
Flow: values over time
A Flow is a cold stream: nothing runs until someone collects it. StateFlow always has a current value and is the usual way a ViewModel exposes UI state; SharedFlow broadcasts events to any number of collectors.
val articles: StateFlow<List<Article>> = dao.observeAll() // a cold Flow from Room .map { rows -> rows.map(::toArticle) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())WhileSubscribed(5_000) keeps the upstream alive for five seconds after the last collector leaves, long enough to survive a rotation without restarting the query.
Collecting safely
Collect in the UI only while it is visible:
// Compose (lifecycle-runtime-compose)val list by viewModel.articles.collectAsStateWithLifecycle() // ViewslifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.articles.collect { adapter.submitList(it) } }}Both stop collecting below STARTED and resume above it, so a backgrounded app isn't doing UI work for nobody.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.