항상 현재 값을 하나 갖고 있는 hot 스트림. 화면 상태 관리의 표준이다.
class HomeViewModel : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun load() = viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val items = repository.load()
_uiState.update { it.copy(isLoading = false, items = items) }
}
}
cold와 hot의 차이
-
cold (Flow) — 수집자마다 독립적으로 실행된다. 수집 전에는 아무 일도 없다
-
hot (StateFlow) — 수집자와 무관하게 값이 존재한다. 여럿이 같은 값을 공유한다
-
"녹화 영상" 과 "생방송" 의 차이
StateFlow의 성질
- 초기값이 반드시 필요하다
- 항상 현재 값을 갖는다 (.value 로 즉시 읽을 수 있다)
- 새 수집자는 즉시 최신 값을 받는다
- 같은 값을 다시 넣으면 방출되지 않는다 (conflate + distinctUntilChanged)
마지막 성질이 함정이다 — 같은 값을 다시 보내 화면을 갱신하려 해도 아무 일이 안 일어난다.
상태와 이벤트를 구분한다
-
StateFlow — '상태' — 지금 화면이 어떤 모습인가
- 회전 후에도 다시 받아야 한다 (최신 값 재전달이 맞다)
-
SharedFlow — '이벤트' — 한 번만 일어나야 하는 일
- 토스트 · 네비게이션 · 스낵바
- 재전달되면 화면 회전 시 토스트가 다시 뜬다
private val _events = MutableSharedFlow<UiEvent>() // replay = 0 (기본)
val events = _events.asSharedFlow()
suspend fun showToast(msg: String) = _events.emit(UiEvent.Toast(msg))
"회전했더니 토스트가 다시 뜬다" 가 이 구분을 안 했을 때의 전형적 증상이다.
안드로이드에서 안전하게 수집한다
// ❌ 화면이 백그라운드로 가도 계속 수집한다 → 자원 낭비 · 크래시
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}
// ✅ STARTED 아래로 내려가면 수집을 멈추고, 돌아오면 다시 시작한다
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
cold를 hot으로 바꾸기
val users: StateFlow<List<User>> = repository.observeUsers()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 구독자가 없으면 5초 뒤 중단
initialValue = emptyList()
)
WhileSubscribed(5000)이 관용구다 — 화면 회전 같은 짧은 단절에는 유지하고, 진짜로 떠나면 중단한다.
면접 함정
- ❌ "StateFlow는 LiveData의 대체품" → 생명주기 인지가 없다.
repeatOnLifecycle이 필요하다. - ❌ "SharedFlow로 상태를 관리한다" → 현재 값 개념이 없어 화면 상태에는 StateFlow가 맞다.