Android

[Android] 뷰모델에서 데이터 불러오기 Best Practice

참깨빵위에참깨빵_ 2024. 9. 15. 19:50
728x90
반응형

이 글은 아래의 미디엄 링크를 번역한 포스팅이다.

 

https://proandroiddev.com/the-best-way-to-load-data-in-viewmodels-a112ced54e07

 

The best way to load data in ViewModels

Simplicity is key. Sadly, it is also one of the hardest things to achieve in software development. Learn how to easily load data in 2024.

proandroiddev.com

 

아래는 수정할 코드다.

 

sealed interface ViewState {
  data class Success(val text: String, val counter: Int): ViewState
  data object Loading: ViewState
  data object Failure: ViewState
}

class ViewStateMapper {

  fun map(dataResult: Result<Data>): ViewState {
    val data = dataResult.getOrElse { exception ->
      println(exception)
      return ViewState.Failure
    }
    return mapSuccess(data)
  }

  fun mapSuccess(data: Data) = ViewState.Success(
    text = data.someText,
    counter = data.counter,
  )

}


class MyViewModel(
  private val fetchDataUseCase: FetchDataUseCase,
  private val observeDataUseCase: ObserveDataUseCase,
  private val viewStateMapper: ViewStateMapper,
): ViewModel() {

  private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
  val viewState = _viewState.asStateFlow()

  init {
    coroutineScope.launch {
      val dataResult = fetchDataUseCase.run()
      _viewState.update { viewStateMapper.map(dataResult) }
      dataResult.onSuccess {
        observeDataUseCase.run().collect { newData ->
          _viewState.update { viewStateMapper.map(newData) }
        }
      }
    }
  }

}

 

이 코드는 로딩 상태 뷰를 표시하고 데이터를 불러오면서 불러오지 못한 경우 실패 상태를 표시하는 등 여러가지를 수행한다. 새로고침해서 데이터를 다시 불러오는 기능을 추가한다. 뷰모델을 아래처럼 수정하면 된다.

 

class MyViewModel(/*Dependencies*/): ViewModel() {

  private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
  val viewState = _viewState.asStateFlow()

  init {
    refreshData()
  }

  fun refreshData() {
    _viewState.update { ViewState.Loading }
    coroutineScope.launch {
      val dataResult = fetchDataUseCase.run()
      _viewState.update { viewStateMapper.map(dataResult) }
      dataResult.onSuccess {
        observeDataUseCase.run().collect { newData ->
          _viewState.update { viewStateMapper.map(newData) }
        }
      }
    }
  }

}

 

그러나 refreshData()를 호출하는 횟수만큼 새로고침 프로세스가 시작된다. 데이터 새로고침에 성공할 때마다 다른 observeDataUseCase 컬렉션을 추가하기 때문에 모든 업데이트가 여러 번 트리거될 수 있다.

사이드 이펙트를 써서 문제를 해결할 수 있다.

 

class MyViewModel(/*Dependencies*/): ViewModel() {

  private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
  private var refreshJob: Job // Store the refreshing of data into this job
  val viewState = _viewState.asStateFlow()

  init {
    refreshJob = refreshData()
  }

  fun refresh() {
    if (_viewState.value is ViewState.Loading) {
      return
    }
    refreshJob.cancel()
    _viewState.update { ViewState.Loading }
    refreshJob = refreshData()
  }

  private fun refreshData() = coroutineScope.launch {
      val dataResult = fetchDataUseCase.run()
      _viewState.update { viewStateMapper.map(dataResult) }
      dataResult.onSuccess {
        observeDataUseCase.run().collect { newData ->
          _viewState.update { viewStateMapper.map(newData) }
        }
      }
    }
  }

}

 

이제 새로고침을 처리해야 하고 재할당해야 한다. 그리고 새로고침할 때마다 전체 화면이 ViewState.Loading 상태가 되는 등 아직 UI에 맞지 않는 부분이 남아있다. 데이터가 이미 메모리에 있을 경우 UI에 표시할 수 있다면 좋을 것이다.

즉 뷰모델에 더 많은 종속성을 주입해야 하며 상태 관리를 위해 더 많은 복잡성을 추가해야 한다.

 

