All skills

Use Kotlin coroutines and Flow in Android and KMP. Covers safe scopes, parallel work, StateFlow, SharedFlow, errors, cancel rules, and tests.

  • 1 file
  • 13.8 KB
  • Updated 2 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/kotlin-flow-guide-plus/skill

This session only. Nothing lands on disk.

SKILL.md

โ‰ˆ37 tokens always: the name and description. โ‰ˆ3.5k when used: this file.

Kotlin Coroutines and Flow

Use these patterns for async work and data streams in Android and Kotlin Multiplatform projects.

Use This Skill When

  • Writing async Kotlin code
  • Using Flow, StateFlow, or SharedFlow
  • Loading data at the same time
  • Adding debounce or retry logic
  • Managing scope and cancel rules
  • Testing coroutines and flows

Core Rules

  • Use a scope tied to a clear owner.
  • Do not use GlobalScope.
  • Let cancel errors pass through.
  • Keep state values read-only outside their owner.
  • Use immutable state and list copies.
  • Pass dispatchers into code that needs easy tests.
  • Collect UI flows only while the UI is active.

Structured Concurrency

A parent scope owns its child jobs:

Application
  โ””โ”€โ”€ viewModelScope
        โ””โ”€โ”€ coroutineScope
              โ”œโ”€โ”€ async
              โ””โ”€โ”€ async

Use a scope with a known life:

// Bad: no clear owner
GlobalScope.launch {
    fetchData()
}

// Good: stops when the ViewModel is cleared
viewModelScope.launch {
    fetchData()
}

// Good: stops when this Compose effect leaves the UI
LaunchedEffect(key) {
    fetchData()
}

Do not create a new CoroutineScope unless the code owns it and can cancel it.

Run Work at the Same Time

Use coroutineScope with async when all results are needed:

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val items = async { itemRepository.getRecent() }
    val stats = async { statsRepository.getToday() }
    val profile = async { userRepository.getCurrent() }

    Dashboard(
        items = items.await(),
        stats = stats.await(),
        profile = profile.await()
    )
}

If one child fails, the other children are canceled. The error is sent to the caller.

Do not use async for work whose result is never read. Use launch for that work.

Keep Other Children Running

Use supervisorScope when one child may fail without stopping its siblings:

suspend fun syncAll() = supervisorScope {
    val items = async { runCatching { syncItems() } }
    val stats = async { runCatching { syncStats() } }
    val settings = async { runCatching { syncSettings() } }

    listOf(
        items.await(),
        stats.await(),
        settings.await()
    )
}

Handle each error. A failed launch can still reach the app error handler if no code catches it.

Flow Patterns

Cold Flow

A cold flow starts again for each collector.

If a data source already returns a flow, map it directly:

fun observeItems(): Flow<List<Item>> =
    itemDao.observeAll()
        .map { rows -> rows.map { it.toDomain() } }

Use flow {} when you need to turn suspend work into a flow:

fun loadItem(id: String): Flow<Item> = flow {
    emit(api.getItem(id))
}

Do not wrap an existing flow in flow { collect { emit(it) } } unless extra control is needed.

StateFlow for UI State

Keep mutable state private:

class DashboardViewModel(
    observeProgress: ObserveUserProgressUseCase
) : ViewModel() {
    val progress: StateFlow<UserProgress> = observeProgress()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UserProgress.EMPTY
        )
}

WhileSubscribed(5_000) keeps the source active for five seconds after the last collector leaves. This can avoid a restart during a screen change.

Pick the start rule on purpose:

  • Use WhileSubscribed for UI state.
  • Use Lazily when work should start on the first collector.
  • Use Eagerly only when work must start at once.

The first value is initialValue. Make sure it is safe to show.

Join More Than One Flow

Use combine when any input change should make new state:

val uiState: StateFlow<HomeState> = combine(
    itemRepository.observeItems(),
    settingsRepository.observeTheme(),
    userRepository.observeProfile()
) { items, theme, profile ->
    HomeState(
        items = items,
        theme = theme,
        profile = profile
    )
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = HomeState()
)

