항상 값을 가지는 코루틴 기반 상태 스트림.
LiveData와의 차이
LiveData StateFlow
초기값 없어도 된다 (null 가능) 반드시 있다
생명주기 인지 내장 (observe 하나로) 없다 → repeatOnLifecycle 필요
연산자 거의 없다 (map/switchMap) Flow 전체 (combine·debounce·flatMapLatest)
스레드 메인에서만 setValue 아무 데서나
플랫폼 안드로이드 전용 순수 코틀린 (테스트·KMP 유리)
중복 값 그대로 전달 같은 값이면 방출하지 않는다 (conflate)
// StateFlow — 값이 항상 있다
private val _state = MutableStateFlow(HomeUiState())
val state: StateFlow<HomeUiState> = _state.asStateFlow()
_state.update { it.copy(isLoading = true) } // 원자적. value = 보다 안전하다
왜 update를 쓰나
// ❌ 읽고-고치고-쓰기 사이에 다른 코루틴이 끼어들 수 있다
_state.value = _state.value.copy(count = _state.value.count + 1)
// ✅ compare-and-set 루프로 원자성을 보장한다
_state.update { it.copy(count = it.count + 1) }
SharedFlow와의 구분
-
StateFlow — 현재 상태. 최신 값 1개를 replay 한다
- 새 구독자가 즉시 현재 값을 받는다
- 같은 값 재방출은 무시된다
-
SharedFlow — 이벤트. replay 를 직접 정한다 (기본 0)
- 같은 값도 그대로 방출된다
-
"토스트를 StateFlow 로 보내면 회전할 때 다시 뜬다" 가 이 차이 때문이다
수집은 반드시 생명주기 안전하게
// Compose
val state by viewModel.uiState.collectAsStateWithLifecycle()
// View 시스템
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
StateFlow 는 생명주기를 모른다
- 그냥 collect 하면 화면이 백그라운드에 가도 계속 수집한다
- 보이지도 않는 화면을 위해 데이터를 받고 다시 그린다
LiveData 에서 옮겨올 때 가장 많이 빠뜨리는 지점이다
면접 함정
- ❌ "StateFlow가 LiveData의 상위 호환" → 생명주기 인지가 빠져 있어 수집 코드가 늘어난다. 그 대신 연산자와 플랫폼 독립성을 얻는다.
- ❌ "data class면 중복 방출이 자동으로 걸러진다" →
equals기준이다. 배열 필드가 있으면 항상 다르다고 판정된다.
LiveData에서 옮길 때의 대응표
- MutableLiveData(x) — → MutableStateFlow(x)
- liveData.value = x — → _flow.value = x (또는 _flow.update { })
- postValue(x) — → 필요 없다 (아무 스레드에서 안전)
- map { } — → map { } (Flow 연산자)
- switchMap { } — → flatMapLatest { }
- MediatorLiveData — → combine { }
- observe(owner) { } — → repeatOnLifecycle + collect
asStateFlow를 쓰는 이유
// ❌ 밖에서 캐스팅해 값을 바꿀 수 있다
val state: StateFlow<UiState> = _state
(state as MutableStateFlow).value = ...
// ✅ 읽기 전용 래퍼
val state: StateFlow<UiState> = _state.asStateFlow()
중복 방출이 걸러지는 것의 함정
data class UiState(val items: List<Item>)
// items 리스트의 '내용' 이 바뀌었지만 같은 인스턴스라면
_state.value = _state.value.copy(items = sameListInstance)
// → equals 가 true → 방출되지 않는다 → 화면이 안 바뀐다
- 가변 컬렉션을 상태에 담고 그것을 제자리 수정(mutate) 하면 이 문제가 난다
- 상태 안의 컬렉션은 반드시 불변으로 다루고 새 리스트를 만든다
테스트
@Test fun 새로고침() = runTest {
vm.uiState.test { // app.cash.turbine
assertTrue(awaitItem().isLoading)
vm.refresh()
assertEquals(3, awaitItem().users.size)
cancelAndIgnoreRemainingEvents()
}
}