Open menu with table of contents Asynchronous Programming
Logo of Stuttgart Media University for light theme Logo of Stuttgart Media University for dark theme
Android Development

Asynchronous Programming

Stuttgart Media University

1 Agenda

  • General Rules for Asynchronous Work
  • The Main / UI Thread
  • Legacy: Threads, AsyncTask and Loaders
  • Kotlin Coroutines: suspend, Scopes, Dispatchers
  • Coroutines on Android: viewModelScope, lifecycleScope, Compose
  • Cancellation and Exceptions
  • Flow and StateFlow
  • WorkManager
  • Testing Coroutines
  • Where you will use this

2 General Rules for Asynchronous Work

  • Never block the main / UI thread
  • Do long-running work (network, database, files, heavy computation) in the background
  • Update the UI only from the main thread, and only with complete, consistent data
  • Tie background work to a lifecycle: it must stop when nobody needs its result anymore
  • Take into account configuration changes (rotation), app restarts and process death
  • Expect errors and slow responses: show loading and error states, allow retries

3 The Main / UI Thread

  • Android draws the user interface and handles input events on one single thread: the main thread, also called the UI thread
  • All events (touches, key presses, lifecycle callbacks, drawing a frame) are collected in a queue and processed one after the other on the main thread

center

4 Blocking the Main Thread

  • A long-running task on the main thread (network call, database query, parsing a big file) blocks the queue: touches are not processed, no frame is drawn, the app freezes
  • If the main thread is blocked for about 5 seconds the system shows the "Application Not Responding" (ANR) dialog and the user can kill the app
  • Much shorter blocks are visible too: a frame has to be drawn every 16 ms (60 fps), everything slower is a dropped frame ("jank")

center

5 Work in the Background

  • Long-running work runs in the background, on another thread, and only its result is handed back to the main thread
  • But beware: the UI must not be manipulated outside the main thread. Classic Views throw an exception, Compose state can technically be written from any thread, but the data you show must be complete and consistent
  • The question of this lecture: how do we get there without writing threads by hand?

center

6 Legacy: Threads, AsyncTask and Loaders

  • Plain Thread + Handler: results are posted back into the event queue with Activity.runOnUiThread(), View.post() or View.postDelayed()
  • No lifecycle awareness: the thread outlives the Activity after a rotation, leaks it and writes into a UI that is already gone
  • AsyncTask: deprecated since API 30 (Android 11); same leak problem, one serial executor, callbacks in onPostExecute()
  • AsyncTaskLoader / LoaderManager: AndroidX legacy, tied to Activities and Fragments, no Compose support
  • ExecutorService + callbacks: works, but turns sequential logic into callback chains
  • You will still meet all of them in older code bases and Stack Overflow answers
  • Today: Kotlin Coroutines for everything inside the app process, WorkManager for work that must survive the process

center 60%

7 Kotlin Coroutines

  • A coroutine is a piece of code that can be suspended and resumed later, without blocking the thread it runs on
  • Looks like sequential code, behaves like asynchronous code: no callbacks
  • Very cheap: thousands of coroutines can share a handful of threads
  • Library kotlinx.coroutines, already on the classpath through the Jetpack Lifecycle libraries
  • The Android libraries are built on them: Room, Retrofit, DataStore, WorkManager, Compose
// blocking: the thread waits 2 seconds and cannot do anything else in the meantime
fun loadBlocking(): String {
    Thread.sleep(2_000)
    return "data"
}

// suspending: the coroutine is paused, the thread is free to draw frames and handle input
suspend fun loadSuspending(): String {
    delay(2_000)
    return "data"
}

8 suspend Functions

  • suspend marks a function that may pause and resume without blocking a thread
  • Can only be called from another suspend function or from a coroutine builder (launch, async)
  • At every suspension point (delay(), a Room query, a Retrofit call) the thread is released; when the result is ready the code continues right after the call
  • The compiler turns the function into a state machine, you never see any of it
  • Rule: a suspend function should be main-safe, i.e. safe to call from the main thread, because it moves blocking work to a background dispatcher itself
// network/MovieApi.kt (assignment 5): Retrofit generates the implementation
@GET("/")
suspend fun searchMovies(@Query("s") searchString: String): SearchResponse

// model/MovieDao.kt (assignment 4): Room generates the implementation
@Insert
suspend fun insert(movie: Movie)

