관리 메뉴

나만을 위한 블로그

[Android] 페이징 라이브러리, Hilt, Flow로 Github API 사용하기 본문

Android

[Android] 페이징 라이브러리, Hilt, Flow로 Github API 사용하기

참깨빵위에참깨빵 2023. 4. 9. 22:31
728x90
반응형

이전 포스팅에선 Github API로부터 Flow로 데이터를 가져올 때 LiveData를 사용했지만 이번에는 Flow를 사용한 방식으로 리팩토링한 코드를 보인다. Flow를 제외한 Hilt와 페이징 라이브러리 설정은 동일하니 코드를 보고 싶다면 이전 포스팅을 확인하면 된다.

 

https://onlyfor-me-blog.tistory.com/725

 

[Android] 페이징 라이브러리, Hilt, LiveData로 Github API 사용하기

페이징 라이브러리 3을 사용해 Github API를 사용하는 법을 확인한다. 먼저 사용할 라이브러리는 아래와 같다. 페이징 라이브러리 3 Hilt Flow (repository에서 데이터를 가져올 때만 사용. 이후 LiveData 사

onlyfor-me-blog.tistory.com

 

위 포스팅에서 보인 코드 중 고칠 부분은 3가지다.

 

  • GithubRepository
  • GithubViewModel
  • 액티비티 파일

 

Repository의 변경된 코드는 아래와 같다.

 

import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class GithubRepository @Inject constructor(
    private val service: GithubApiService
) {
    fun getSearchRepoResult(query: String): Flow<PagingData<Repo>> {
        return Pager(
            config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false,
                prefetchDistance = 50
            ),
            pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow
    }
}

 

getSearchRepoResult()의 리턴타입이 LiveData에서 Flow로 바뀌었고 flow 뒤의 .asLiveData()가 사라졌다. 추가로 prefetchDisatnce의 값이 50으로 변경된 사소한 변경점을 제외해도 그렇게 크게 바뀌진 않았다. 조금 바뀐 부분은 뷰모델과 액티비티다.

 

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject

@HiltViewModel
class GithubViewModel @Inject constructor(
    private val repository: GithubRepository
): ViewModel() {
    private val _searchQuery = MutableStateFlow<String>("")

    val repoResult: Flow<PagingData<Repo>> = _searchQuery
        .flatMapLatest { query ->
            if (query.isBlank()) {
                flowOf(PagingData.empty())
            } else {
                repository.getSearchRepoResult(query).cachedIn(viewModelScope)
            }
        }

    fun searchRepos(query: String) {
        _searchQuery.value = query
    }
}

 

Flow를 사용하는 만큼 LiveData를 다루기 위해 사용했던 MutableLiveData 대신 MutableStateFlow를 사용하고 생성자에 빈 문자열을 넘겨 초기화했다. SharedFlow를 사용하겠다면 아래 코드를 참고한다.

 

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class GithubViewModel @Inject constructor(
    private val repository: GithubRepository
): ViewModel() {
    private val _searchQuery = MutableSharedFlow<String>(replay = 1)

    val repoResult: Flow<PagingData<Repo>> = _searchQuery
        .flatMapLatest { query ->
            if (query.isBlank()) {
                flowOf(PagingData.empty())
            } else {
                repository.getSearchRepoResult(query).cachedIn(viewModelScope)
            }
        }

    fun searchRepos(query: String) {
        viewModelScope.launch {
            _searchQuery.emit(query)
        }
    }
}

 

차이라면 SharedFlow를 사용할 땐 searchRepos() 안에서 viewModelScope.launch {}를 사용했는데 StateFlow를 사용할 땐 쓰지 않았다. 이것은 StateFlow와 SharedFlow의 차이 때문이다.

 

  • StateFlow : StateFlow는 상태를 저장하고, 새 값을 할당할 때 즉시 업데이트된다. 그래서 viewModelScope.launch {}를 사용할 필요 없이 값을 바꾸려면 그냥 value 프로퍼티만 할당하면 된다
  • SharedFlow : 상태를 저장하지 않아서 값을 어딘가로 전달하려면 emit()을 호출해야 한다. 또한 코루틴 안에서 호출되어야 하기 때문에 viewModelScope.launch {} 안에서 코루틴을 시작한 다음 사용해야 한다.

 

리사이클러뷰에 데이터를 뿌려 UI를 실시간으로 업데이트해야 하기 때문에 나는 StateFlow를 사용했지만, 각각 다른 작업을 수행하는 구독자에게 이벤트를 전달해야 한다면 SharedFlow를 쓸 수 있겠다. 각자 상황에 알맞은 Flow를 사용한다.

그리고 flatMapLatest {}는 Flow의 값이 바뀔 때마다 새 Flow를 만들고 이전 Flow의 작업을 취소한다. 그래서 검색어가 바뀌면 이전 쿼리를 사용한 데이터 요청을 취소하고 새 검색어에 대한 데이터 요청을 시작해야 하기 때문에 flatMapLatest를 사용하면 알맞다. 그 외에는 똑같은 코드를 사용한다.

아래는 FlatMapLatest에 대한 코틀린 공식문서의 설명이다.

 

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/flat-map-latest.html

 

flatMapLatest

Returns a flow that switches to a new flow produced by transform function every time the original flow emits a value. When the original flow emits a new value, the previous flow produced by transform block is cancelled. For example, the following flow: flo

kotlinlang.org

원래 Flow가 값을 내보낼 때마다 변환 함수에서 생성된 새 Flow로 전환하는 Flow를 반환한다. 원래 Flow가 새 값을 내보내면 변환 블록에서 생성된 이전 흐름이 취소된다

 

마지막으로 액티비티다.

 

@AndroidEntryPoint
class GithubPagingActivity :
    BaseActivity<ActivityGithubPagingBinding>(R.layout.activity_github_paging) {

    private val githubViewModel: GithubViewModel by viewModels()
    private val repoListAdapter = RepoListAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        bind {
            viewModel = githubViewModel
            lifecycleOwner = this@GithubPagingActivity

            rvRepoList.apply {
                addItemDecoration(
                    DividerItemDecoration(
                        this@GithubPagingActivity,
                        DividerItemDecoration.VERTICAL
                    )
                )
                adapter = repoListAdapter
            }

            etSearchQuery.setOnEditorActionListener { _, actionId, _ ->
                if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                    val query = etSearchQuery.text.toString()
                    if (query.isNotEmpty()) {
                        githubViewModel.searchRepos(query)
                    }
                    true
                } else {
                    false
                }
            }

            btnSearch.setOnClickListener {
                val query = etSearchQuery.text.toString()
                if (query.isNotEmpty()) {
                    githubViewModel.searchRepos(query)
                }
            }
        }

        lifecycleScope.launch {
            githubViewModel.repoResult.collectLatest { pagingData ->
                repoListAdapter.submitData(pagingData)
            }
        }

        repoListAdapter.addLoadStateListener { loadState ->
            val errorState = when {
                loadState.prepend is LoadState.Error -> loadState.prepend as LoadState.Error
                loadState.append is LoadState.Error -> loadState.append as LoadState.Error
                loadState.refresh is LoadState.Error -> loadState.refresh as LoadState.Error
                else -> null
            }
            errorState?.let {
                Timber.e("## 에러 : ${it.error}")
                Toast.makeText(this, "\uD83D\uDE28 에러 : ${it.error}", Toast.LENGTH_LONG).show()
            }
        }

    }
}

 

