Kotlin Coroutines and Flow: Practical Patterns for Android

Coroutines and Flow are now the standard async foundation for Android apps. This guide focuses on the practical patterns you will use every day — not theory, but the actual code that solves real problems.

ViewModel and viewModelScope

Always launch coroutines from viewModelScope inside ViewModels. This scope is automatically cancelled when the ViewModel is cleared, preventing leaks.

class SearchViewModel(private val repo: SearchRepo) : ViewModel() {
    private val _results = MutableStateFlow<List<Result>>(emptyList())
    val results: StateFlow<List<Result>> = _results.asStateFlow()

    fun search(query: String) {
        viewModelScope.launch {
            _results.value = repo.search(query)
        }
    }
}

StateFlow vs SharedFlow

  • StateFlow — holds the current value, replays it to new collectors. Use for UI state.
  • SharedFlow — does not hold state, can replay N events. Use for one-shot events (navigation, toasts).
// One-shot events via SharedFlow
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()

fun onSubmit() = viewModelScope.launch {
    _events.emit(UiEvent.NavigateToHome)
}

Combining Flows

combine merges multiple flows and emits whenever any source emits. Essential for UIs that depend on multiple data sources.

val uiState: StateFlow<HomeUiState> = combine(
    repo.user,
    repo.feed,
    repo.notifications
) { user, feed, notifs ->
    HomeUiState(user = user, feed = feed, unreadCount = notifs.size)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())

Error Handling

Never let exceptions silently swallow in coroutines. Use runCatching or a CoroutineExceptionHandler at the launch site.

viewModelScope.launch {
    runCatching { repo.refresh() }
        .onFailure { e -> _error.value = e.message }
}

Testing Flows with Turbine

The Turbine library makes Flow testing concise and readable.

@Test
fun `search returns results`() = runTest {
    viewModel.results.test {
        viewModel.search("android")
        assertEquals(expectedList, awaitItem())
        cancelAndIgnoreRemainingEvents()
    }
}

Key Rules

  • Use Dispatchers.IO for network and database work.
  • Prefer stateIn(WhileSubscribed(5000)) on StateFlows shared to UI — the 5-second grace period survives screen rotations.
  • Avoid GlobalScope — it does not cancel and causes leaks.
  • Use flowOn to change the context of cold flows, not withContext.