combine waits until each input has sent at least one value.

Search with Debounce

Use flatMapLatest when a new query should stop the old search:

searchQuery
    .debounce(300)
    .map(String::trim)
    .distinctUntilChanged()
    .flatMapLatest { query ->
        if (query.isBlank()) {
            flowOf(emptyList())
        } else {
            repository.search(query)
        }
    }
    .catch { error ->
        if (error is CancellationException) throw error
        emit(emptyList())
    }
    .collect { results ->
        _state.update { it.copy(results = results) }
    }

The catch operator handles errors from code above it. It does not catch errors thrown by the collector below it.

Retry with a Limit

Retry only errors that may pass after a short wait:

fun fetchWithRetry(): Flow<Data> =
    flow {
        emit(api.fetch())
    }.retryWhen { cause, attempt ->
        if (cause is IOException && attempt < 3) {
            val waitMs = 1_000L * (1L shl attempt.toInt())
            delay(waitMs)
            true
        } else {
            false
        }
    }

This makes up to four calls: the first call and three retries.

Do not retry login errors, bad input, or other errors that need user action.

SharedFlow for Effects

Use a SharedFlow for effects that may be seen by active collectors:

class ItemListViewModel : ViewModel() {
    private val _effects = MutableSharedFlow<Effect>(
        extraBufferCapacity = 1
    )
    val effects: SharedFlow<Effect> = _effects.asSharedFlow()

    sealed interface Effect {
        data class ShowSnackbar(val message: String) : Effect
        data class NavigateTo(val route: String) : Effect
    }

    fun deleteItem(id: String) {
        viewModelScope.launch {
            repository.delete(id)
            _effects.emit(Effect.ShowSnackbar("Item deleted"))
        }
    }
}

Collect it in Compose:

LaunchedEffect(viewModel) {
    viewModel.effects.collect { effect ->
        when (effect) {
            is Effect.ShowSnackbar ->
                snackbarHostState.showSnackbar(effect.message)

            is Effect.NavigateTo ->
                navController.navigate(effect.route)
        }
    }
}

A SharedFlow with replay = 0 does not save an effect for a later collector. If an effect must not be lost, store it in UI state or use a Channel with one clear receiver.

Do not use StateFlow for one-time events. A new collector may see the old event again.

Dispatchers

Use a dispatcher that fits the work:

// Heavy CPU work
withContext(Dispatchers.Default) {
    parseJson(largePayload)
}

// Blocking file, network, or database work on Android or JVM
withContext(Dispatchers.IO) {
    database.query()
}

// UI work
withContext(Dispatchers.Main) {
    updateUi()
}

Many modern database and network tools already move blocking work off the main thread. Check the tool before adding withContext.

For KMP, do not assume Dispatchers.IO works the same on every target. Pass a dispatcher into shared code:

class ItemRepository(
    private val ioDispatcher: CoroutineDispatcher
) {
    suspend fun load(): List<Item> =
        withContext(ioDispatcher) {
            readItems()
        }
}

This also makes tests easy.

Cancellation

Check Long Loops

Cancel works at suspend points. A long loop with no suspend call must check for cancel:

suspend fun processItems(items: List<Item>) {
    for (item in items) {
        currentCoroutineContext().ensureActive()
        process(item)
    }
}

Do Not Hide Cancellation

If code catches a wide error type, send cancel errors back up:

try {
    repository.fetch()
} catch (error: CancellationException) {
    throw error
} catch (error: Exception) {
    showError(error)
}

Clean Up with finally

viewModelScope.launch {
    try {
        _state.update { it.copy(isLoading = true) }
        val data = repository.fetch()
        _state.update { it.copy(data = data) }
    } finally {
        _state.update { it.copy(isLoading = false) }
    }
}

A finally block runs during cancel. Do not start long suspend work there.

If cleanup must suspend, use NonCancellable only for short, vital cleanup:

finally {
    withContext(NonCancellable) {
        closeSession()
    }
}

Collect in Android UI

In a Fragment or Activity, collect only while the UI is started:

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

