跳到正文
DEV Community· Prem Singh·· 3 小時前AI 評分52

Android Compose 畫面消失後,Flow 為何仍在執行?

Your Compose Screen Is Gone. Why Is Your Flow Still Running?

AI 導讀

Android Compose 畫面消失後,Flow 上游不一定會停止,因為 UI 收集器與生產端各有自己的生命週期。

正文

Press Home on an Android app. The screen disappears, but that doesn't automatically mean every coroutine feeding it has stopped.

For a search screen, live dashboard, or AI response view, this distinction matters. A UI collector and the producer behind it have separate lifetimes. Reviewing only one can leave unnecessary work running—or stop work that the product actually needs to finish.

Here's a small pattern worth understanding before adding more architecture to a Compose screen.

1. Collect screen state with the Android lifecycle

For an Android Compose route backed by a ViewModel, use lifecycle-aware collection:

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun FeedRoute(viewModel: FeedViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    FeedScreen(state)
}

FeedScreen is your rendering composable; FeedViewModel is supplied by your app's normal ViewModel creation mechanism. Add androidx.lifecycle:lifecycle-runtime-compose using a version aligned with your project's Lifecycle dependencies.

By default, collection runs when the lifecycle is at least STARTED. It stops below that state and starts again when the lifecycle becomes active. This is different from ordinary collectAsState(), whose collection follows the composition rather than applying this lifecycle threshold. See the Android Compose state guide.

That fixes the consumer side. Now look upstream.

2. Give the upstream an explicit sharing policy

Here is an integration sketch for a repository that observes feed items. It assumes a cold Flow whose observation work is cancelled when its collector is cancelled. Adapt the model and repository to your app.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

data class FeedItem(val id: String, val title: String)

sealed interface FeedUiState {
    data object Loading : FeedUiState
    data class Content(val items: List<FeedItem>) : FeedUiState
    data object Error : FeedUiState
}

interface FeedRepository {
    fun observeItems(): Flow<List<FeedItem>>
}

class FeedViewModel(repository: FeedRepository) : ViewModel() {
    val uiState: StateFlow<FeedUiState> = repository.observeItems()
        .map<List<FeedItem>, FeedUiState> { items ->
            FeedUiState.Content(items)
        }
        .catch { cause ->
            if (cause is CancellationException) throw cause
            emit(FeedUiState.Error)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(
                stopTimeoutMillis = 5_000,
            ),
            initialValue = FeedUiState.Loading,
        )
}

The explicit map result type allows both Content and Error to flow through the same pipeline. The cancellation guard makes the intended behavior clear: cancelling observation should not become a visible failure.

These are focused integration snippets, not a complete sample app. The repository, UI rendering, and dependency setup remain application-specific.

3. Read the timeline, not just the API name

With the policy above, the expected sequence is:

UI starts collecting       -> upstream observation starts
last subscriber disappears -> five-second grace period begins
subscriber returns early   -> existing observation continues
no subscriber after 5s     -> sharing cancels upstream collection
subscriber returns later   -> upstream collection starts again

The grace period can bridge short subscriber gaps such as configuration changes. Five seconds is a choice, not a universal performance rule.

The default replay expiration retains the last value: stopping the upstream doesn't automatically put uiState back into Loading. Returning users can see cached content while observation restarts. Read the Kotlin WhileSubscribed reference before changing cache behavior.

4. Know what this pattern does not stop

WhileSubscribed controls this sharing coroutine. It doesn't cancel every job in the ViewModel or shut down an independently running hot producer.

For example, this job has a different owner:

init {
    viewModelScope.launch {
        repository.refreshOnce()
    }
}

Here refreshOnce() represents an application-specific suspend function. Its job can continue after the UI stops collecting because it was launched separately in viewModelScope.

This can be intentional. An explicit user action and an observational feed don't always need the same lifetime. Decide whether an operation should stop when the screen becomes inactive, survive a navigation change, or require durable execution. Then choose its owner.

5. Handle failures and retry deliberately

The catch above turns a failure into an error state, but it does not continuously retry. Once that upstream attempt completes, a retry action needs an explicit strategy, such as a refresh trigger that starts a new observation, or a bounded retryWhen policy before catch for suitable transient failures.

Don't rely on backgrounding and reopening the app as the user's retry mechanism. Also consider retaining existing content alongside a refresh error when that serves the screen better.

A useful code review exercise

For the next Flow-backed screen you review, trace four things:

  1. Which lifecycle controls the UI collector?
  2. Which scope owns the producer?
  3. Does cancellation actually reach the underlying work?
  4. What happens after failure, and who triggers another attempt?

For a portfolio demo, instrument a fake cold Flow with start and cancellation logs, then record navigating away and returning before and after the timeout. Show the logs beside the screen recording. That demonstrates the behavior far more clearly than a screenshot of an API call.

The key design decision is ownership: the screen's visibility, the observer's lifetime, and the operation's lifetime should each be intentional.

What do you usually find in reviews: missing lifecycle-aware collection, or upstream work with an unexpected lifetime?


Written with AI assistance; technical references are linked above.

Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose & Android architecture

GitHub · LinkedIn

來源:DEV Community · dev.to