class MyViewModel(
  private val fetchDataFromMemoryUseCase: FetchDataFromMemoryUseCase,
  private val fetchDataUseCase: FetchDataUseCase,
  private val observeDataUseCase: ObserveDataUseCase,
  private val viewStateMapper: ViewStateMapper,
): ViewModel() {

  private val _viewState = MutableStateFlow<ViewState>(
    fetchDataFromMemoryUseCase.run().map { data ->
      viewStateMapper.mapSuccess(data)
    } ?: ViewState.Loading
  )
  private var refreshJob: Job // Store the refreshing of data into this job
  val viewState = _viewState.asStateFlow()

  init {
    refreshJob = if (_viewState.value is ViewState.Success) {
      coroutineScope.launch { observeData() }
    } else {
      refreshData()
    }
  }

  fun refresh() {
    if (_viewState.value is ViewState.Loading) {
      return
    }
    refreshJob.cancel()
    _viewState.update { ViewState.Loading }
    refreshJob = refreshData()
  }

  private fun refreshData() = coroutineScope.launch {
      val dataResult = fetchDataUseCase.run()
      _viewState.update { viewStateMapper.map(dataResult) }
      dataResult.onSuccess {
        observeData()
      }
    }
  }

  private fun observeData() {
    observeDataUseCase.run().collect { newData ->
      _viewState.update { viewStateMapper.map(newData) }
    }
  }

}

 

이 코드는 잘 작동하겠지만 데이터 불러오는 것 하나를 위해 이렇게 복잡하게 만들어야 하는가? 이 뷰모델엔 데이터 표시 외의 실제 기능은 없다. 하지만 실제 함수 호출 전에 초기 데이터 상태, 로드 상태, 초기 새로고침, 관찰, 동시 새로고침 금지 등을 확인하기 위해 여러 테스트를 작성해야 한다.

X타입 데이터를 먼저 가져와야 Y타입 데이터를 가져오는 데 쓸 수 있다면 뷰모델이 엉망이 될 것이다.

sealed interface에 로딩 / 성공 / 실패 타입을 추가하고 데이터의 Flow를 리턴하고 내부에서 모든 로직을 처리한 다음 뷰모델에서 로드를 제거하는 UseCase가 있을까? 아래처럼 할 수 있다.

 

class DataRepository(private val dataNetworkSource: DataNetworkSource) {

  fun fetchData: Flow<LoadingResult<Data>> = flow {
    emit(LoadingResult.Loading)
    dataNetworkSource.fetchData().onSuccess { data ->
      emit(LoadingResult(data))
    }.onFailure { exception ->
      emit(LoadingResult.Failure(exception)
    }
  }

}

 

도메인 레이어는 프레젠테이션 레이어를 전혀 신경쓰지 않는다. 로딩 상태를 어떻게든 노출한다는 건 말이 안 된다. 데이터 불러오기를 추가하고 넘어간다. 비동기적으로 한 가지를 불러오기 위해 전체 Flow는 필요없다. 일시중단 함수가 있기 때문이다. 도메인 레이어에 너무 많은 로직 없이 단순한 객체를 노출해야 한다.

 

class DataRepository(/* Dependencies */) {

  fun fetchDataFromMemory(): Result<Data> = memoryCache.getData()
  suspend fun fetchData: Result<Data> = dataNetworkSource.fetchData()
  fun observeData(): Flow<Data> = database.observeData()

}

 

그러나 결과를 로딩하는 개념이 마음에 들 수 있다. 하지만 이건 나중에 프레젠테이션 레이어에서만 필요하다. 그 전의 모든 것은 단순한 객체거나 Kotlin.Result 클래스로 래핑할 수 있다. LoadingResult라는 sealed interface를 정의한 다음 이 인터페이스가 뭘 하고 싶은지 생각한다.

Loading, Success, Failure의 3가지 상태를 저장할 수 있길 바란다. 또한 데이터를 불러오고 있는지 여부를 확인할 수 있어야 하기 때문에 아래처럼 클래스를 만들 수 있다.

 

sealed interface LoadingResult<out T> {
  val isLoading: Boolean

  data class Success<T>(
    val value: T, 
    override val isLoading: Boolean = false,
  ) : LoadingResult<T>

  data class Failure(
    val throwable: Throwable, 
    override val isLoading: Boolean = false,
  ) : LoadingResult<Nothing>