In Compose, prefer lifecycle-aware state collection when it is available:

val state by viewModel.uiState.collectAsStateWithLifecycle()

Do not start a new collector on every screen update.

Tests

Use runTest. Pass test dispatchers into the code under test. Do not use real delays.

Test a Flow with Turbine

@Test
fun `search updates item list`() = runTest {
    val repository = FakeItemRepository()
    val viewModel = ItemListViewModel(repository)

    viewModel.state.test {
        assertEquals(ItemListState(), awaitItem())

        viewModel.onSearch("cats")
        assertTrue(awaitItem().isLoading)

        repository.emit(
            listOf(Item(id = "1", name = "Cat"))
        )

        val loaded = awaitItem()
        assertFalse(loaded.isLoading)
        assertEquals(1, loaded.items.size)

        cancelAndIgnoreRemainingEvents()
    }
}

End the Turbine test after the needed checks. A hot flow does not end on its own.

Test Work That Runs in the Background

@Test
fun `parallel load fills the state`() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val viewModel = DashboardViewModel(
        itemRepo = FakeItemRepo(),
        statsRepo = FakeStatsRepo(),
        dispatcher = dispatcher
    )

    viewModel.load()
    advanceUntilIdle()

    assertNotNull(viewModel.state.value.items)
    assertNotNull(viewModel.state.value.stats)
}

If a ViewModel uses Dispatchers.Main, set a test main dispatcher before the test and reset it after the test.

Fake a Flow Source

class FakeItemRepository : ItemRepository {
    private val items =
        MutableStateFlow<List<Item>>(emptyList())

    override fun observeItems(): Flow<List<Item>> = items

    fun emit(value: List<Item>) {
        items.value = value.toList()
    }

    override suspend fun getItemsByCategory(
        category: String
    ): Result<List<Item>> {
        return Result.success(
            items.value.filter { it.category == category }
        )
    }
}

Copy input lists so later test changes do not change old state by mistake.

Full Usage Example

This ViewModel loads items from a search flow and keeps errors in UI state:

data class SearchState(
    val query: String = "",
    val items: List<Item> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

class SearchViewModel(
    private val repository: ItemRepository
) : ViewModel() {
    private val query = MutableStateFlow("")

    val state: StateFlow<SearchState> = query
        .debounce(300)
        .map(String::trim)
        .distinctUntilChanged()
        .flatMapLatest { text ->
            if (text.isBlank()) {
                flowOf(SearchState(query = text))
            } else {
                repository.search(text)
                    .map<List<Item>, SearchState> { items ->
                        SearchState(
                            query = text,
                            items = items
                        )
                    }
                    .onStart {
                        emit(
                            SearchState(
                                query = text,
                                isLoading = true
                            )
                        )
                    }
                    .catch { error ->
                        if (error is CancellationException) throw error
                        emit(
                            SearchState(
                                query = text,
                                error = error.message ?: "Search failed"
                            )
                        )
                    }
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = SearchState()
        )

    fun onQueryChange(value: String) {
        query.value = value
    }
}

A new query stops the old search. A blank query clears the results. Cancel errors still pass through.

Avoid These Mistakes

  • Do not use GlobalScope.
  • Do not make a scope that no owner can cancel.
  • Do not catch and hide CancellationException.
  • Do not change a mutable list inside StateFlow.
  • Do not expose MutableStateFlow outside its owner.
  • Do not use flowOn(Dispatchers.Main) to choose where collection runs.
  • Do not put UI work above flowOn.
  • Do not make a new flow during each Compose update without remember.
  • Do not use runBlocking in app code.
  • Do not use real time or real dispatchers in unit tests.
  • Do not collect the same cold flow many times if the work should be shared.
  • Do not assume a SharedFlow saves events for users who are not listening.
  • Do not update Android views from a worker dispatcher.

Related Skills

  • Use compose-multiplatform-patterns for Flow use in Compose UI.
  • Use android-clean-architecture for coroutine rules across app layers.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 1023c56. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago
origin
ECC

README badge

README badge for agenticluke/kotlin-flow-guide-plus