9 CoroutineScope and Structured Concurrency

  • Every coroutine is started in a CoroutineScope, the scope owns the coroutine
  • A scope carries a Job and a CoroutineContext (dispatcher, name, exception handler)
  • Cancelling the scope cancels every coroutine started in it: no leaks, no forgotten background work
  • A parent coroutine waits for all of its children; a failing child cancels its siblings (unless a SupervisorJob is used)
  • On Android you almost never create a scope yourself: viewModelScope, lifecycleScope and rememberCoroutineScope() are provided
  • GlobalScope is the anti-pattern: nobody owns it, nothing is ever cancelled
// a scope that lives as long as the object owning it
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

scope.launch { /* work */ }
scope.cancel()        // cancels everything that was launched in this scope

10 launch vs. async / await

viewModelScope.launch {                      // fire and forget, returns a Job
    movieRepository.insert(movie)
}

viewModelScope.launch {
    // two independent requests in parallel, each returns a Deferred<SearchResponse>
    val matrix = async { movieApi.searchMovies("Matrix") }
    val her = async { movieApi.searchMovies("Her") }
    val movies = (matrix.await().search ?: emptyList()) + (her.await().search ?: emptyList())
}
  • launch: starts a coroutine that returns no value; the Job lets you cancel or join() it
  • async: starts a coroutine that produces a value; await() suspends until it is there
  • Both are extension functions on CoroutineScope, that is why they only work inside a scope
  • runBlocking { } blocks the current thread until the coroutine ends: only for main() functions and tests, never in Android app code

11 Dispatchers: Where Does the Code Run?

  • Dispatchers.Main: the Android main thread. UI updates, reading state, calling suspend functions
  • Dispatchers.IO: a large thread pool for blocking I/O: files, databases, network streams
  • Dispatchers.Default: one thread per CPU core for CPU-heavy work: sorting, parsing, image processing
  • withContext(dispatcher) { } runs a block on another dispatcher and comes back with its result. This is how a function is made main-safe
// main-safe: the caller may be on Dispatchers.Main, the file is read on the IO pool
suspend fun readFile(context: Context, name: String): String =
    withContext(Dispatchers.IO) {
        context.openFileInput(name).bufferedReader().use { it.readText() }
    }
  • Room and Retrofit suspend functions are already main-safe: they switch to their own background executors internally. Wrapping them in withContext(Dispatchers.IO) is redundant

12 Scopes on Android: viewModelScope

  • viewModelScope is part of androidx.lifecycle:lifecycle-viewmodel, bound to Dispatchers.Main.immediate
  • Cancelled automatically in onCleared(), i.e. when the ViewModel is finally gone, not on a rotation
  • The default place for everything the UI triggers: load data, save data, call an API
  • lifecycleScope (Activity / Fragment, lifecycle-runtime-ktx) is cancelled when the lifecycle is destroyed. Collect flows there with repeatOnLifecycle(Lifecycle.State.STARTED) { } so they stop in the background
  • In the MovieTracker app the Activity only hosts the NavHost, so lifecycleScope is not needed
// ui/home/HomeViewModel.kt (assignment 4)
fun deleteMovie(movie: Movie) {
    // viewModelScope is cancelled when the ViewModel is cleared, not when the screen leaves
    viewModelScope.launch {
        movieRepository.delete(movie)
    }
}

13 Scopes in Compose

  • rememberCoroutineScope(): a scope bound to the composable's place in the composition, cancelled when it leaves. For work the user starts inside the UI: scroll animations, snackbars
  • LaunchedEffect(key) { }: starts a coroutine when the composable enters the composition and restarts it whenever key changes. For "do this once the screen is shown"
  • Both end with the composable, so business logic (save to the database, API call) does not belong there, it belongs to the ViewModel
@Composable
fun ScrollingMovieList(movies: List<Movie>) {
    val listState = rememberLazyListState()
    val scope = rememberCoroutineScope()

    LaunchedEffect(movies.size) {                  // runs again whenever the size changes
        listState.animateScrollToItem(0)
    }
    Button(onClick = { scope.launch { listState.animateScrollToItem(0) } }) { Text("Top") }
    LazyColumn(state = listState) {
        items(movies, key = { it.imdbId }) { movie -> MovieItem(movie = movie, onMovieClick = {}) }
    }
}