  data object Loading: LoadingResult<Nothing> {
    override val isLoading: Boolean = true
  }
}

fun <T> loading(): LoadingResult<T> = LoadingResult.Loading

fun <T> loadingSuccess(
  value: T,
): LoadingResult<T> = LoadingResult.Success(value)

fun <T> loadingFailure(
  throwable: Throwable,
): LoadingResult<T> = LoadingResult.Failure(throwable)

fun <T> Result<T>.toLoadingResult() = fold(
   onSuccess = { loadingSuccess(it) }, 
   onFailure = { loadingFailure(it) },
)

fun <T,R> LoadingResult<T>.map(
  block: (T) -> R,
): LoadingResult<R> = when(this) {
  is LoadingResult.Success -> LoadingResult.Success(block(value), isLoading)
  is LoadingResult.Failure -> LoadingResult.Failure(throwable, isLoading)
  is LoadingResult.Loading -> LoadingResult.Loading
}

fun <T> LoadingResult<T>.toLoading(): LoadingResult<T> = when(this) {
  is LoadingResult.Success -> copy(isLoading = true)
  is LoadingResult.Failure -> copy(isLoading = true)
  is LoadingResult.Loading -> LoadingResult.Loading
}

 

몇 가지 확장 함수가 만들어져 있는데 이건 나중에 사용한다. 중요한 건 이제 상태를 유지하고 데이터를 불러오는 중인지 여부를 나타낼 수 있는 클래스가 있다는 것이다.

이제 데이터를 불러온다. 뷰모델 자체가 데이터를 불러오게 뷰모델에 지시할 수 있는 특정 데이터 상태를 가질 수 있다면 좋지 않을까? 규칙은 아래와 같다.

 

  • 가능하면 유저가 화면에 들어오는 즉시 메모리에서 데이터를 불러와 지연 없이 표시한다
  • 쓸 수 있는 데이터가 없으면 로딩 화면을 표시한다
  • 데이터 로딩에 실패하면 이걸 정상적으로 처리하고 재시도 로직을 실행한다
  • 데이터 로드에 성공해서 유저에 표시되면 유저가 데이터를 확인하고 오류를 알 수 있게 한다

 

많은 작업으로 보이지만 이 모든 걸 하나의 기능으로 압축할 수 있다. 여기선 그 자체로 업데이트를 트리거하는 데이터 스트림을 만들고 싶다. 데이터 로더를 담을 새 인터페이스를 만든다.

첫 버전은 간단하다. 프레임을 설정하고 함수가 입력 데이터의 Flow를 리턴하게 한다.

 

sealed interface DataLoader<T> {

  fun loadAndObserveData(
    initialData: LoadingResult<T>,
  ) : Flow<LoadingResult<T>>

}

// public api to instantiate a data loader.
fun <T> DataLoader(): DataLoader<T> = DefaultDataLoader()

private class DefaultDataLoader<T>: DataLoader<T> {

  override fun loadAndObserveData(
    initialData: LoadingResult<T>,
  ): Flow<LoadingResult<T>> = flow {
    // Whatever happens, emit the current result
    emit(initialData)
  }

}

 

내부 또는 비공개 클래스에서 구현을 숨기고 쉽게 인스턴스화할 수 있는 함수를 제공하면서 공용 인터페이스를 노출하는 파일을 종종 찾을 수 있다. 독립적 방법일 수 있지만 구현 세부사항을 숨기며 간단한 인터페이스를 제공할 수 있는 좋은 방법이다.

여기선 단 하나의 동작만 필요하며 메서드가 아닌 인터페이스라서 동작을 더 쉽게 모킹할 수 있고, 나중에 인터페이스를 확장할 수 있다.

 

sealed interface DataLoader<T> {

  fun loadAndObserveData(
    initialData: LoadingResult<T>,
    observeData: (T) -> Flow<T>,
    fetchData: suspend (LoadingResult<T>) -> Result<T>,
  ) : Flow<LoadingResult<T>>

}

// public api to instantiate a data loader.
fun <T> DataLoader(): DataLoader<T> = DefaultDataLoader()

private class DefaultDataLoader<T>: DataLoader<T> {

