Coroutines & Concurrency Mastery ⚡🧵
A strict rulebook to prevent thread-blocking, memory leaks, and lost UI events in modern Android applications.
⚡ When to Use
- ViewModel Logic: Launching coroutines for network/database requests.
- One-Time UI Events: Showing Snackbars, Toasts, or Navigation triggers.
- Parallel Work: Running multiple non-blocking tasks.
🚦 Rule 1: The UI Event Dilemma (StateFlow vs Channel)
You must explicitly distinguish between UI State (things that persist on rotation) and UI Events (things that happen once, like a Toast or Navigation).
- UI STATE: Use
MutableStateFlow. - UI EVENTS: Use an un-buffered
Channelexported as aFlow. NEVER useSharedFlowwithreplay = 0orMutableStateFlowfor one-time events, because if the app rotates, the event might be dropped or replayed twice.
The Golden Implementation:
class MyViewModel : ViewModel() {
// 1. UI STATE
private val _uiState = MutableStateFlow(MyState())
val uiState = _uiState.asStateFlow()
// 2. ONE-TIME EVENTS
private val _uiEvent = Channel<MyAction>()
val uiEvent = _uiEvent.receiveAsFlow()
fun triggerSnackbar() {
viewModelScope.launch {
_uiEvent.send(MyAction.ShowSnackbar)
}
}
}
🚥 Rule 2: Dispatcher Discipline
Agents frequently launch heavy database or network calls on the default dispatcher (Dispatchers.Main inside a viewModelScope.launch).
- Rule: If you are using Retrofit or Room, they are naturally "main-safe", so
viewModelScope.launch { }is fine. - Rule: If you are doing manual file I/O, heavy JSON parsing, or bitmap manipulation, you MUST switch to
Dispatchers.IOusingwithContext(Dispatchers.IO) { ... }. - Rule: If you are doing CPU-heavy sorting or filtering on massive lists, use
Dispatchers.Default.
🛑 Rule 3: Structured Concurrency
If an agent executes multiple independent API requests inside a viewModelScope.launch, and one fails, it cancels the entire scope/parent job!
The Anti-Pattern:
viewModelScope.launch {
// If ApiA fails, ApiB is cancelled!
val resultA = apiA.fetch()
val resultB = apiB.fetch()
}
The Architect's Standard (supervisorScope or async):
viewModelScope.launch {
// Both attempt to run. If A fails, B continues successfully.
supervisorScope {
val deferredA = async { apiA.fetch() }
val deferredB = async { apiB.fetch() }
try { val a = deferredA.await() } catch (e: Exception) {}
try { val b = deferredB.await() } catch (e: Exception) {}
}
}
🔋 Rule 4: collectAsStateWithLifecycle
As enforced in our Code Review checklists, NEVER use collectAsState() in Compose UI. Always use collectAsStateWithLifecycle() to ensure Coroutines pause fetching when the screen goes to the background.