14 Example: Network Call in a ViewModel

// ui/search/SearchViewModel.kt (assignment 5)
fun searchMovies(searchString: String) {
    viewModelScope.launch {
        searchUiState = SearchUiState(isLoading = true)
        searchUiState = try {
            // Network call: Retrofit runs it on a background thread, we only suspend here
            val response = movieApi.searchMovies(searchString)
            SearchUiState(
                movieList = response.search ?: emptyList(),
                errorMessage = response.error
            )
        } catch (e: IOException) {
            // No network, timeout, ...
            SearchUiState(errorMessage = e.message ?: "Network error")
        } catch (e: HttpException) {
            // Server answered with an error code, e.g. 401 when the API key is wrong
            SearchUiState(errorMessage = "HTTP ${e.code()} ${e.message()}")
        }
    }
}
  • try is an expression in Kotlin: every branch yields the new UI state
  • The loading state is set before the suspension point, the result after it, both on the main thread

15 Example: Why viewModelScope and Not the Composable's Scope?

// ui/search/SearchScreen.kt: the click handler saves and leaves the screen right away
MovieList(
    movieList = searchUiState.movieList,
    onMovieClick = { movie ->
        viewModel.saveMovie(movie)
        navigateBack()
    }
)

// ui/search/SearchViewModel.kt
fun saveMovie(movie: Movie) {
    viewModelScope.launch {
        movieRepository.insert(movie)
    }
}
  • navigateBack() removes SearchScreen from the composition. A coroutine started with rememberCoroutineScope() there is cancelled at that moment: the insert is lost or not, randomly
  • The ViewModel outlives the composable: it is cleared only when its back stack entry is gone, and viewModelScope.launch hands the insert to Room immediately, so it completes
  • Rule of thumb: anything that must finish belongs to a ViewModel (or WorkManager), not to the UI

16 Cancellation

  • Cancellation is cooperative: job.cancel() only sets a flag. The coroutine stops at its next suspension point (delay, withContext, Room and Retrofit calls all check the flag)
  • A CPU loop without suspension points never stops: call ensureActive() or yield() inside, or check isActive
  • A cancelled coroutine throws a CancellationException at the suspension point; this is how structured concurrency unwinds the whole hierarchy
  • finally blocks run; cleanup that has to suspend goes into withContext(NonCancellable) { }
  • withTimeout(5_000) { } cancels the block after the timeout with a TimeoutCancellationException
val job = viewModelScope.launch(Dispatchers.Default) {
    var sum = 0L
    for (i in 0 until 1_000_000) {
        ensureActive()          // throws CancellationException once the job is cancelled
        sum += i.toLong() * i
    }
}
job.cancel()                    // the loop stops at the next ensureActive()

17 Exceptions in Coroutines

  • viewModelScope uses a SupervisorJob: a failing coroutine does not cancel its siblings, but an uncaught exception still crashes the app
  • Catch specific exceptions (IOException, HttpException) around the suspending call, as in searchMovies()
  • Never catch (e: Exception) around a suspend call without rethrowing CancellationException: it swallows the cancellation and the coroutine continues as if nothing happened
  • runCatching { } has the same problem, it catches CancellationException too
  • Exceptions in async are deferred until await() is called
  • A CoroutineExceptionHandler in the context is the last resort for logging, not for recovery
try {
    movieApi.searchMovies(query)
} catch (e: CancellationException) {
    throw e                      // let structured concurrency do its job
} catch (e: Exception) {
    Log.e("Search", "request failed", e)
}

18 Flow: Streams of Values

  • A suspend function returns one value, a Flow<T> delivers many values over time: database updates, sensor readings, text input
  • Flows are cold: the code inside flow { } runs only when somebody collects, and again for every collector
  • collect { } is a suspend function, it runs until the flow completes or the coroutine is cancelled
  • Operators transform flows lazily: map, filter, combine, debounce, flatMapLatest
  • Flows respect structured concurrency: cancel the scope and the collection stops
fun countdown(): Flow<Int> = flow {
    for (i in 3 downTo 1) {
        emit(i)                   // emit is suspend: waits until the collector is ready
        delay(1_000)
    }
}

viewModelScope.launch {
    countdown()
        .map { it * 10 }
        .filter { it > 10 }
        .collect { value -> Log.d("Flow", "$value") }   // 30, 20
}