  override fun loadAndObserveData(
    initialData: LoadingResult<T>,
    observeData: (T) -> Flow<T>,
    fetchData: suspend (LoadingResult<T>) -> Result<T>,
  ): Flow<LoadingResult<T>> = flow {
    // Little helper method to observe the data and map it to a LoadingResult
    val observe: (T) -> Flow<LoadingResult<T>> = { value -> 
      observeData(value).map { loadingSuccess(it) } 
    }
    // Whatever happens, emit the current result
    emit(initialData)
    when {
      // If the current result is loading, we fetch the data and emit the result
      initialData.isLoading -> {
        val newResult = fetchData(initialData)
        emit(newResult.toLoadingResult())
        // If the fetching is successful, we observe the data and emit it
        newResult.onSuccess { value -> emitAll(observe(value)) }
      }
    
      // If the current result is successful, we simply observe and emit the data changes
      initialData is LoadingResult.Success -> emitAll(observe(initialData.value))
      else -> {
        // Nothing to do in case of failure and not loading
      }
    }
  }
  
}

 

핵심 컨셉은 아래와 같다.

 

  • 초기 상태를 입력한다. 이 상태는 로딩 결과인 모든 상태여야 한다. 예를 들어 loading()을 전달하면 데이터 로더가 로딩 상태로 바뀌고 데이터를 불러오기 시작한다
  • 데이터 관찰 매개변수는 성공적으로 데이터를 불러온 후 방출되는 데이터 Flow 생성에 쓰인다
  • fetchData 변수는 일시중단 함수를 써서 데이터를 비동기적으로 가져올 때 쓰는 람다다. 이 람다는 데이터 로더가 로딩 상태일 때만 호출된다. 즉 초기 데이터의 isLoading이 true를 리턴하면 데이터 불러오기를 호출한다. 데이터 불러오기에 성공하면 데이터 변경사항을 관찰하는 것도 시작한다

 

이제 테스트를 시작한다.

 

val dataLoader = DataLoader<Int>()

dataLoader.loadAndObserveData(
  initialData = loading(),
  observeData = { flowOf(2,3) },
  fetchData = { success(1) },
).collect {
  println(it)
  /* Prints:
  Loading
  Success(value = 1, isLoading = false),
  Success(value = 2, isLoading = false),
  Success(value = 3, isLoading = false),
  */
}

dataLoader.loadAndObserveData(
  initialData = loadingFailure(Exception()),
  observeData = { flowOf(2,3) },
  fetchData = { success(1) },
).collect {
  println(it)
  /* Prints:
  Failure(throwable=java.lang.Exception, isLoading = false)
  */
}

dataLoader.loadAndObserveData(
  initialData = loadingFailure(Exception(), isLoading = true),
  observeData = { flowOf(2,3) },
  fetchData = { success(1) },
).collect {
  println(it)
  /* Prints:
  Failure(throwable=java.lang.Exception, isLoading = true)
  Success(value = 1, isLoading = false),
  Success(value = 2, isLoading = false),
  Success(value = 3, isLoading = false),
  */
}

dataLoader.loadAndObserveData(
  initialData = loadingSuccess(1),
  observeData = { flowOf(2,3) },
  fetchData = { success(5) },
).collect {
  println(it)
  /* Prints:
  Success(value = 1, isLoading = false),
  Success(value = 2, isLoading = false),
  Success(value = 3, isLoading = false),
  */
}

dataLoader.loadAndObserveData(
  initialData = loadingSuccess(1, isLoading = true),
  observeData = { flowOf(2,3) },
  fetchData = { success(5) },
).collect {
  println(it)
  /* Prints:
  Success(value = 1, isLoading = true),
  Success(value = 5, isLoading = false),
  Success(value = 2, isLoading = false),
  Success(value = 3, isLoading = false),
  */
}

dataLoader.loadAndObserveData(
  initialData = loading(),
  observeData = { flowOf(2,3) },
  fetchData = { failure(Exception()) },
).collect {
  println(it)
  /* Prints:
  Loading
  Failure(throwable=java.lang.Exception, isLoading = false)
  */
}

 

isLoading이 true면 데이터를 가져오고 결과가 성공 상태면 데이터 변경사항을 관찰한다. 한 가지 일만 할 수 있어서 실패에서 회복하는 것, Flow 대신 State를 쓰는 것은 할 수 없다. 여기선 반응형 데이터 스트림에 대해 확인하고 있다.

컴포즈에선 헬퍼 객체를 써서 작성한 뷰의 동작에 영향을 줄 수 있다는 걸 알 수 있다. 뷰에 초점을 맞추는 함수를 보면 Modifier에 FocusRequester를 추가하고 이 Modifier를 써서 포커스를 요청한다.

 

@Composable
fun Something(/*params*/) {
  val focusRequester = remember { FocusRequester() }
  LaunchedEffect(event) { // Some event trigger
    if (event == Event.RequestFocus) {
      focusRequester.requestFocus()
    }
  }
  TextField(
    modifier = Modifier
      .focusRequester(focusRequester),
    value = value,
    onValueChange = onValueChanged,
  )
}

 

UI는 선언형이지만 헬퍼를 써서 기능적으로 생성된 UI 동작에 영향을 줄 수 있다. 이제 헬퍼 클래스를 인스턴스화하고 로직에 연결하면 된다. 새로고침을 트리거하는 RefreshTrigger 클래스를 구현할 수 있다. 이 경우 단일 함수만 노출하고 모든 구현 세부사항은 숨긴다. 나중에 내 데이터 로더와 호환되지 않는 자체 버전을 구현할 일이 없게 하는 게 중요하기 때문이다. 새로고침 트리거의 개념은 새로고침을 누를 때마다 SharedFlow에서 Unit을 내보내는 것이다.

 

/* Data loader class will be in the same file so it can access private parts */

sealed fun interface RefreshTrigger {
  suspend fun refresh()
}

fun RefreshTrigger(): RefreshTrigger = DefaultRefreshTrigger()

private class DefaultRefreshTrigger: RefreshTrigger {

