Hun's Blog

Android Dagger-Hilt 적용기 (6) - ViewModel + Repository(Sandwich) 본문

Android

Android Dagger-Hilt 적용기 (6) - ViewModel + Repository(Sandwich)

jhk-im 2020. 11. 20. 03:37

아래의 오픈소스를 통해서 분석하고 학습하여 개인프로젝트에 Dagger-Hilt적용

github.com/android/architecture-samples/tree/dev-hilt

 

android/architecture-samples

A collection of samples to discuss and showcase different architectural tools and patterns for Android apps. - android/architecture-samples

github.com

github.com/skydoves/Pokedex

 

skydoves/Pokedex

🗡️ Android Pokedex using Hilt, Motion, Coroutines, Flow, Jetpack (Room, ViewModel, LiveData) based on MVVM architecture. - skydoves/Pokedex

github.com

 

프로젝트 진행상황

현재까지의 진행상황이다. 

sandwich는 Pokedex 의 skydoves님이 제공하며 retrofit을 사용 할 때 네트워크 response와 error를 손쉽게 핸들링하도록 도와주는 라이브러리이다. 

github.com/skydoves/Sandwich

 

skydoves/Sandwich

🥪 A lightweight Android network response API for handling data and error response with transformation extensions. - skydoves/Sandwich

github.com

처음에는 sandwich를 사용하지 않고 구현한 후에 적용하였다. 

이렇게 해야 라이브러리의 편리함을 절실히 느낄 수 있는 듯..

 

먼저 viewModel을 살펴보도록 하자. 

class MainViewModel @ViewModelInject constructor(
  private val mainRepository: MainRepository,
  @Assisted private val savedStateHandle: SavedStateHandle
) : LiveCoroutinesViewModel() {

  private var moviesFetchingLiveData: MutableLiveData<Int> = MutableLiveData(0)
  val movies: LiveData<List<Movie>>

  val isLoading: ObservableBoolean = ObservableBoolean(false)

  private val _toastLiveData: MutableLiveData<String> = MutableLiveData()
  val toastLiveData: LiveData<String> get() = _toastLiveData

  init {
    movies = moviesFetchingLiveData.switchMap {
      launchOnViewModelScope {
        isLoading.set(true)
        this.mainRepository.getMovies(
          page = it,
          onSuccess = { isLoading.set(false) },
          onError = { _toastLiveData.postValue(it) }
        ).asLiveData()
      }
    }
  }

  @MainThread
  fun fetchMovieList(page: Int) {
    moviesFetchingLiveData.value = page
  }
}

기본 viewModel이 아닌 LiveCoroutinesViewModel()을 상속받고 있고 해당 클래스에는 다음과 같은 inline fun 메서드가 있다. 

  inline fun <T> launchOnViewModelScope(
  crossinline block: suspend () -> LiveData<T>
  ) : LiveData<T> {
    return liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
      emitSource(block())
    }
  }

그리고 해당 메서드는 viewModel의 init 블럭에서 호출되고 있다. 

해당 프로젝트는 Retrofit2 + Coroutine을 활용하고 있고 viewModel 에서 사용하기위해 viewModelScope.lanucn(Dispatchers.IO) 블럭을 만들어서 구현해야한다.

 

mainViewModel의 init { } 블럭에서 처럼 launchOnViewModelScope 메서드를 사용하면 코루틴 블럭을 자동 생성하고 mainRepository로 부터 전달받은 Movie 리스트 객체를 LiveData<List<Movie>>에 담아 반환하는 것 까지 손쉽게 구현할 수 있다. 

반환된 LiveData<List<Movie>>는 movies에 입력되고 해당 LiveData는 MainActivity의 recycler view 어댑터에서 관찰하고 있다.  

      <androidx.recyclerview.widget.RecyclerView
       	...
        app:movies="@{viewModel.movies}"
        .../>

여기서 어댑터의 동작을 설명하려면 너무 먼 길을 돌아와야하기 때문에 다시 viewModel로 돌아가보자. 

 

launchOnViewModelScope{} 블럭은 moviesFetchingLiveData.switchMap {} 블럭에서 호출된다. 

private var moviesFetchingLiveData: MutableLiveData<Int> = MutableLiveData(0) 처럼 MutableLiveData이다. 

즉, int{} 블럭은 다음과 같이 동작한다. 

 

처음 viewModel이 생성 될 때 moviesFetchingLiveData의 데이터 변동에 따라 launchOnViewModelScope 블럭이 실행되어 movies LiveData에 담겨진다. 

 

moviesFetchingLiveData 의 타입이 int이고 이것이 바로 Page를 뜻한다.  

그렇다면 페이지의 변동은 어디서 이루어질까? 바로 viewModel의 fetchMovieList()를 통해서 변동된다. 

  @MainThread
  fun fetchMovieList(page: Int, refresh: Boolean) {
     moviesFetchingLiveData.value = page
  }

 그리고 리사이클러뷰 xml 에서 app:paginationMovies를 통해 fetchMovieList() 를 실행하는 메서드에 viewModel을 입력하여 호출한다. 

     <androidx.recyclerview.widget.RecyclerView
       	...
        app:movies="@{viewModel.movies}"
        app:paginationMovies="@{viewModel}"
        .../>

여기서 자세히 설명하기에는 내용이 너무 길어지기 때문에 간략히만 설명하도록 하겠다. 

paginationMovies 메서드는 RecyclerViewPagination 클래스를 생성하는데 해당 클래스의 생성과 동시에 init 블럭에서 fetchMovieList() 에 1을 입력하여 메서드를 실행한다. 즉, 1page를 api 서버로부터 받아온다. 