19 Flow from Room

// model/MovieDao.kt (assignment 4)
@Dao
interface MovieDao {
    // Returning a Flow makes Room emit a new list every time the table changes
    @Query("SELECT * FROM movie ORDER BY id")
    fun getAllMovies(): Flow<List<Movie>>

    @Insert
    suspend fun insert(movie: Movie)

    @Delete
    suspend fun delete(movie: Movie)
}
  • getAllMovies() is not suspend: calling it is cheap, it only creates the flow
  • Room runs the query on its own executor and re-runs it whenever the movie table changes: the UI never has to "refresh" by hand
  • The repository passes it through: override fun getAllMovies(): Flow<List<Movie>> = movieDao.getAllMovies()
  • combine(moviesFlow, filterFlow) { movies, filter -> ... } merges it with other state if needed

20 StateFlow: A Hot State Holder

  • StateFlow<T> always has a current value (.value) and emits it to every new collector immediately
  • Hot: it exists independently of collectors. Conflated: only the latest value matters
  • The natural type for UI state: "what does the screen show right now"
  • MutableStateFlow(initial) when the ViewModel sets the value itself, stateIn() to turn an existing cold flow (Room) into one
  • SharedFlow: a hot flow without a current value, for one-off events (show a snackbar, navigate)
  • Compared to mutableStateOf: a StateFlow is independent of Compose and can be collected anywhere (unit tests, other flows, Views)
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()   // read-only view for the UI

fun onQueryChange(text: String) {
    _query.value = text
}

21 stateIn: The Real HomeViewModel

// ui/home/HomeViewModel.kt (assignment 4)
class HomeViewModel(private val movieRepository: MovieRepository) : ViewModel() {

    val homeUiState: StateFlow<HomeUiState> = movieRepository.getAllMovies()
        .map { HomeUiState(it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
            initialValue = HomeUiState()
        )

    companion object {
        private const val TIMEOUT_MILLIS = 5_000L
    }
}

data class HomeUiState(val movieList: List<Movie> = emptyList())
  • scope: the collection of the Room flow lives in viewModelScope
  • started = WhileSubscribed(5_000): Room is queried only while the UI collects, and stopped 5 s after the last collector left, so a rotation does not restart the query
  • initialValue: what the UI shows before the first database result arrives

22 Collecting a Flow in Compose

// ui/home/HomeScreen.kt (assignment 4)
@Composable
fun HomeScreen(
    onSearchClick: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    // Collects the StateFlow as Compose state, only while the screen is at least STARTED
    val homeUiState by viewModel.homeUiState.collectAsStateWithLifecycle()

    Column(modifier = modifier) {
        MovieList(
            movieList = homeUiState.movieList,
            onMovieClick = { movie -> viewModel.deleteMovie(movie) }
        )
    }
}
  • collectAsStateWithLifecycle() (lifecycle-runtime-compose) turns the flow into a State<T>, every new value recomposes the screen
  • It collects only while the lifecycle is at least STARTED and stops in the background. This is what makes WhileSubscribed work
  • collectAsState() from the Compose runtime collects as long as the composable exists, also when the app is in the background: wasted work, the Room query never stops

23 WorkManager: Work That Must Survive the Process

  • Coroutines die with the process. For deferrable, guaranteed work use WorkManager: upload a log, sync with a server, periodic cleanup
  • Persistent: requests are stored in a database and run even after a reboot or after the system killed the app
  • Three types of work: immediate (start now, finish soon; may be expedited), long running (longer than 10 minutes, runs as a foreground service with a notification), deferrable (later, optionally periodic with at least 15 minutes interval)
  • Constraints: NetworkType, BatteryNotLow, RequiresCharging, DeviceIdle, StorageNotLow. The work runs only when all of them are met
  • Retries with Result.retry() and a backoff policy (LINEAR / EXPONENTIAL, at least 10 seconds), chaining of several requests
  • Not for: work the user is waiting for right now (coroutines), exact timing (AlarmManager)
# gradle/libs.versions.toml
[versions]
work = "2.12.0"
[libraries]
androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" }

implementation(libs.androidx.work.runtime). Since WorkManager 2.9 CoroutineWorker lives in work-runtime, the former work-runtime-ktx artifact is empty.

24 CoroutineWorker

class UploadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {

    // suspend: delay, Room and Retrofit calls work here; runs on Dispatchers.Default by default
    override suspend fun doWork(): Result {
        val title = inputData.getString(KEY_TITLE) ?: return Result.failure()
        return try {
            upload(title)
            Result.success()
        } catch (e: IOException) {
            Result.retry()          // tries again according to the backoff criteria of the request
        }
    }

    private suspend fun upload(title: String) { /* network call */ }

    companion object {
        const val KEY_TITLE = "title"
    }
}
  • Result.success(), Result.failure(), Result.retry(); output via Result.success(workDataOf(...)) (max. 10 KB)
  • WorkManager creates the worker, so there is no ViewModel: get dependencies through applicationContext as MovieTrackerApplication

25 Scheduling Work

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val request = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(constraints)
    .setInputData(workDataOf(UploadWorker.KEY_TITLE to movie.title))
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .build()

WorkManager.getInstance(context).enqueue(request)
  • PeriodicWorkRequestBuilder<UploadWorker>(15, TimeUnit.MINUTES) for repeating work
  • enqueueUniqueWork(name, ExistingWorkPolicy.KEEP, request) avoids duplicates
  • getWorkInfoByIdFlow(request.id) returns a Flow<WorkInfo> to observe the state in a ViewModel
  • Guide: WorkManager basics

26 Testing Coroutines

// testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
class HomeViewModelTest {

    @Test
    fun deleteMovie_removesTheMovie() = runTest {
        Dispatchers.setMain(StandardTestDispatcher(testScheduler))  // viewModelScope uses Main
        val repository = FakeMovieRepository()                      // in-memory MovieRepository
        val movie = Movie(title = "Goldfinger", year = "1964", actor = "Sean Connery")
        repository.insert(movie)
        val viewModel = HomeViewModel(repository)

        viewModel.deleteMovie(movie)
        advanceUntilIdle()                                          // run all pending coroutines

        assertEquals(emptyList<Movie>(), repository.getAllMovies().first())
        Dispatchers.resetMain()
    }
}
  • runTest runs the test body in a coroutine with a virtual clock: delay() does not wait
  • Because MovieRepository is an interface, the test uses a fake instead of Room (see lecture 11)
  • Guide: Testing Kotlin coroutines on Android

27 Where You Will Use This

Assignment 4 (MVVM + Room):

  • MovieDao: getAllMovies(): Flow<List<Movie>>, suspend fun insert() / delete()
  • HomeViewModel: stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUiState())
  • HomeScreen: collectAsStateWithLifecycle()
  • saveMovie() / deleteMovie(): viewModelScope.launch { movieRepository.insert(movie) }

Assignment 5 (Retrofit + Coil):

  • MovieApi.searchMovies() is a suspend function, Retrofit makes it main-safe
  • SearchViewModel.searchMovies(): viewModelScope.launch, loading state, try/catch for IOException and HttpException

28 Summary

  • Everything that takes longer than a few milliseconds must leave the main thread, otherwise frames drop and the ANR dialog appears
  • Kotlin Coroutines make asynchronous code look sequential: suspend functions pause instead of blocking
  • Structured concurrency: every coroutine lives in a scope; on Android these are viewModelScope, lifecycleScope, rememberCoroutineScope() and LaunchedEffect
  • Dispatchers.Main / IO / Default decide where code runs, withContext switches. Room and Retrofit suspend functions are main-safe already
  • Catch specific exceptions, never swallow CancellationException
  • Flow is a cold stream of values, StateFlow a hot state holder. Room emits flows, stateIn turns them into UI state, collectAsStateWithLifecycle() brings them into Compose
  • WorkManager is for deferrable work that must survive the process
  • runTest from kotlinx-coroutines-test makes coroutine code unit-testable

29 Recap Questions

  • Why must long-running work not run on the main thread, and what happens if it does anyway?
  • What does suspend mean, and how is it different from blocking a thread?
  • When do you use viewModelScope, rememberCoroutineScope() and LaunchedEffect?
  • Why is catch (e: Exception) around a suspend call dangerous?
  • What is the difference between a cold Flow and a StateFlow? What do the three parameters of stateIn do?
  • Why collectAsStateWithLifecycle() instead of collectAsState()?
  • When would you use WorkManager instead of a coroutine?

Questions?