suspend, Scopes, DispatchersviewModelScope, lifecycleScope, Compose
Thread + Handler: results are posted back into the event queue with
Activity.runOnUiThread(), View.post() or View.postDelayed()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 supportExecutorService + callbacks: works, but turns sequential logic into callback chains
kotlinx.coroutines, already on the classpath through the Jetpack Lifecycle libraries// 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"
}
suspend Functionssuspend marks a function that may pause and resume without blocking a threadsuspend function or from a coroutine builder (launch, async)delay(), a Room query, a Retrofit call) the thread is released; when
the result is ready the code continues right after the callsuspend 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)
CoroutineScope, the scope owns the coroutineJob and a CoroutineContext (dispatcher, name, exception handler)SupervisorJob is used)viewModelScope, lifecycleScope and
rememberCoroutineScope() are providedGlobalScope 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
launch vs. async / awaitviewModelScope.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() itasync: starts a coroutine that produces a value; await() suspends until it is thereCoroutineScope, that is why they only work inside a scoperunBlocking { } blocks the current thread until the coroutine ends: only for main() functions
and tests, never in Android app codeDispatchers.Main: the Android main thread. UI updates, reading state, calling suspend functionsDispatchers.IO: a large thread pool for blocking I/O: files, databases, network streamsDispatchers.Default: one thread per CPU core for CPU-heavy work: sorting, parsing, image processingwithContext(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() }
}
suspend functions are already main-safe: they switch to their own
background executors internally. Wrapping them in withContext(Dispatchers.IO) is redundantviewModelScopeviewModelScope is part of androidx.lifecycle:lifecycle-viewmodel, bound to Dispatchers.Main.immediateonCleared(), i.e. when the ViewModel is finally gone, not on a rotationlifecycleScope (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 backgroundNavHost, 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)
}
}
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, snackbarsLaunchedEffect(key) { }: starts a coroutine when the composable enters the composition and
restarts it whenever key changes. For "do this once the screen is shown"@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 = {}) }
}
}
// 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 stateviewModelScope 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, randomlyviewModelScope.launch hands the insert to Room immediately, so it completesjob.cancel() only sets a flag. The coroutine stops at its next
suspension point (delay, withContext, Room and Retrofit calls all check the flag)ensureActive() or yield() inside, or
check isActiveCancellationException at the suspension point; this is how
structured concurrency unwinds the whole hierarchyfinally blocks run; cleanup that has to suspend goes into withContext(NonCancellable) { }withTimeout(5_000) { } cancels the block after the timeout with a TimeoutCancellationExceptionval 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()
viewModelScope uses a SupervisorJob: a failing coroutine does not cancel its siblings, but an
uncaught exception still crashes the appIOException, HttpException) around the suspending call, as in searchMovies()catch (e: Exception) around a suspend call without rethrowing CancellationException:
it swallows the cancellation and the coroutine continues as if nothing happenedrunCatching { } has the same problem, it catches CancellationException tooasync are deferred until await() is calledCoroutineExceptionHandler in the context is the last resort for logging, not for recoverytry {
movieApi.searchMovies(query)
} catch (e: CancellationException) {
throw e // let structured concurrency do its job
} catch (e: Exception) {
Log.e("Search", "request failed", e)
}
suspend function returns one value, a Flow<T> delivers many values over time: database
updates, sensor readings, text inputflow { } runs only when somebody collects, and again for every collectorcollect { } is a suspend function, it runs until the flow completes or the coroutine is cancelledmap, filter, combine, debounce, flatMapLatestfun 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
}
// 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 flowmovie table changes: the UI
never has to "refresh" by handoverride fun getAllMovies(): Flow<List<Movie>> = movieDao.getAllMovies()combine(moviesFlow, filterFlow) { movies, filter -> ... } merges it with other state if neededStateFlow<T> always has a current value (.value) and emits it to every new collector immediatelyMutableStateFlow(initial) when the ViewModel sets the value itself, stateIn() to turn an
existing cold flow (Room) into oneSharedFlow: a hot flow without a current value, for one-off events (show a snackbar, navigate)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
}
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 viewModelScopestarted = 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 queryinitialValue: what the UI shows before the first database result arrives// 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 screenSTARTED and stops in the background. This is
what makes WhileSubscribed workcollectAsState() 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 stopsNetworkType, BatteryNotLow, RequiresCharging, DeviceIdle, StorageNotLow.
The work runs only when all of them are metResult.retry() and a backoff policy (LINEAR / EXPONENTIAL, at least 10 seconds),
chaining of several requestsAlarmManager)# 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.
CoroutineWorkerclass 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)applicationContext as MovieTrackerApplicationval 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 workenqueueUniqueWork(name, ExistingWorkPolicy.KEEP, request) avoids duplicatesgetWorkInfoByIdFlow(request.id) returns a Flow<WorkInfo> to observe the state in a ViewModel// 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 waitMovieRepository is an interface, the test uses a fake instead of Room (see lecture 11)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-safeSearchViewModel.searchMovies(): viewModelScope.launch, loading state, try/catch for
IOException and HttpExceptionsuspend functions pause instead of blockingviewModelScope,
lifecycleScope, rememberCoroutineScope() and LaunchedEffectDispatchers.Main / IO / Default decide where code runs, withContext switches. Room and
Retrofit suspend functions are main-safe alreadyCancellationExceptionFlow is a cold stream of values, StateFlow a hot state holder. Room emits flows, stateIn
turns them into UI state, collectAsStateWithLifecycle() brings them into ComposerunTest from kotlinx-coroutines-test makes coroutine code unit-testablesuspend mean, and how is it different from blocking a thread?viewModelScope, rememberCoroutineScope() and LaunchedEffect?catch (e: Exception) around a suspend call dangerous?Flow and a StateFlow? What do the three parameters of
stateIn do?collectAsStateWithLifecycle() instead of collectAsState()?