StateFlow
와 SharedFlow
를 구글에 검색해보면, 이에 대해 잘 정리된 문서와 블로그들이 존재하지만, 이러한 hot flow
를 어떤 상황에서 적절하게 선택할 지에 대한 몇가지 아이디어에 대한 내용이 부족한 것 같아 해당 포스팅에서는 이것을 정리하며 학습하고자 한다.
해당 포스팅에서는
collect
를수집
,emit
를방출
및발행
으로 표현한다.
아래에서 보여줄 샘플 코드에서는 아래의 내용들을 보여줄 것이다.
Flow
를 수집하고, 뷰모델에서 StateFlow
를 노출한다.stateIn
extension을 사용하여 flow
를 수집하고, 뷰모델에서 StateFlow
를 노출한다.sharedIn
extension을 사용하여 flow
를 수집하고, 뷰모델에서 SharedFlow
를 노출한다.StateFlow
와 SharedFlow
를 UiState
로 수집한다.Flow
를 StateFlow
와 SharedFlow
로 전환하는 뷰모델 유닛 테스팅을 한다.들어가기에 앞서, SharedFlow
와 StateFlow
가 내부적으로 어떻게 구현되어있는지 짚고 넘어가고자 한다.
interface SharedFlow<out T> : Flow<T> {
...
}
interface StateFlow<out T> : SharedFlow<T> {
/**
* The current value of this state flow.
*/
public val value: T
}
위 코드의 구현 관계에서 볼 수 있듯이,
SharedFlow
는 StateFlow
의 추상화된 버전이라고 볼 수 있다.
그리고, SharedFlow
와 StateFlow
둘 다 모두, Flow
와 달리 Hot Stream (Rx의 Subject처럼 Hot Observable 역할)
이므로, 하나 이상의 소비자들이 구독할 수 있고, 기본값을 가지고 모든 구독자에게 같은 데이터를 발행하며, 구독자가 없는 경우에도 데이터를 발행한다.
반면,
Cold Stream
은 하나의 소비자에게만 값을 보내며, 소비자가 구독하는 시점 이후에야 발행을 시작한다.
StateFlow
는 SharedFlow
의 구체화 버전이며, 발행된 마지막 데이터 값을 갖고 있다. StateFlow
는 더 효율적이고 API가 더 간단하며 상태 관리
에 더 잘 어울리기 때문에 ViewModel에서 더 일반적으로 사용된다.StateFlow
는 1
의 고정된 replayCache
값을 가지며(LiveData
와 유사), 구성 변경 시 기존 구독자뿐만 아니라 새 구독자에게 가장 최근의 데이터를 방출한다.Initial Value
)이 필요한 경우에는 StateFlow
를 사용하는 것이 적절하다.non-suspending way
)으로 .value
속성을 사용하여 get
혹은 set
할 수 있다.SharedFlow
는 StateFlow
의 일반화 버전이다.SharedFlow
의 replayCache
는 개발자가 직접 정의할 수 있으며 기본값은 0
이다. 기본값을 사용하면 구성 변경 시 기존 구독자뿐만 아니라 새 구독자에게도 값이 방출되지 않는다(예: 구성 변경 후 오류를 다시 내보낼 이유가 없음).SharedFlow
는 시간이 지남에 따라 동일한 이벤트를 트리거해야 하는 경우(예: SnackBar
표시 이벤트, 탐색
이벤트, 네트워크 연결 불가능
이벤트 등)에 더 적합하도록 이후의 동일한 값을 재방출하는 방식으로 진행된다.init
블록 안에서 SharedFlow
로 푸시하고 collector
가 아직 수집을 시작하지 않은 경우, collector
가 해당 방출을 놓칠 수 있다.뷰모델에서는 Cold Flow(Flow)
보다는 Hot Flow(StateFlow or SharedFlow)
를 노출해야하는데, 만약 일반적인 Flow
를 노출한다면, 새로운 구독자가 구독을 할 때마다, Repository
의 Flow Builder
블록에서 새 Flow
방출을 트리거하여 리소스를 낭비할 수 있기 때문이다.
그래서 보통 Repository
로부터 전달된 Cold Flow
를 Hot Flow
인 StateFlow or SharedFlow
로 변환하는 2가지의 일반적인 방법을 사용한다.
Flow
를 수집하고, StateFlow
또는 SharedFlow
로 push
하기stateIn
확장함수를 사용하여 Flow
를 StateFlow
로 push
하거나 shareIn
확장함수를 사용하여 Flow
를 SharedFlow
로 push
하기.예제 코드는 아래와 같다.
@HiltViewModel
class MyViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _userFlow = MutableStateFlow<UiState>(UiState.Loading)
val userFlow: StateFlow<UiState> = _userFlow.asStateFlow()
fun onRefresh() {
viewModelScope.launch {
userRepository
.getUsers().asResult()
.collect { result ->
_userFlow.update {
when (result) {
is Result.Loading -> UiState.Loading
is Result.Success -> UiState.Success(result.data)
is Result.Error -> UiState.Error(result.exception)
}
}
}
}
}
}
해당 코드는 위의 (1) 방식의 예제코드이고, UiState
의 구현 부분은 아래와 같다.
sealed interface UiState {
object Loading : UiState
data class Success(
val data: List<User>
) : UiState
data class Error(
val throwable: Throwable? = null
) : UiState
}
UserRepository
인터페이스는 데이터를 Flow
로 방출하도록 한다.
interface UserRepository {
fun getUsers(): Flow<List<User>>
}
그리고, Flow
를 방출하는 실제의 real-world
코드를 가져올 수는 없기 때문에, 아래의 mock
코드를 사용했다.
class InMemoryUserRepository @Inject constructor() : UserRepository {
override fun getUsers(): Flow<List<User>> = flow {
val userList = listOf(
User(
name = "User 1",
age = 20
),
User(
name = "User 2",
age = 30
)
)
emit(userList.shuffled())
}
}
마지막으로, 아래의 코드를 통해 Flow
가 Result(userRepository.getUsers().asResult()
로 매핑된다.
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import java.io.IOException
private const val RETRY_TIME_IN_MILLIS = 15_000L
private const val RETRY_ATTEMPT_COUNT = 3
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing>
object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> {
return this
.map<T, Result<T>> {
Result.Success(it)
}
.onStart { emit(Result.Loading) }
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < RETRY_ATTEMPT_COUNT) {
delay(RETRY_TIME_IN_MILLIS)
true
} else {
false
}
}
.catch { emit(Result.Error(it)) }
}
아래의 예제 코드는 fun <T> Flow<T>.stateIn
확장 함수를 사용하여 Flow
를 StateFlow
로 전환하여 방출하는 방법의 코드이다.
private const val DEFAULT_TIMEOUT = 5000L
@HiltViewModel
class MyViewModel @Inject constructor(
userRepository: UserRepository
) : ViewModel() {
val userFlow: StateFlow<UiState> = userRepository
.getUsers()
.asResult()
.map { result ->
when (result) {
is Result.Loading -> UiState.Loading
is Result.Success -> UiState.Success(result.data)
is Result.Error -> UiState.Error(result.exception)
}
}
.stateIn(
scope = viewModelScope,
initialValue = UiState.Loading,
started = SharingStarted.WhileSubscribed(DEFAULT_TIMEOUT)
)
}
이렇게 stateIn
를 사용하면 조금 더 코드가 간단해진다.
위 예제 코드의 stateIn
의 started
인자를 보면, 구성 변경이 발생하는 경우 Flow
스트림이 다시 구독되지 않도록 보장해준다는 의미이고, DEFAULT_TIMEOUT
인 5초는 Google 샘플에서 권장하는 시간 제한 기본값이다.
또다른 stateIn
사용의 장점은 여러가지의 Flow
들을 combine
할 수 있다는 점인데, 이 코드도 한 번 살펴보자.
val uiState: StateFlow<HomeUiState> = combine(
topRatedMovies,
actionMovies
) { topRatedResult, actionMoviesResult ->
val topRated: TopRatedMoviesUiState = when (topRatedResult) {
is Result.Success -> TopRatedMoviesUiState.Success(topRatedResult.data)
is Result.Loading -> TopRatedMoviesUiState.Loading
is Result.Error -> TopRatedMoviesUiState.Error
}
val action: ActionMoviesUiState = when (actionMoviesResult) {
is Result.Success -> ActionMoviesUiState.Success(actionMoviesResult.data)
is Result.Loading -> ActionMoviesUiState.Loading
is Result.Error -> ActionMoviesUiState.Error
}
HomeUiState(topRated, action)
}
.stateIn(
scope = viewModelScope,
started = WhileUiSubscribed,
initialValue = HomeUiState(
TopRatedMoviesUiState.Loading,
ActionMoviesUiState.Loading
)
)
만약, Activity
에서 StateFlow
가 수집된다면, 아래와 같은 코드를 사용할 수 있을 것이다.
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.userFlow.collect {
render(it)
}
}
}
해당 방식의 코드는 android developers 문서에서 아래와 같이 찾아볼 수 있다.
여기서 lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
블록 안의 코드는 만약 뷰의 라이프사이클이 STARTED
와 STOPPED
사이에 있지 않다면, 뷰모델에서 Flow
를 수집하지 않을 것이라는 것을 보장한다.
다시말해, View Layer
는 생명주기가 started
, resumed
, paused
상태일때만 데이터를 수집한다는 뜻이다.
이것을 통해, 원치않는 리소스 낭비를 방지할 수 있다.
그렇다면, Activity
가 아닌, Fragment
에서는 어떨까?
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.userFlow.collect {
render(it)
}
}
}
private fun render(uiState: UiState) {
when (uiState) {
UiState.Loading -> TODO()
is UiState.Success -> TODO()
is UiState.Error -> TODO()
}
}
Activity
에서와 비슷하지만, viewLifecycleOwner
를 사용한다는 점이 다르다.
요새 필자도, Jetpack Compose
의 매력에 빠져, 컴포즈 기반 프로젝트들을 진행하면서 컴포즈 기반 UI 코드 작성에 익숙해지고자 노력하고 있다.
그렇다면 Jetpack Compose
기반 프로젝트에서는 StateFlow
는 어떻게 수집될까?
val uiState by viewModel.userFlow.collectAsStateWithLifecycle()
when (uiState) {
UiState.Loading -> TODO()
is UiState.Success -> TODO()
is UiState.Error -> TODO()
}
collectAsStateWithLifecycle()
를 통해 생명주기를 고려한 수집이 이루어진다.
비슷하게 SharedFlow
는 아래와 같이 컴포즈 기반 프로젝트에서 수집될 수 있다.
val uiState by shareInViewModel.userFlow.collectAsStateWithLifecycle(
initialValue = UiState.Loading
)
요약하자면, collectAsStateWithLifecycle()
를 사용하는 것은 hot flow(StateFlow or SharedFlow)
를 화면이 표시될 때에만 수집하여 시스템 리소스 낭비 및 불필요한 네트워크 호출을 방지한다고 볼 수 있다.
또한, 이 방식을 통해 LiveData
와 비슷한 역할을 할 수 있다.
결국, 도메인 레이어에서 안드로이드 플랫폼에 종속적인
LiveData
를 사용하지 않고, 동일한 역할을 하는StateFlow
를 사용하여 밥 아저씨의클린 아키텍처
관점을 지킬 수 있게 된다!
Slack GDG Korea 채널에서 이에 대해 동일하게 고민한 분의 질문이 있어서 가져와봤다.
엄청난 실력자인 아래 두 분께서 너무 잘 설명해주셔서 곧바로 따봉👍 을 눌러주었다.
깊게 탐구하시고 고민하신 흔적이 느껴집니다 :)
좋은 글 감사합니다 ㅎㅎ