LiveData.observe()를 지우고 lifecycleScope.launch {} 안에서 뷰모델의 repoResult를 통해 collectLatest {}를 사용하고 있다.

이것은 Flow에서 수집된 새 값이 있을 때마다 블록 안의 코드를 실행하며 이전의 데이터 수집이 완료되지 않았어도 새 값을 받으면 블록을 즉시 실행한다. 리사이클러뷰 어댑터에 바뀐 값을 전달해서 화면을 바꿔야 하기 때문에 이것을 사용하면 알맞게 페이징 데이터들을 처리할 수 있다. 아래는 collectLatest의 문서다.

 

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/collect-latest.html?query=suspend%20fun%20%3CT%3E%20Flow%3CT%3E.collectLatest(action:%20suspend%20(T)%20-%3E%20Unit) 

 

collectLatest

Terminal flow operator that collects the given flow with a provided action. The crucial difference from collect is that when the original flow emits a new value then the action block for the previous value is cancelled. It can be demonstrated by the follow

kotlinlang.org

제공된 작업으로 지정된 Flow를 수집하는 터미널 Flow 연산자다. collect와의 중요한 차이점은 원래 Flow가 새 값을 내보내면 이전 값에 대한 작업 블록이 취소된다는 것이다

 

이제 실행하면 LiveData를 사용했을 때와 동일하게 작동할 것이다.

반응형
Comments