RecyclerViewPagination이라는 명칭처럼 이곳에서 페이지네이션이 구현된다. 나중에 자세히..

 

자 이제 repository의 getMovies 함수를 살펴보자. 아래는 Sandwich를 활용하지 않은 코드이다. 

...
  @WorkerThread
  suspend fun getMovies(
    page: Int,
    onSuccess: () -> Unit,
    onError: (String) -> Unit
  ) = flow {

    val movies = movieDao.getMovies(page)

    if (movies.isEmpty()) {

      val newMovies = ArrayList<Movie>()

      try {

        retrofitClient.fetchMovies(page).run {
          for (movie in this.results) {
            movie.page = this.page
            newMovies.add(movie)
          }
          movieDao.saveMovies(newMovies)
          emit(newMovies)
          onSuccess()
        }
      } catch (e: Exception) {

        e.message?.let { onError(it) }
      }
    } else {
      emit(movies)
      onSuccess()
    }
  }.flowOn(Dispatchers.IO)
  ...

getMovies()는 page를 입력받고 상황에 따라 onSuccess()와 onError를 반환한다. 

그 다음으로 아주 중요한 블럭이 나오는데 .. 

 

**flow.{ }.flowOn(Dispatchers.IO)

suspend fun은 비동기로 동작하면서 하나의 값을 반환한다. 

Flow는 비동기로 동작하면서 여러개의 값을 반환하는 경우 사용하는 코루틴 Builder이다. 

해당 블럭안에서 list를 가져오는 로직을 구현한 후 emit()에 결과를 담아 반환하게 된다.

반환된 값은 collect {} 블럭을 통해 수집할 수있는데 viewModel의 launchOnViewModelScope 블럭에서 이 부분까지 자동으로 수집해준다. 

 

flow 내부 코드는 간단하다. 로컬에서 데이터를 요청하고 있으면 onSuccess + 리스트를 리턴하고 없으면 네트워크를 통해 데이터를 요청하여 onSuccess + 리스트를 리턴한다. 

retrofit 메서드는 try {} catch {} 를 통해 exception을 캐치하고 있고, exception이 발생할 경우 onError()를 반환한다. 

try {} catch {} 하나만으로 HTTP요청에 대한 error와 exception을 전부 캐치하는건 무리가 있다. 더 중요한것은 캐치를 하지못하면 앱이 멈춘다는 것이다.  수많은 에러코드와 exception에 대처하기 위한 방대한 코드가 추가되야함을 의미한다. 

 

이럴 때 바로 Sandwich가 유용하게 활용된다. 

다음은 샌드위치를 사용해서 repository의 getMovies()를 변동한 코드이다. 

  @WorkerThread
  suspend fun getMovies(
    page: Int,
    onSuccess: () -> Unit,
    onError: (String) -> Unit
  ) = flow {
    var movies = movieDao.getMovies(page)

    if (movies.isEmpty()) {
      val response = retrofitClient.getMovies(page)
      response.suspendOnSuccess {
        data.whatIfNotNull { response ->
          movies = response.results
          movies.forEach { movie -> movie.page = page }
          movieDao.saveMovies(movies)
          emit(movieDao.getMovies(page))
          onSuccess()
        }
      }
        .onError {
          onError(message())
        }
        .onException {
          onError(message())
        }
    } else {
      emit(movies)
      onSuccess()
    }
  }.flowOn(Dispatchers.IO)

local은 동일하다. 

반면 retrofit의 getMovies()메서드는 우선 reponse 변수에 담는다. 

그리고 response 변수는 Sandwich의 suspensOnSuccess { } 블럭을 실행할 수 있다.

해당 블럭은 data {} / onError{} / onException{} 블럭으로 나누어 결과값을 반환하게 된다. 위에서 고민했던 수많은 Error 코드와 Exception에 대한 방안을 위처럼 짧은 코드로 해결할 수 있게 되었다. 

data블럭에선 whatIfNotNull 블럭이 함께 실행되는데 이것 또한 skydoves님이 제공하는 라이브러리이다. 

github.com/skydoves/WhatIf

 

skydoves/WhatIf

☔ Some fluent Kotlin expressions for a single if-else statement, nullable and boolean. - skydoves/WhatIf

github.com

각종 if-else 의 nullable 과 boolean을 손쉽게 표현하기 위한 라이브러리이다.

 

여기서 끝이아니다.  response가 suspensOnSuccess { }블럭을 사용할 수 있는 이유는 해당 객체가 Sandwich의 ApiRespnse<> 에 감싸져 있기 때문이다. 다음과 같다. 

  @GET("discover/movie")
  suspend fun getMovies(
    @Query("page") page: String,
    @Query("api_key") api_key: String,
    @Query("sort_by") sort_by: String,
    @Query("language") language: String,
  ): ApiResponse<MovieResponse>

retrofit의 getMovies 메서드는 네트워크 요청 결과 값을 sandwich의 ApiResponse<MovieResponse>에 담게되고 sandwich가 결과 값을 검사하여 data { emmit(networkResult), onSuccess() } / onError { onError(message()) } / onException { onError(message()) }으로 구분하여 반환해준다. 

 

참으로 편리하다!!

 

ApiResponse<>에 담기는 MovieResponse 객체는 각각의 Network API가 어떻게 데이터를 반환하느냐에 따라 다르게 적용되기 때문에 자세한 설명은 생략

 

다음 글에선 pagination에 대해 알아보도록 하겠다. 

 

 

github.com/jhk-im/info-movies

 

GitHub - jhk-im/info-movies: Android Kotlin Project with Dagger-hilt

Android Kotlin Project with Dagger-hilt. Contribute to jhk-im/info-movies development by creating an account on GitHub.

github.com