StateFlow & SharedFlow에 대한 고찰

wonseok·2023년 1월 5일
7

개요

StateFlowSharedFlow를 구글에 검색해보면, 이에 대해 잘 정리된 문서와 블로그들이 존재하지만, 이러한 hot flow를 어떤 상황에서 적절하게 선택할 지에 대한 몇가지 아이디어에 대한 내용이 부족한 것 같아 해당 포스팅에서는 이것을 정리하며 학습하고자 한다.

해당 포스팅에서는 collect수집, emit방출발행으로 표현한다.

아래에서 보여줄 샘플 코드에서는 아래의 내용들을 보여줄 것이다.

  1. Flow를 수집하고, 뷰모델에서 StateFlow를 노출한다.
  2. stateIn extension을 사용하여 flow를 수집하고, 뷰모델에서 StateFlow를 노출한다.
  3. sharedIn extension을 사용하여 flow를 수집하고, 뷰모델에서 SharedFlow를 노출한다.
  4. 뷰 기반의 앱과 뷰 라이프사이클을 따르는 컴포즈 기반의 앱에서 StateFlowSharedFlowUiState로 수집한다.
  5. FlowStateFlowSharedFlow로 전환하는 뷰모델 유닛 테스팅을 한다.

들어가기에 앞서, SharedFlowStateFlow가 내부적으로 어떻게 구현되어있는지 짚고 넘어가고자 한다.

interface SharedFlow<out T> : Flow<T> {
  ...
}

interface StateFlow<out T> : SharedFlow<T> {
    /**
     * The current value of this state flow.
     */
    public val value: T
}

위 코드의 구현 관계에서 볼 수 있듯이,
SharedFlowStateFlow의 추상화된 버전이라고 볼 수 있다.

그리고, SharedFlowStateFlow 둘 다 모두, Flow와 달리 Hot Stream (Rx의 Subject처럼 Hot Observable 역할)이므로, 하나 이상의 소비자들이 구독할 수 있고, 기본값을 가지고 모든 구독자에게 같은 데이터를 발행하며, 구독자가 없는 경우에도 데이터를 발행한다.

반면, Cold Stream은 하나의 소비자에게만 값을 보내며, 소비자가 구독하는 시점 이후에야 발행을 시작한다.

(1) StateFlow

  • StateFlowSharedFlow의 구체화 버전이며, 발행된 마지막 데이터 값을 갖고 있다.
  • StateFlow는 더 효율적이고 API가 더 간단하며 상태 관리에 더 잘 어울리기 때문에 ViewModel에서 더 일반적으로 사용된다.
  • StateFlow1의 고정된 replayCache값을 가지며(LiveData와 유사), 구성 변경 시 기존 구독자뿐만 아니라 새 구독자에게 가장 최근의 데이터를 방출한다.
  • 초기화할 기본 값(Initial Value)이 필요한 경우에는 StateFlow를 사용하는 것이 적절하다.
  • 중단되지 않는 방식(non-suspending way)으로 .value 속성을 사용하여 get 혹은 set할 수 있다.

(2) SharedFlow

  • SharedFlowStateFlow의 일반화 버전이다.
  • SharedFlowreplayCache는 개발자가 직접 정의할 수 있으며 기본값은 0이다. 기본값을 사용하면 구성 변경 시 기존 구독자뿐만 아니라 새 구독자에게도 값이 방출되지 않는다(예: 구성 변경 후 오류를 다시 내보낼 이유가 없음).
  • SharedFlow는 시간이 지남에 따라 동일한 이벤트를 트리거해야 하는 경우(예: SnackBar 표시 이벤트, 탐색 이벤트, 네트워크 연결 불가능 이벤트 등)에 더 적합하도록 이후의 동일한 값을 재방출하는 방식으로 진행된다.
  • ViewModel의 init 블록 안에서 SharedFlow로 푸시하고 collector가 아직 수집을 시작하지 않은 경우, collector가 해당 방출을 놓칠 수 있다.

Cold Flow에서 Hot Flow로 전환하기

뷰모델에서는 Cold Flow(Flow) 보다는 Hot Flow(StateFlow or SharedFlow)를 노출해야하는데, 만약 일반적인 Flow를 노출한다면, 새로운 구독자가 구독을 할 때마다, RepositoryFlow Builder 블록에서 새 Flow 방출을 트리거하여 리소스를 낭비할 수 있기 때문이다.

그래서 보통 Repository로부터 전달된 Cold FlowHot FlowStateFlow or SharedFlow로 변환하는 2가지의 일반적인 방법을 사용한다.

  • (1) Flow를 수집하고, StateFlow 또는 SharedFlowpush하기
  • (2) stateIn 확장함수를 사용하여 FlowStateFlowpush하거나 shareIn 확장함수를 사용하여 FlowSharedFlowpush하기.

예제 코드는 아래와 같다.

(1) 뷰모델에서 Flow 수집하기

@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())
  }
}

마지막으로, 아래의 코드를 통해 FlowResult(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)) }
}

(2) stateIn Flow extension 사용하기

아래의 예제 코드는 fun <T> Flow<T>.stateIn 확장 함수를 사용하여 FlowStateFlow로 전환하여 방출하는 방법의 코드이다.

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를 사용하면 조금 더 코드가 간단해진다.
위 예제 코드의 stateInstarted 인자를 보면, 구성 변경이 발생하는 경우 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
      )
    )

뷰 기반 프로젝트에서 State 수집하기

만약, Activity에서 StateFlow가 수집된다면, 아래와 같은 코드를 사용할 수 있을 것이다.

lifecycleScope.launch {
  lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
    viewModel.userFlow.collect {
      render(it)
    }
  }
}

해당 방식의 코드는 android developers 문서에서 아래와 같이 찾아볼 수 있다.

여기서 lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) 블록 안의 코드는 만약 뷰의 라이프사이클이 STARTEDSTOPPED 사이에 있지 않다면, 뷰모델에서 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 기반 프로젝트에서 State 수집하기

요새 필자도, 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 채널에서 이에 대해 동일하게 고민한 분의 질문이 있어서 가져와봤다.

엄청난 실력자인 아래 두 분께서 너무 잘 설명해주셔서 곧바로 따봉👍 을 눌러주었다.


2개의 댓글

comment-user-thumbnail
2023년 1월 6일

깊게 탐구하시고 고민하신 흔적이 느껴집니다 :)
좋은 글 감사합니다 ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 8월 5일

SharedFlow가 StateFlow의 추상화된 버전이라고 하셨는데,

StateFlow가 SharedFlow의 인터페이스를 상속받아 구현하고있으니 StateFlow가 SharedFlow의 추상화된 버전이지 않을까요??

답글 달기