  private val _refreshEvent = MutableSharedFlow<Unit>()
  val refreshEvent = _refreshEvent.asSharedFlow()

  override suspend fun refresh() {
    _refreshEvent.send(Unit)
  }

}

 

트리거를 만든 후엔 데이터 로더가 새로고침 이벤트에 반응하기만 하면 된다. 앞서 정의한 loadAndObserveData()에 nullable 인자로 전달하면 된다.

loadAndObserveData()는 입력 상태에 따라 트리거된다. 초기 상태를 제공한 다음 그 상태 기반으로 작동한다. 이제 새로고침 트리거에 따라 입력 상태를 바꾸는 건 어떤가? 앞에서 봤듯이 loadAndObserveData는 기본적으로 하나의 Flow였다. 다른 입력값으로 이 Flow를 재시작할 수 있다면 어떤가? 코틀린엔 flatMapLatest 같은 메서드를 제공하는 라이브러리가 있다.

아래는 비슷한 로직을 써서 데이터 새로고침을 트리거하는 함수다. refreshEvent가 새 값을 내보낼 때마다 데이터를 불러오기 시작하고 관찰한다.

 

fun simplifiedExample(
  val refreshEvent: Flow<Unit>,
  val loadData: suspend () -> Int,
  val observeData: Flow<Int>,
): Flow<Int> {
  refreshEvent.flatMapLatest {
    flow { // Any time refreshEvent emits a value, this flow is recreated
      emit(loadData())
      emitAll(observeData())
    }
  }
}

 

첫 번째 새로고침 이벤트가 트리거될 때까지 작업이 차단된다. 시작 시 자체 이벤트를 실행시켜서 이걸 해결할 수 있다.

 

fun flatMapExample(
  val refreshEvent: Flow<Unit>,
  val loadData: suspend () -> Int,
  val observeData: Flow<Int>,
): Flow<Int> {
  flow {
    emit(Unit) // <- first emit an element to get the show on the road
    emitAll(refreshEvent) // <- afterwards emit any refresh event
  }.flatMapLatest {
    flow {
      emit(loadData())
      emitAll(observeData())
    }
  }
}

 

이제 원래 인터페이스에 새로고침 트리거를 추가하고 이걸 선택사항으로 만들 수도 있다. 그 후엔 원래 기능을 가져와 비공개로 만들면 컨텐츠는 여전히 유효하므로 그 위에 추가 코드만 쓰면 된다.

 

sealed interface DataLoader<T> {

  fun loadAndObserveData(
    initialData: LoadingResult<T>,
    refreshTrigger: RefreshTrigger? = null,
    observeData: (T) -> Flow<T>,
    fetchData: suspend (LoadingResult<T>) -> Result<T>,
  ) : Flow<LoadingResult<T>>

}

private class DefaultDataLoader<T>: DataLoader<T> {

