ntation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
}
### Step 2: Repository Layer with Suspend Functions
Repository methods should be `suspend` functions. They handle I/O work and automatically suspend without blocking threads.
```kotlin
class UserRepository @Inject constructor(
private val api: UserApi,
private val dao: UserDatabaseDao
) {
suspend fun syncUserPreferences(): Result<Unit> = try {
val remotePrefs = api.fetchPreferences()
withContext(Dispatchers.IO) {
dao.insertAll(remotePrefs)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
Key decisions:
suspend marks the function as coroutine-compatible.
withContext(Dispatchers.IO) switches to a shared I/O thread pool for database operations.
Result wraps success/failure without relying on exceptions for control flow.
Step 3: ViewModel with Structured Scope
ViewModels should expose StateFlow and launch coroutines inside viewModelScope.
class PreferencesViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<PreferencesUiState>(PreferencesUiState.Loading)
val uiState: StateFlow<PreferencesUiState> = _uiState.asStateFlow()
fun loadPreferences() {
viewModelScope.launch {
_uiState.value = PreferencesUiState.Loading
val result = repository.syncUserPreferences()
_uiState.value = when {
result.isSuccess -> PreferencesUiState.Success(result.getOrNull()!!)
else -> PreferencesUiState.Error(result.exceptionOrNull()!!)
}
}
}
}
sealed class PreferencesUiState {
object Loading : PreferencesUiState()
data class Success(val data: Unit) : PreferencesUiState()
data class Error(val throwable: Throwable) : PreferencesUiState()
}
Rationale:
viewModelScope is automatically cancelled when the ViewModel is cleared, preventing memory leaks.
StateFlow provides cold, lifecycle-aware state emission without manual subscription management.
- State is updated on the main thread by default, matching Android UI requirements.
Step 4: UI Collection with Lifecycle Awareness
Activities/Fragments should collect flows using repeatOnLifecycle to ensure emissions only occur when the view is visible.
class PreferencesFragment : Fragment(R.layout.fragment_preferences) {
private val viewModel: PreferencesViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when (state) {
is PreferencesUiState.Loading -> showLoading()
is PreferencesUiState.Success -> showData(state.data)
is PreferencesUiState.Error -> showError(state.throwable)
}
}
}
}
viewModel.loadPreferences()
}
}
Architecture decision: repeatOnLifecycle cancels the collector when the fragment stops, preventing unnecessary work and state updates during background execution. This pairs with viewModelScope to create a complete lifecycle-bound concurrency chain.
Pitfall Guide
1. Leaking CoroutineScopes
Mistake: Using GlobalScope.launch or creating custom scopes that outlive Android components.
Impact: Background work continues after the UI is destroyed, causing memory leaks, stale data updates, and crashes when accessing detached views.
Fix: Always use viewModelScope, lifecycleScope, or viewLifecycleOwner.lifecycleScope. Never break the coroutine tree.
2. Blocking the Main Thread
Mistake: Running CPU-intensive operations or synchronous network calls on Dispatchers.Main or using runBlocking in production code.
Impact: ANRs, dropped frames, and unresponsive UI. runBlocking blocks the calling thread until completion and should only be used in tests.
Fix: Wrap blocking work in withContext(Dispatchers.Default) for CPU tasks or Dispatchers.IO for I/O. Let suspend functions handle suspension automatically.
3. Ignoring Cancellation Semantics
Mistake: Writing long-running loops or I/O operations that don't check for cancellation, or using non-cancellable suspend functions like Thread.sleep().
Impact: Orphaned coroutines consume resources and may mutate state after the UI is gone.
Fix: Use isActive checks in loops, prefer delay() over Thread.sleep(), and ensure library functions you call are cancellation-aware. Use withTimeout() or withTimeoutOrNull() for bounded execution.
4. Mixing LiveData and Flow Incorrectly
Mistake: Converting StateFlow to LiveData via asLiveData() in ViewModels, or using LiveData for streams that require backpressure.
Impact: Unnecessary overhead, loss of coroutine context, and missing cancellation signals.
Fix: Use StateFlow or SharedFlow directly in the UI. LiveData lacks cancellation awareness and should be deprecated in new coroutine-based codebases.
5. Unhandled Exceptions in launch vs async
Mistake: Assuming launch propagates exceptions to the caller, or using async for fire-and-forget tasks.
Impact: launch fails silently unless a CoroutineExceptionHandler is attached. async throws exceptions only when .await() is called, which can be missed.
Fix: Use launch for side-effects with explicit error handling or a handler. Use async only when you need a return value, and always call .await() inside a try-catch block.
6. Overusing supervisorScope Without Understanding Failure Isolation
Mistake: Wrapping everything in supervisorScope to prevent child cancellation from affecting siblings, masking underlying dependency issues.
Impact: Partial failures go unnoticed, leading to inconsistent UI states or corrupted data.
Fix: Reserve supervisorScope for independent parallel tasks (e.g., fetching user profile and settings simultaneously). For dependent tasks, use standard coroutineScope so failures cascade appropriately.
7. Forgetting Dispatcher Boundaries in Tests
Mistake: Running coroutine tests without replacing dispatchers, causing flaky timing or main-thread violations.
Impact: Tests fail intermittently or never complete due to unmocked I/O/Main dispatchers.
Fix: Use runTest from kotlinx-coroutines-test, inject TestDispatcher, and set Dispatchers.setMain(testDispatcher) during test setup.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Independent parallel network calls | supervisorScope + async | Isolates failures; prevents one crash from cancelling others | Low CPU, higher memory temporarily |
| Dependent sequential API calls | coroutineScope + async | Fails fast; maintains data consistency | Minimal overhead |
| UI state emission | StateFlow | Cold, lifecycle-aware, single-value replay | Negligible |
| Event-based UI triggers (toasts, navigation) | SharedFlow with replay=0 | Prevents stale event delivery on configuration changes | Low |
| Fire-and-forget background sync | viewModelScope.launch + CoroutineExceptionHandler | Bounded to ViewModel lifecycle, logs failures | Low |
| CPU-heavy image processing | withContext(Dispatchers.Default) | Uses bounded thread pool optimized for computation | Moderate CPU, predictable |
Configuration Template
Gradle Dependencies (build.gradle.kts)
dependencies {
val coroutineVersion = "1.8.1"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion")
}
Base CoroutineDispatcher Module (Hilt-compatible)
@Module
@InstallIn(SingletonComponent::class)
object CoroutineDispatchersModule {
@Provides
@Singleton
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@Singleton
fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
}
ViewModel State Wrapper Template
abstract class BaseViewModel : ViewModel() {
protected fun <T> MutableStateFlow<T>.emitSafely(value: T) {
tryUpdate { value }
}
}
Quick Start Guide
- Add Dependencies: Insert the coroutine and lifecycle extensions into your module's
build.gradle.kts and sync the project.
- Create Suspend Repository Method: Write a
suspend function in your data layer that performs network or database work, wrapping blocking calls in withContext(Dispatchers.IO).
- Wire to ViewModel: Inject the repository, expose a
StateFlow for UI state, and launch the repository call inside viewModelScope.launch. Update the flow with success/failure states.
- Collect in UI: In your Activity/Fragment, use
viewLifecycleOwner.lifecycleScope.launch combined with repeatOnLifecycle(Lifecycle.State.STARTED) to collect the flow and update views. Trigger the ViewModel method in onViewCreated.
Execution time: ~4 minutes. This pattern establishes lifecycle-bound concurrency, automatic cancellation, and type-safe state management without boilerplate.