Beyond launch and async
Coroutine Scopes and Their Lifecycle
class SearchViewModel(
private val searchRepo: SearchRepository
) : ViewModel() {
private val _results = MutableStateFlow<List<Result>>(emptyList())
val results: StateFlow<List<Result>> = _results.asStateFlow()
// viewModelScope survives configuration changes
fun search(query: String) {
viewModelScope.launch {
// This coroutine is automatically cancelled when
// the ViewModel is cleared (user leaves the screen)
_results.value = searchRepo.search(query)
}
}
}
// In a Composable:
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
// rememberCoroutineScope ties to composition lifecycle
val scope = rememberCoroutineScope()
// Use for UI-only work like animations or snackbars
scope.launch {
snackbarHostState.showSnackbar("Search complete")
}
}Cancellation: The Silent Contract
// BAD: This loop ignores cancellation
suspend fun processLargeList(items: List<Item>) {
for (item in items) {
item.transform() // CPU work, no suspension point
}
}
// GOOD: Check for cancellation in tight loops
suspend fun processLargeList(items: List<Item>) {
for (item in items) {
ensureActive() // Throws if cancelled
item.transform()
}
}
// GOOD: Use yield() to check cancellation and give
// other coroutines a chance to run
suspend fun processLargeList(items: List<Item>) {
for (item in items) {
yield()
item.transform()
}
}Exception Handling Strategies
// PROBLEM: One failure cancels everything
viewModelScope.launch {
launch { loadUserProfile() } // If this throws...
launch { loadUserPosts() } // ...this gets cancelled
launch { loadUserSettings() } // ...and this too
}
// SOLUTION: supervisorScope isolates failures
viewModelScope.launch {
supervisorScope {
launch {
try { loadUserProfile() }
catch (e: Exception) { handleProfileError(e) }
}
launch {
try { loadUserPosts() }
catch (e: Exception) { handlePostsError(e) }
}
launch {
try { loadUserSettings() }
catch (e: Exception) { handleSettingsError(e) }
}
}
}
// ALTERNATIVE: CoroutineExceptionHandler at the top level
val handler = CoroutineExceptionHandler { _, exception ->
analytics.logError(exception)
}
viewModelScope.launch(handler + SupervisorJob()) {
launch { loadUserProfile() }
launch { loadUserPosts() }
}Flow: Reactive Streams Done Right
class SearchViewModel(
private val repo: SearchRepository
) : ViewModel() {
private val _query = MutableStateFlow("")
// debounce + flatMapLatest: classic search pattern
val results: StateFlow<SearchState> = _query
.debounce(300) // Wait 300ms after last keystroke
.filter { it.length >= 2 } // Min query length
.flatMapLatest { query ->
// Cancels previous search when new query arrives
flow {
emit(SearchState.Loading)
try {
val results = repo.search(query)
emit(SearchState.Success(results))
} catch (e: Exception) {
emit(SearchState.Error(e.message))
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = SearchState.Idle
)
fun onQueryChanged(query: String) {
_query.value = query
}
}
sealed interface SearchState {
data object Idle : SearchState
data object Loading : SearchState
data class Success(val results: List<Result>) : SearchState
data class Error(val message: String?) : SearchState
}Testing Coroutines
class SearchViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `search emits loading then results`() = runTest {
val fakeRepo = FakeSearchRepository(
results = listOf(Result("Kotlin"))
)
val viewModel = SearchViewModel(fakeRepo)
viewModel.onQueryChanged("Kotlin")
advanceUntilIdle()
val state = viewModel.results.value
assertIs<SearchState.Success>(state)
assertEquals(1, state.results.size)
}
}Key Takeaways
- 1Structured concurrency binds coroutine lifetime to scope -- preventing leaks automatically.
- 2Use viewModelScope for data operations, rememberCoroutineScope for UI-only work.
- 3Cancellation is cooperative: check ensureActive() in CPU-intensive loops.
- 4Use supervisorScope to isolate failures between independent operations.
- 5flatMapLatest + debounce is the canonical pattern for search-as-you-type.
- 6Inject dispatchers for testability -- never hardcode Dispatchers.IO.
Frequently Asked
What's the difference between launch and async?
launch returns Job for fire-and-forget. async returns Deferred<T> for results. Always call await() on Deferred to get results or propagate exceptions.
How do I handle exceptions in coroutines?
Use try/catch inside coroutines for local handling. Use CoroutineExceptionHandler for global handling. For async, exceptions propagate on await().
Flow vs StateFlow - when to use which?
Flow for cold streams starting fresh per collector. StateFlow for hot streams maintaining state and replaying latest value to new collectors.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.