  override fun loadAndObserveData(
    initialData: LoadingResult<T>,
    refreshTrigger: RefreshTrigger? = null,
    observeData: (T) -> Flow<T>,
    fetchData: suspend (LoadingResult<T>) -> Result<T>,
  ) : Flow<LoadingResult<T>> = TODO("Patience my friend...")

  private fun loadAndObserveData(
    result: LoadingResult<T>,
    observeData: (T) -> Flow<T>,
    fetchData: suspend (LoadingResult<T>) -> Result<T>,
  ): Flow<LoadingResult<T>> = flow {
    // Little helper method to observe the data and map it to a LoadingResult
    val observe: (T) -> Flow<LoadingResult<T>> = { value -> 
      observeData(value).map { loadingSuccess(it) } 
    }
    // Whatever happens, emit the current result
    emit(result)
    when {
      // If the current result is loading, we fetch the data and emit the result
      initialData.isLoading -> {
        val newResult = fetchData(result)
        emit(newResult.toLoadingResult())
        // If the fetching is successful, we observe the data and emit it
        newResult.onSuccess { value -> emitAll(observe(value)) }
      }
    
      // If the current result is successful, we simply observe and emit the data changes
      result is LoadingResult.Success -> emitAll(observe(result.value))
      else -> {
        // Nothing to do in case of failure and not loading
      }
    }
  }
  
}

 

그 다음 새로고침 이벤트를 새 데이터 로드 스트림으로 flatMap하는 새 loadAndObserveData()를 구현한다.

추가한 코드는 Unit을 써서 데이터 새로고침을 시작하는 대신 마지막으로 방출한 값을 추적한다는 것이다. 새로고침할 때마다 마지막으로 방출한 값을 Loading 상태로 전환해서 loadAndObserveData()의 매개변수로 사용한다.

 

override fun loadAndObserveData(
  refreshTrigger: RefreshTrigger?,
  initialData: LoadingResult<T>,
  observeData: (T) -> Flow<T>,
  fetchData: suspend (LoadingResult<T>) -> Result<T>,
): Flow<LoadingResult<T>> {
  val refreshEventFlow = refreshTrigger.asInstance<DefaultRefreshTrigger>()?.refreshEvent ?: emptyFlow()

  // We store the latest emitted value in the lastValue
  var lastValue = initialData
  
  return flow {
    // Emit the initial data. This will make sure we start all the work 
    // as soon as this flow is collected
    emit(lastValue)
    // Every time we collect a refresh event, we should emit the last
    // value with the isLoading flag turned to true
    refreshEventFlow.collect {
      // Make sure we do not emit if we are already in a loading state
      if (!lastValue.isLoading) {
        emit(lastValue.toLoading())
      }
    }
  }
    .flatMapLatest { currentResult -> 
      // We simply use the good old loadAndObserveData function we already made
      loadAndObserveData(currentResult, observeData, fetchData) 
    }
    // No need to emit similar values, so make them distinct
    .distinctUntilChanged() 
    // Store latest value into lastValue so we can reuse it for the next
    // refresh trigger
    .onEach { lastValue = it }
}

private fun loadAndObserveData(
  result: LoadingResult<T>,
  observeData: (T) -> Flow<T>,
  fetchData: suspend (LoadingResult<T>) -> Result<T>,
): Flow<LoadingResult<T>> = flow {
  // Little helper method to observe the data and map it to a LoadingResult
  val observe: (T) -> Flow<LoadingResult<T>> = { value -> 
    observeData(value).map { loadingSuccess(it) } 
  }
  // Whatever happens, emit the current result
  emit(result)
  when {
    // If the current result is loading, we fetch the data and emit the result
    initialData.isLoading -> {
      val newResult = fetchData(result)
      emit(newResult.toLoadingResult())
      // If the fetching is successful, we observe the data and emit it
      newResult.onSuccess { value -> emitAll(observe(value)) }
    }
  
    // If the current result is successful, we simply observe and emit the data changes
    result is LoadingResult.Success -> emitAll(observe(result.value))
    else -> {
      // Nothing to do in case of failure and not loading
    }
  }
}

 

인터페이스에 기본 구현이 있는 아래 함수를 추가하는 게 기능을 이해하고 모킹하기 더 쉬울 것이다.

 

sealed interface DataLoader<T> {

