Advanced Kotlin: Scope Functions, Coroutines and Flow

Android Development with Kotlin: 21 Real Projects · Kotlin and Android in Depth · 4 min read · Updated

Structured concurrency

  1. The ViewModel starts work in its own scope.
  2. Coroutines run the calls without blocking the main thread.
  3. Results go into a StateFlow that always holds the latest value.
  4. The screen collects it with the lifecycle. If the ViewModel is cleared, the work is cancelled.

Most Android bugs in real apps come from threading and state, so this lesson matters. It covers the Kotlin features that make code short, then the whole coroutine toolkit with the rules that keep it safe.

Extension, inline and scope functions

An extension function adds a function to an existing type without changing it: fun String.isEmail() = contains("@"). A higher-order function takes or returns a function. inline copies the function body and lambda into the call site, which avoids creating objects. The five scope functions run a block on an object and differ in how you refer to it and what they return.

  • let: object is it, returns the block result. Used for null checks: name?.let { save(it) }.
  • run: object is this, returns the block result.
  • with(obj) { ... }: like run, but obj is passed in.
  • apply: object is this, returns the object. Used for setting up: Intent().apply { action = "x" }.
  • also: object is it, returns the object. Used for side effects such as logging.

Coroutines: the model

A coroutine is a light task that can suspend (pause) without blocking a thread. A suspend function can only be called from a coroutine or another suspend function. Every coroutine lives in a CoroutineScope. In Android you rarely create scopes: viewModelScope ends when the ViewModel is cleared, and lifecycleScope ends with the lifecycle. This is structured concurrency: children cannot outlive the parent, so work is cancelled with its screen and does not leak.

class DetailViewModel(private val repo: Repo) : ViewModel() {
    fun load(id: Int) {
        viewModelScope.launch {
            val user = repo.user(id)          // suspend, runs off the main thread inside
            val posts = repo.posts(id)
            // update state here
        }
    }
}

Dispatchers, async and cancellation

  • Dispatchers.Main: UI work.
  • Dispatchers.IO: disk and network.
  • Dispatchers.Default: heavy calculation.
  • Use withContext(Dispatchers.IO) { ... } inside a suspend function to switch thread and come back.

launch starts a job and returns it. async starts work that returns a value and you collect it with await(). Use async to run independent calls in parallel.

suspend fun loadScreen(): Screen = coroutineScope {
    val user = async { api.user() }
    val feed = async { api.feed() }
    Screen(user.await(), feed.await())     // both calls run at the same time
}

Cancelling a job sets a flag. Suspending functions in the standard libraries check it, but a long loop of your own must call ensureActive() or yield(). Never swallow CancellationException in a catch block. For errors, use try/catch inside the coroutine. An exception in a launch child cancels its parent; supervisorScope or SupervisorJob keeps siblings alive.

Flow, StateFlow and SharedFlow

A Flow is a stream of values over time: flow { emit(1); emit(2) }. It is *cold*: it starts when someone collects it. Operators such as map, filter, debounce, distinctUntilChanged, combine and flatMapLatest transform it. StateFlow is a *hot* flow that always holds one current value, the right tool for UI state. SharedFlow has no current value and sends events to all collectors, but one-off events are often better modelled as state. A Channel passes values between coroutines, one receiver per value.

val query = MutableStateFlow("")

val results: StateFlow<List<Item>> = query
    .debounce(300)
    .distinctUntilChanged()
    .flatMapLatest { text -> repo.search(text) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

stateIn turns a cold flow into StateFlow. WhileSubscribed(5000) keeps it running for five seconds after the last collector goes away, which survives a rotation and stops work in the background. In Compose, read it with collectAsStateWithLifecycle().

Thread safety

  • Share data between coroutines through immutable objects, StateFlow or a Mutex.
  • Avoid mutable shared variables across threads. If you must, use Mutex or atomic types.
  • Never call blocking code (Thread.sleep, blocking network) on the main dispatcher.

Your task

  1. Write an extension function fun Int.toRupees(): String and use it.
  2. Use async to run two delay calls in parallel and measure that the total is about the longer one.
  3. Build a search flow with debounce and flatMapLatest against a fake repository.
  4. Cancel a long loop of your own with ensureActive().

How to check it worked

  • You can explain why viewModelScope prevents leaks.
  • You can choose the right dispatcher for a network call and for a sort.
  • You know when to use StateFlow and when SharedFlow.

Frequently asked questions

launch or async?

Use launch for work that returns nothing. Use async when you need a value back, especially to run several calls in parallel.

LiveData or Flow?

New code uses Flow and StateFlow. LiveData still appears in older apps and works with the XML UI, and it is easy to convert with asLiveData and asFlow.

What is structured concurrency?

Coroutines are tied to a scope, so a parent waits for its children and cancelling the parent cancels them all. Work cannot be forgotten and leak.