  fun loadAndObserveDataAsState(
    coroutineScope: CoroutineScope,
    initialData: LoadingResult<T>,
    refreshTrigger: RefreshTrigger? = null,
    observeData: (T) -> Flow<T>,
    fetchData: suspend (LoadingResult<T>) -> Result<T>,
  ): StateFlow<LoadingResult<T>> = loadAndObserveData(
    initialData = initialData,
    refreshTrigger = refreshTrigger,
    observeData = observeData,
    fetchData = fetchData,
  ).stateIn(
    scope = coroutineScope,
    started = SharingStarted.WhileSubscribed(),
    initialValue = initialData,
  )

  fun loadAndObserveData(
    initialData: LoadingResult<T>,
    refreshTrigger: RefreshTrigger? = null,
    observeData: (T) -> Flow<T>,
    fetchData: suspend (LoadingResult<T>) -> Result<T>,
  ): Flow<LoadingResult<T>>

}

 

이제 코루틴 스코프를 추가하고 원래 함수가 리턴한 Flow를 StateFlow로 바꾸면 된다. 뷰모델에서 데이터 로더를 직접 인스턴스화하거나 이를 위한 헬퍼 클래스를 만들 수 있다. 이 헬퍼 클래스에선 데이터 로딩 등 모든 종속성을 번들로 묶어서 뷰모델을 깔끔하게 유지할 수 있다.

아래 예에선 3개의 데이터 로딩 종속성을 추가하지만 원하는 대로 섞을 수 있다.

 

interface ExampleDataLoader {

  fun loadAndObserveData(
    coroutineScope: CoroutineScope,
    refreshTrigger: RefreshTrigger,
    onRefreshFailure: (Throwable) -> Unit,
  ): StateFlow<LoadingResult<Int>>

}

internal class DefaultExampleDataLoader(
  private val fetchIntFromMemoryUseCase: FetchIntFromMemoryUseCase = FetchIntFromMemoryUseCase,
  private val fetchIntUseCase: FetchIntUseCase = FetchIntUseCase,
  private val observeIntUseCase: ObserveIntUseCase = ObserveIntUseCase,
  private val dataLoader: DataLoader<Int> = DataLoader(),
) : ExampleDataLoader {

  override fun loadAndObserveData(
    coroutineScope: CoroutineScope,
    refreshTrigger: RefreshTrigger,
  ) = dataLoader.loadAndObserveDataAsState(
    coroutineScope = coroutineScope,
    refreshTrigger = refreshTrigger,
    initialData = fetchIntFromMemoryUseCase.fetchInt().fold(
      onSuccess = { loadingSuccess(it) },
      onFailure = { loading() },
    ),
    observeData = { observeIntUseCase.observeInt() },
    fetchData = { fetchIntUseCase.fetchInt() },
  )

}

 

모든 로딩 종속성을 헬퍼 클래스로 감쌌으므로 남은 건 데이터를 실제 State로 매핑하는 것 뿐이다.

 

class ExampleViewModel(
  exampleDataLoader: ExampleDataLoader = DefaultExampleDataLoader(),
  private val exampleDataMapper: ExampleDataMapper = ExampleDataMapper,
  private val refreshTrigger: RefreshTrigger = RefreshTrigger(),
) : ViewModel() {

  private val data = exampleDataLoader.loadAndObserveData(
    coroutineScope = viewModelScope,
    refreshTrigger = refreshTrigger,
  )
  
  val screenState = data.map { exampleDataMapper.map(it) }.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(),
    initialValue = exampleDataMapper.map(data.value),
  )
  
  fun refresh() {
    viewModelScope.launch {
      refreshTrigger.refresh()
    }
  }

}

 

아래는 예제 프로젝트 깃허브 링크다. 임의의 숫자가 표시되고 UI에 LoadingResult 객체를 표시하는 방법도 있다.

 

https://github.com/joost-klitsie/DataLoadingExample?source=post_page-----a112ced54e07--------------------------------

 

GitHub - joost-klitsie/DataLoadingExample: An example how to use a reactive way of data loading inside your view models, while c

An example how to use a reactive way of data loading inside your view models, while consuming the data from a Composable view. - joost-klitsie/DataLoadingExample

github.com

 

반응형