Android Paging

과정 내용

  • Paging 3.0의 기본 구성요소
  • Paging 3.0을 프로젝트에 추가하는 방법
  • Paging 3.0 API를 사용하여 목록에 머리글 또는 바닥글을 추가하는 방법
  • Paging 3.0 API를 사용하여 목록 구분자를 추가하는 방법
  • 네트워크 및 데이터베이스에서 페이징하는 방법

빌드할 항목

이 Codelab에서는 이미 GitHub 저장소 목록을 표시하는 샘플 앱으로 시작합니다. 사용자가 표시된 목록의 끝으로 스크롤할 때마다 새로운 네트워크 요청이 트리거되고 결과가 화면에 표시됩니다.

일련의 단계를 통해 코드를 추가하여 다음과 같은 작업을 실행합니다.

  • Paging 라이브러리 구성요소로 이전
  • 목록에 로드 상태 머리글 및 바닥글 추가
  • 모든 새 저장소 검색 사이에 로딩 진행률 표시
  • 목록에 구분자 추가
  • 네트워크 및 데이터베이스에서의 페이징을 위한 데이터베이스 지원 추가

앱은 최종적으로 다음과 같이 표시됩니다.

e662a697dd078356.png

필요한 항목

아키텍처 구성요소에 관한 소개는 뷰 Codelab이 있는 Room을 확인하세요. Flow에 관한 소개는 Kotlin Flow 및 LiveData Codelab을 사용한 고급 코루틴을 확인하세요.

이 단계에서는 전체 Codelab을 위한 코드를 다운로드한 후 간단한 예시 앱을 실행합니다.

Google에서 준비한 시작 프로젝트를 사용하면 신속하게 빌드할 수 있습니다.

git을 설치한 경우 아래 명령어를 실행하면 됩니다. 터미널/명령줄에 git --version을 입력하여 올바르게 실행되는지 확인할 수 있습니다.

 git clone https://github.com/googlecodelabs/android-paging

초기 상태는 마스터 분기에 있습니다. 특정 단계의 솔루션은 다음과 같이 확인할 수 있습니다.

  • 분기 step5-9_paging_3.0: 프로젝트에 Paging 3.0을 추가하는 5~9단계의 솔루션을 찾을 수 있습니다.
  • 분기 step10_loading_state_footer: 로딩 상태를 표시하는 바닥글을 추가하는 10단계의 솔루션을 찾을 수 있습니다.
  • 분기 step11_loading_state: 쿼리 간 로드 상태 디스플레이를 추가하는 11단계의 솔루션을 찾을 수 있습니다.
  • 분기 step12_separator: 앱에 구분자를 추가하는 12단계의 솔루션을 찾을 수 있습니다.
  • 분기 step13-19_network_and_database: 앱에 오프라인 지원을 추가하는 13~19단계의 솔루션을 찾을 수 있습니다.

git이 없는 경우 다음 버튼을 클릭하여 이 Codelab을 위한 모든 코드를 다운로드할 수 있습니다.

소스 코드 다운로드

  1. 코드의 압축을 푼 후 프로젝트 Android 스튜디오 버전 3.6.1 이상을 엽니다.
  2. 기기 또는 에뮬레이터에서 app 실행 구성을 실행합니다.

b3c0dfdb92dfed77.png

앱이 실행되고 다음과 비슷한 GitHub 저장소의 목록이 표시됩니다.

86fcb1b9b845c2f6.png

앱을 사용하면 GitHub에서 이름 또는 설명에 특정 단어가 포함된 저장소를 검색할 수 있습니다. 저장소 목록은 별표 수의 내림차순으로 정렬된 후 이름의 가나다순으로 표시됩니다.

앱은 '앱 아키텍처 가이드'에서 권장하는 아키텍처를 따릅니다. 각 패키지에는 다음과 같은 항목이 포함됩니다.

  • API: Retrofit을 사용한 GitHub API 호출
  • 데이터: API 요청을 트리거하고 메모리에 응답을 캐시하는 저장소 클래스
  • 모델: Room 데이터베이스의 테이블이기도 한 Repo 데이터 모델 및 UI에서 검색결과 데이터와 네트워크 오류를 관찰하기 위해 사용하는 RepoSearchResult 클래스
  • UI: RecyclerView가 포함된 Activity 표시와 관련된 클래스

GithubRepository 클래스는 사용자가 목록의 끝으로 스크롤할 때마다 또는 새 저장소를 검색할 때 네트워크에서 저장소 이름 목록을 가져옵니다. 쿼리 결과 목록은 ConflatedBroadcastChannelGithubRepository의 메모리에 저장되고 Flow로 노출됩니다.

SearchRepositoriesViewModel에서는 GithubRepository의 데이터를 요청하고 SearchRepositoriesActivity에 데이터를 노출합니다. 구성 변경(예: 회전) 시 데이터를 여러 번 요청하지 않기 위해 liveData() 빌더 메서드를 사용하여 ViewModel에서 FlowLiveData로 변환합니다. 이렇게 하면 LiveData에서 메모리의 최신 결과 목록을 캐시하고 SearchRepositoriesActivity가 다시 생성되면 LiveData의 콘텐츠가 화면에 표시됩니다.

사용성 측면에서 다음과 같은 문제가 있습니다.

  • 사용자가 목록 로드 상태를 알 수 없습니다. 사용자가 새 저장소를 검색할 때 빈 화면이 표시되거나 동일한 쿼리의 결과가 더 로드되는 동안 갑자기 목록의 끝이 표시됩니다.
  • 사용자가 실패한 쿼리를 다시 시도할 수 없습니다.

구현 측면에서 다음과 같은 문제가 있습니다.

  • 목록이 메모리에서 무제한으로 늘어나서 사용자가 스크롤함에 따라 메모리가 낭비됩니다.
  • 결과를 캐시하려면 Flow에서 LiveData로 변환해야 하므로 코드가 더 복잡해집니다.
  • 앱에서 여러 개의 목록을 표시해야 하는 경우 목록마다 많은 상용구를 작성해야 합니다.

Paging 라이브러리를 사용하여 이러한 문제를 해결하는 방법 및 Paging 라이브러리에 포함된 구성요소에 관해 살펴보겠습니다.

Paging 라이브러리를 사용하면 앱의 UI 내 데이터를 점진적이고 매끄럽게 로드할 수 있습니다. Paging API는 페이지에서 데이터를 로드해야 할 때 수동으로 구현해야 하는 많은 기능을 지원합니다.

  • 다음 및 이전 페이지를 가져오는 데 사용될 키를 추적합니다.
  • 사용자가 목록의 끝으로 스크롤하면 자동으로 올바른 페이지를 요청합니다.
  • 여러 요청이 동시에 트리거되지 않도록 합니다.
  • 데이터를 캐시할 수 있습니다. Kotlin을 사용하는 경우 CoroutineScope에서 이 작업이 실행되며 자바를 사용하는 경우 LiveData로 이 작업을 실행할 수 있습니다.
  • 로드 상태를 추적하여 UI의 RecyclerView 목록 항목 또는 다른 위치에 표시하고 실패한 로드를 쉽게 다시 시도할 수 있습니다.
  • Flow, LiveData, RxJava Flowable 또는 Observable 사용 여부와 관계없이 표시되는 목록에서 map 또는 filter와 같은 일반적인 작업을 실행할 수 있습니다.
  • 목록 구분자를 쉽게 구현할 수 있습니다.

앱 아키텍처 가이드에서는 다음과 같은 기본 구성요소가 포함된 아키텍처를 제안합니다.

  • 사용자에게 표시되고 사용자가 조작하는 데이터의 단일 정보 소스로 사용되는 로컬 데이터베이스
  • 웹 API 서비스
  • 데이터베이스와 함께 작동하는 저장소 및 통합된 데이터 인터페이스를 제공하는 웹 API 서비스
  • UI별 데이터를 제공하는 ViewModel
  • ViewModel의 데이터를 시각적으로 표현하는 UI

Paging 라이브러리는 이 모든 구성요소와 함께 작동하며 구성요소 간의 상호작용을 조정하므로 데이터 소스에서 콘텐츠의 '페이지'를 로드하여 UI에 콘텐츠를 표시할 수 있습니다.

이 Codelab에서는 Paging 라이브러리와 이 라이브러리의 기본 구성요소를 소개합니다.

  • PagingData: 페이지로 나눈 데이터의 컨테이너. 데이터를 새로고침할 때마다 상응하는 PagingData가 별도로 생성됩니다.
  • PagingSource: PagingSource는 데이터의 스냅샷을 PagingData의 스트림으로 로드하기 위한 기본 클래스입니다.
  • Pager.flow: 구현된 PagingSource의 구성 방법을 정의하는 함수와 PagingConfig를 기반으로 Flow<PagingData>를 빌드합니다.
  • PagingDataAdapter: RecyclerViewPagingData를 표시하는 RecyclerView.Adapter. PagingDataAdapter는 Kotlin Flow, LiveData, RxJava Flowable 또는 RxJava Observable에 연결할 수 있습니다. PagingDataAdapter는 페이지가 로드될 때 내부 PagingData 로딩 이벤트를 수신 대기하고 업데이트된 콘텐츠가 새로운 PagingData 객체 형태로 수신될 때 백그라운드 스레드에서 DiffUtil를 사용하여 세분화된 업데이트를 계산합니다.
  • RemoteMediator: 네트워크 및 데이터베이스에서 페이지로 나누기를 구현하는 데 유용합니다.

이 Codelab에서는 위에 설명된 각 구성요소의 예를 구현합니다.

PagingSource 구현에서는 데이터 소스를 정의하고 이 소스에서 데이터를 가져오는 방법을 정의합니다. PagingData 객체는 사용자가 RecyclerView에서 스크롤할 때 생성되는 힌트가 로드되면 PagingSource에서 데이터를 쿼리합니다.

현재 GithubRepository는 추가된 후 Paging 라이브러리에서 처리되는 데이터 소스와 관련하여 다양한 작업을 실행합니다.

  • 여러 요청이 동시에 트리거되지 않도록 GithubService에서 데이터를 로드합니다.
  • 검색된 데이터의 메모리 내 캐시를 유지합니다.
  • 요청될 페이지를 추적합니다.

PagingSource를 빌드하려면 다음 항목을 정의해야 합니다.

  • 페이징 키의 유형: 여기서는 GitHub API에서 페이지에 1을 기반으로 하는 색인 번호를 사용하므로 유형이 Int입니다.
  • 로드된 데이터의 유형: 여기서는 Repo 항목을 로드합니다.
  • 데이터를 가져오는 위치: GithubService에서 데이터를 가져옵니다. 데이터 소스는 특정 쿼리에 한정되므로 쿼리 정보도 GithubService에 전달해야 합니다.

따라서 data 패키지에서 GithubPagingSource라는 PagingSource 구현을 만들어 보겠습니다.

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        TODO("Not yet implemented")
    }

}

PagingSource에서 두 가지 함수(loadgetRefreshKey)를 구현해야 합니다.

사용자가 스크롤할 때 표시할 더 많은 데이터를 비동기식으로 가져오기 위해 Paging 라이브러리에서 load() 함수를 호출합니다. LoadParams 객체에는 다음 항목을 포함하여 로드 작업과 관련된 정보가 저장됩니다.

  • 로드할 페이지의 키. 로드가 처음 호출되는 경우에는 LoadParams.keynull입니다. 여기서는 초기 페이지 키를 정의해야 합니다. 이 프로젝트에서는 이 키가 초기 페이지 키이므로 GITHUB_STARTING_PAGE_INDEX 상수를 GithubRepository에서 PagingSource 구현으로 이동해야 합니다.
  • 로드 크기: 로드 요청된 항목의 수

로드 함수는 LoadResult를 반환합니다. LoadResult가 다음 유형 중 하나를 취할 수 있으므로 이 앱에서는 RepoSearchResult 대신 사용됩니다.

  • LoadResult.Page: 로드에 성공한 경우
  • LoadResult.Error: 오류가 발생한 경우

LoadResult.Page를 구성할 때 상응하는 방향으로 목록을 로드할 수 없는 경우 nextKey 또는 prevKeynull을 전달합니다. 예를 들어 여기서는 네트워크 응답에 성공했지만 목록이 비어 있는 경우에는 로드할 데이터가 없는 것으로 간주할 수 있습니다. 따라서 nextKeynull일 수 있습니다.

이 모든 정보를 바탕으로 load() 함수를 구현할 수 있습니다.

다음으로 getRefreshKey()를 구현해야 합니다. 새로고침 키는 PagingSource.load()의 후속 새로고침 호출에 사용됩니다. 첫 번째 호출은 Pager에 의해 제공되는 initialKey를 사용하는 초기 로드입니다. 새로고침은 스와이프하여 새로고침하거나 데이터베이스 업데이트, 구성 변경, 프로세스 중단 등으로 인해 무효화되어 Paging 라이브러리가 현재 목록을 대체할 새 데이터를 로드하려고 할 때마다 발생합니다. 일반적으로 후속 새로고침 호출은 가장 최근에 액세스한 인덱스를 나타내는 PagingState.anchorPosition 주변 데이터의 로드를 다시 시작하려고 합니다.

GithubPagingSource 구현은 다음과 같습니다.

// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
        val apiQuery = query + IN_QUALIFIER
        return try {
            val response = service.searchRepos(apiQuery, position, params.loadSize)
            val repos = response.items
            val nextKey = if (repos.isEmpty()) {
                null
            } else {
                // initial load size = 3 * NETWORK_PAGE_SIZE
                // ensure we're not requesting duplicating items, at the 2nd request
                position + (params.loadSize / NETWORK_PAGE_SIZE)
            }
            LoadResult.Page(
                    data = repos,
                    prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                    nextKey = nextKey
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
    // The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        // We need to get the previous key (or next key if previous is null) of the page
        // that was closest to the most recently accessed index.
        // Anchor position is the most recently accessed index
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

}

현재 구현에서는 GitHubRepositoryFlow<RepoSearchResult>를 사용하여 네트워크에서 데이터를 가져와 ViewModel에 전달합니다. 그런 다음 ViewModel에서 데이터를 LiveData로 변환하여 UI에 노출합니다. 표시된 목록의 끝에 도달하여 네트워크에서 더 많은 데이터가 로드될 때마다 Flow<RepoSearchResult>에는 최신 데이터 외에 쿼리 결과 이전에 가져온 데이터의 전체 목록이 포함됩니다.

RepoSearchResult에서는 성공 및 오류 사례를 모두 캡슐화합니다. 성공 사례에는 저장소 데이터가 포함됩니다. 오류 사례에는 Exception 이유가 포함됩니다. Paging 3.0을 사용하면 라이브러리가 LoadResult로 성공 및 오류 사례를 모두 모델링하므로 RepoSearchResult가 더 이상 필요하지 않습니다. RepoSearchResult는 다음 단계에서 대체되므로 삭제해도 됩니다.

PagingData를 구성하려면 먼저 PagingData를 앱의 다른 레이어에 전달하는 데 사용할 API를 결정해야 합니다.

  • Kotlin Flow - Pager.flow 사용
  • LiveData - Pager.liveData 사용
  • RxJava Flowable - Pager.flowable 사용
  • RxJava Observable - Pager.observable 사용

앱에서 이미 Flow를 사용하고 있으므로 이 방법을 계속 사용하겠습니다. 단 Flow<RepoSearchResult> 대신 Flow<PagingData<Repo>>를 사용합니다.

사용하는 PagingData 빌더에 관계없이 다음 매개변수를 전달해야 합니다.

  • PagingConfig. 이 클래스는 로드 대기 시간, 초기 로드의 크기 요청 등 PagingSource에서 콘텐츠를 로드하는 방법에 관한 옵션을 설정합니다. 정의해야 하는 유일한 필수 매개변수는 각 페이지에 로드해야 하는 항목 수를 가리키는 페이지 크기입니다. 기본적으로 Paging은 로드하는 모든 페이지를 메모리에 유지합니다. 사용자가 스크롤할 때 메모리를 낭비하지 않으려면 PagingConfig에서 maxSize 매개변수를 설정하세요. 기본적으로 Paging은 로드되지 않은 항목을 집계할 수 있고 enablePlaceholders 구성 플래그가 true인 경우 아직 로드되지 않은 콘텐츠의 자리표시자로 null 항목을 반환합니다. 이와 마찬가지로 어댑터에 자리표시자 뷰를 표시할 수 있습니다. 이 Codelab의 작업을 단순화하기 위해 enablePlaceholders = false를 전달하여 자리표시자를 사용 중지하겠습니다.
  • PagingSource를 만드는 방법을 정의하는 함수. 여기서는 각 새 쿼리를 위해 새 GithubPagingSource을 만듭니다.

GithubRepository를 수정해 보겠습니다.

GithubRepository.getSearchResultStream 업데이트

  • suspend 수정자를 삭제합니다.
  • Flow<PagingData<Repo>>를 반환합니다.
  • Pager를 구성합니다.
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
    return Pager(
          config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
          pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

GithubRepository 정리

Paging 3.0은 많은 작업을 실행합니다.

  • 메모리 내 캐시를 처리합니다.
  • 사용자가 목록의 끝에 가까워지면 데이터를 요청합니다.

즉, getSearchResultStreamNETWORK_PAGE_SIZE를 정의한 컴패니언 객체를 제외한 GithubRepository의 모든 값이 삭제될 수 있습니다. 이제 GithubRepository가 다음과 같이 표시됩니다.

class GithubRepository(private val service: GithubService) {

    fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        return Pager(
                config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
             ),
                pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow
    }

    companion object {
        private const val NETWORK_PAGE_SIZE = 50
    }
}

이제 SearchRepositoriesViewModel에 컴파일 오류가 발생합니다. 어떻게 변경해야 하는지 알아보겠습니다.

SearchRepositoriesViewModel에서 repoResult: LiveData<RepoSearchResult>를 노출합니다. repoResult는 구성 변경 이후에도 지속되는 결과 검색의 메모리 내 캐시로 사용됩니다. Paging 3.0을 사용하면 더 이상 FlowLiveData로 변환할 필요가 없습니다. 대신 SearchRepositoriesViewModelrepoResult와 같은 역할을 하는 비공개 Flow<PagingData<Repo>> 멤버가 포함됩니다.

각 새 쿼리에 LiveData 객체를 사용하는 대신 String만 사용할 수 있습니다. 이렇게 하면 현재 쿼리와 동일한 새 검색어를 가져올 때마다 기존 Flow가 반환됩니다. 새 검색어가 다른 경우에만 repository.getSearchResultStream()을 호출하면 됩니다.

Flow<PagingData>에는 CoroutineScope에서 Flow<PagingData>의 콘텐츠를 캐시할 수 있는 편리한 cachedIn() 메서드가 있습니다. ViewModel에 있으므로 androidx.lifecycle.viewModelScope를 사용합니다.

Paging 3.0의 내장 기능을 활용하기 위해 SearchRepositoriesViewModel의 대부분이 다시 작성됩니다. SearchRepositoriesViewModel은 다음과 같이 표시됩니다.

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
                .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

이제 SearchRepositoriesViewModel의 변경사항을 살펴보겠습니다.

  • 새 쿼리 String 및 검색결과 Flow 멤버가 추가되었습니다.
  • searchRepo() 메서드가 이전에 설명한 기능으로 업데이트되었습니다.
  • Paging 3.0 및 Flow로 목적이 달성되므로 queryLiveDatarepoResult가 삭제되었습니다.
  • Paging 라이브러리가 이 작업을 처리하므로 listScrolled()가 삭제되었습니다.
  • VISIBLE_THRESHOLD가 더 이상 필요하지 않으므로 companion object가 삭제되었습니다.

PagingDataRecyclerView에 바인딩하려면 PagingDataAdapter를 사용하세요. PagingData 콘텐츠가 로드될 때마다 PagingDataAdapter에서 알림을 받은 다음 RecyclerView에 업데이트하라는 신호를 보냅니다.

PagingData 스트림을 사용하도록 ui.ReposAdapter 업데이트

  • 현재는 ReposAdapterListAdapter를 구현합니다. 대신 PagingDataAdapter를 구현하도록 합니다. 클래스 본문의 나머지 부분은 변경되지 않습니다.
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}

지금까지 많은 사항을 변경했지만 이제 한 단계만 더 실행하면 앱을 실행할 수 있습니다. UI를 연결하기만 하면 됩니다.

Paging 3.0을 사용하도록 SearchRepositoriesActivity을 업데이트하겠습니다. Flow<PagingData>를 사용하려면 새 코루틴을 실행해야 합니다. 이 작업은 활동이 다시 생성될 때 요청을 취소하는 lifecycleScope에서 실행됩니다.

또한 사용자가 쿼리를 검색할 때마다 이전 쿼리가 취소되도록 하려고 합니다. 이렇게 하기 위해 SearchRepositoriesActivity에 새 쿼리를 검색할 때마다 취소되는 새 Job의 참조를 포함할 수 있습니다.

쿼리를 매개변수로 가져오는 새 검색 함수를 만들어 보겠습니다. 이 함수는 다음 작업을 처리해야 합니다.

  • 이전 검색 작업 취소
  • lifecycleScope에서 새 작업 실행
  • viewModel.searchRepo 호출
  • PagingData 결과 수집
  • adapter.submitData(pagingData)를 호출하여 PagingDataReposAdapter에 전달
private var searchJob: Job? = null

private fun search(query: String) {
   // Make sure we cancel the previous job before creating a new one
   searchJob?.cancel()
   searchJob = lifecycleScope.launch {
       viewModel.searchRepo(query).collectLatest {
           adapter.submitData(it)
       }
   }
}

검색 함수는 onCreate() 메서드의 SearchRepositoriesActivity에서 호출해야 합니다. updateRepoListFromInput()에서는 viewModeladapter 호출을 search()로 바꿉니다.

private fun updateRepoListFromInput() {
    binding.searchRepo.text.trim().let {
        if (it.isNotEmpty()) {
            binding.list.scrollToPosition(0)
            search(it.toString())
        }
    }
}

새로운 검색을 할 때마다 스크롤 위치를 재설정해야 하므로 binding.list.scrollToPosition(0)이 포함되었습니다. 하지만 새로운 검색에서 위치를 재설정하는 대신 목록 어댑터가 새로운 검색의 결과로 업데이트될 때 위치를 재설정해야 합니다. 이를 위해 PagingDataAdapter.loadStateFlow API를 사용할 수 있습니다. 이 Flow는 로드 상태가 변경될 때마다 CombinedLoadStates 객체를 통해 메시지를 표시합니다.

CombinedLoadStates를 사용하면 세 가지 유형의 로드 작업의 로드 상태를 가져올 수 있습니다.

  • CombinedLoadStates.refresh: PagingData를 처음 로드할 때의 로드 상태를 나타냅니다.
  • CombinedLoadStates.prepend: 목록의 시작 부분에서 데이터를 로드하는 작업의 로드 상태를 나타냅니다.
  • CombinedLoadStates.append: 목록의 끝에서 데이터를 로드하는 작업의 로드 상태를 나타냅니다.

여기서는 새로고침이 완료된 때, 즉 LoadStaterefresh, NotLoading인 경우에만 스크롤 위치를 재설정하려고 합니다.

검색을 초기화할 때 이 흐름에서 데이터를 수집하고 initSearch 메서드에서 흐름에서 새로운 메시지 표시될 때마다 위치 0으로 스크롤하겠습니다.

private fun initSearch(query: String) {
    ...
    // First part of the method is unchanged

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                   // Only emit when REFRESH LoadState changes.
                   .distinctUntilChangedBy { it.refresh }
                   // Only react to cases where REFRESH completes i.e., NotLoading.
                   .filter { it.refresh is LoadState.NotLoading }
                   .collect { binding.list.scrollToPosition(0) }
        }
}

이제 updateRepoListFromInput()에서 binding.list.scrollToPosition(0)을 삭제할 수 있습니다.

현재 Google에서는 RecyclerView에 연결된 OnScrollListener를 사용하여 더 많은 데이터를 트리거할 시기를 파악합니다. Paging 라이브러리에서 목록 스크롤을 처리하도록 할 수 있습니다. setupScrollListener() 메서드와 이 메서드의 모든 참조를 삭제합니다.

repoResult 사용도 삭제하겠습니다. 활동은 다음과 같이 표시됩니다.

class SearchRepositoriesActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySearchRepositoriesBinding
    private lateinit var viewModel: SearchRepositoriesViewModel
    private val adapter = ReposAdapter()

    private var searchJob: Job? = null

    private fun search(query: String) {
        // Make sure we cancel the previous job before creating a new one
        searchJob?.cancel()
        searchJob = lifecycleScope.launch {
            viewModel.searchRepo(query).collect {
                adapter.submitData(it)
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // get the view model
        viewModel = ViewModelProvider(this, Injection.provideViewModelFactory())
                .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        initAdapter()
        val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        search(query)
        initSearch(query)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString())
    }

    private fun initAdapter() {
        binding.list.adapter = adapter
    }

    private fun initSearch(query: String) {
        binding.searchRepo.setText(query)

        binding.searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
        binding.searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                    // Only emit when REFRESH LoadState for RemoteMediator changes.
                    .distinctUntilChangedBy { it.refresh }
                    // Only react to cases where Remote REFRESH completes i.e., NotLoading.
                    .filter { it.refresh is LoadState.NotLoading }
                    .collect { binding.list.scrollToPosition(0) }
        }
    }

    private fun updateRepoListFromInput() {
        binding.searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                search(it.toString())
            }
        }
    }

    private fun showEmptyList(show: Boolean) {
        if (show) {
            binding.emptyList.visibility = View.VISIBLE
            binding.list.visibility = View.GONE
        } else {
            binding.emptyList.visibility = View.GONE
            binding.list.visibility = View.VISIBLE
        }
    }

    companion object {
        private const val LAST_SEARCH_QUERY: String = "last_search_query"
        private const val DEFAULT_QUERY = "Android"
    }
}

앱이 컴파일되고 실행되지만 로드 상태 바닥글 및 오류에 표시되는 Toast 없이 실행됩니다. 다음 단계에서는 로드 상태 바닥글을 표시하는 방법을 살펴봅니다.

분기 step5-9_paging_3.0에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.

앱에서 로드 상태를 기반으로 바닥글을 표시하려고 합니다. 목록이 로드되는 동안 진행률 스피너를 표시하려고 합니다. 오류가 발생하면 오류와 재시도 버튼을 표시하려고 합니다.

3f6f2cd47b55de92.png 661da51b58c32b8c.png

머리글/바닥글은 표시하는 실제 항목 목록의 시작 부분에 (머리글로) 또는 끝 부분에 (바닥글로) 추가해야 하는 목록의 아이디어에 따라 작성해야 합니다. 머리글/바닥글은 하나의 요소(Paging LoadState를 기반으로 진행률 표시줄 또는 오류와 재시도 버튼을 표시하는 뷰)만 포함된 목록입니다.

로드 상태를 기반으로 머리글/바닥글을 표시하고 재시도 메커니즘을 구현하는 것은 일반적인 작업이므로 Paging 3.0 API는 이 두 작업을 모두 처리하는 데 도움이 됩니다.

머리글/바닥글 구현에는 LoadStateAdapter가 사용됩니다. 이 RecyclerView.Adapter 구현에서는 로드 상태가 변경되면 자동으로 알림을 받습니다. LoadingError 상태에서만 항목이 표시되고 LoadState에 따라 항목이 삭제, 삽입 또는 변경되면 RecyclerView에 알립니다.

재시도 메커니즘에는 adapter.retry()가 사용됩니다. 내부적으로 이 메서드에서는 오른쪽 페이지를 위해 PagingSource 구현을 호출합니다. 응답은 Flow<PagingData>를 통해 자동으로 전파됩니다.

머리글/바닥글 구현의 예를 살펴보겠습니다.

다른 목록과 마찬가지로 3개의 파일을 생성해야 합니다.

  • 레이아웃 파일: 진행률, 오류, 재시도 버튼을 표시하기 위한 UI 요소가 포함됩니다.
  • ViewHolder 파일: Paging LoadState를 기반으로 UI 항목을 표시합니다.
  • 어댑터 파일: ViewHolder를 만들고 바인딩하는 방법을 정의합니다. RecyclerView.Adapter를 확장하는 대신 Paging 3.0에서 LoadStateAdapter를 확장합니다.

뷰 레이아웃 만들기

저장소 로드 상태의 repos_load_state_footer_view_item 레이아웃을 만듭니다. 여기에는 ProgressBar, (오류를 표시하기 위한) TextView, 재시도 Button이 포함되어야 합니다. 필요한 문자열과 치수는 이미 프로젝트에 선언되어 있습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:padding="8dp">
    <TextView
        android:id="@+id/error_msg"
        android:textColor="?android:textColorPrimary"
        android:textSize="@dimen/error_text_size"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAlignment="center"
        tools:text="Timeout"/>
    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"/>
    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/retry"/>
</LinearLayout>

ViewHolder 만들기

ui 폴더에 ReposLoadStateViewHolder라고 하는 새 ViewHolder를 만듭니다**.** 재시도 함수를 재시도 버튼을 누르면 호출되는 매개변수로 수신해야 합니다. LoadState를 매개변수로 수신하고 LoadState에 따라 각 뷰의 공개 상태를 설정하는 bind() 함수를 만듭니다. ViewBinding을 사용하는 ReposLoadStateViewHolder 구현은 다음과 같습니다.

class ReposLoadStateViewHolder(
        private val binding: ReposLoadStateFooterViewItemBinding,
        retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.retryButton.setOnClickListener { retry.invoke() }
    }

    fun bind(loadState: LoadState) {
        if (loadState is LoadState.Error) {
            binding.errorMsg.text = loadState.error.localizedMessage
        }
        binding.progressBar.isVisible = loadState is LoadState.Loading
        binding.retryButton.isVisible = loadState is LoadState.Error
        binding.errorMsg.isVisible = loadState is LoadState.Error
    }

    companion object {
        fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.repos_load_state_footer_view_item, parent, false)
            val binding = ReposLoadStateFooterViewItemBinding.bind(view)
            return ReposLoadStateViewHolder(binding, retry)
        }
    }
}

LoadStateAdapter 만들기

ui 폴더의 LoadStateAdapter도 확장하는 ReposLoadStateAdapter를 만듭니다. 어댑터는 구성될 때 재시도 함수가 ViewHolder에 전달되므로 재시도 함수를 매개변수로 수신해야 합니다.

다른 Adapter와 마찬가지로 onBind()onCreate() 메서드를 구현해야 합니다. LoadStateAdapter는 두 함수 모두에서 LoadState를 전달하므로 이 메서드를 더 쉽게 구현할 수 있습니다. onBindViewHolder()에서 ViewHolder를 바인딩합니다. onCreateViewHolder()에서 상위 ViewGroup 및 재시도 함수를 기반으로 ReposLoadStateViewHolder를 만드는 방법을 정의합니다.

class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
    override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
        return ReposLoadStateViewHolder.create(parent, retry)
    }
}

이제 바닥글의 모든 요소가 준비되었으므로 목록에 바인딩하겠습니다. PagingDataAdapter에 이를 위한 세 가지 유용한 메서드가 있습니다.

  • withLoadStateHeader: 머리글만 표시하려는 경우, 목록의 시작 부분에만 항목을 추가할 수 있는 경우 사용해야 합니다.
  • withLoadStateFooter: 바닥글만 표시하려는 경우, 목록의 끝 부분에만 항목을 추가할 수 있는 경우 사용해야 합니다.
  • withLoadStateHeaderAndFooter: 머리글과 바닥글을 표시하려는 경우, 목록을 두 방향으로 모두 페이징할 수 있는 경우.

SearchRepositoriesActivity.initAdapter() 메서드를 업데이트하고 어댑터에서 withLoadStateHeaderAndFooter()를 호출합니다. 재시도 함수로 adapter.retry()을 호출할 수 있습니다.

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
}

무한 스크롤 목록이 있으므로 휴대전화나 에뮬레이터를 비행기 모드로 설정하고 목록을 끝까지 스크롤하면 쉽게 바닥글을 표시할 수 있습니다.

앱을 실행해 보겠습니다.

분기 step10_loading_state_footer에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.

현재 다음과 같은 두 가지 문제가 있음을 알 수 있습니다.

  • Paging 3.0으로 마이그레이션하는 동안 결과 목록이 비어 있으면 메시지를 표시할 수 없습니다.
  • 새 쿼리를 검색할 때마다 네트워크 응답을 받을 때까지 현재 쿼리 결과가 화면에 남아 있습니다. 이러한 사용자 환경은 좋지 않습니다. 진행률 표시줄이나 재시도 버튼을 대신 표시해야 합니다.

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

이 두 가지 문제를 해결하기 위해서는 SearchRepositoriesActivity에서 로드 상태 변경에 반응해야 합니다.

빈 목록 메시지 표시

먼저 빈 목록 메시지를 다시 가져와 보겠습니다. 이 메시지는 목록이 로드되고 목록의 항목 수가 0인 경우에만 표시됩니다. 목록이 로드되는 시점을 파악하기 위해 PagingDataAdapter.addLoadStateListener() 메서드를 사용합니다. 이 콜백은 로드 상태가 변경될 때마다 CombinedLoadStates 객체를 통해 알립니다.

CombinedLoadStates는 정의한 PageSource 또는 네트워크 및 데이터베이스에 필요한 RemoteMediator의 로드 상태를 제공합니다(자세한 내용은 뒷부분 참고).

SearchRepositoriesActivity.initAdapter()에서 addLoadStateListener를 호출합니다. CombinedLoadStatesrefresh 상태가 NotLoadingadapter.itemCount == 0인 경우 목록이 비어 있습니다. 그런 다음 showEmptyList를 호출합니다.

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)
  }
}

로드 상태 표시

재시도 버튼과 진행률 표시줄 UI 요소를 포함하도록 activity_search_repositories.xml을 업데이트해 보겠습니다.

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.SearchRepositoriesActivity">
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/input_layout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginLeft="8dp"
        android:layout_marginRight="8dp"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <EditText
            android:id="@+id/search_repo"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/search_hint"
            android:imeOptions="actionSearch"
            android:inputType="textNoSuggestions"
            android:selectAllOnFocus="true"
            tools:text="Android"/>
    </com.google.android.material.textfield.TextInputLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:paddingVertical="@dimen/row_item_margin_vertical"
        android:scrollbars="vertical"
        app:layoutManager="LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/input_layout"
        tools:ignore="UnusedAttribute"/>

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/retry"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <TextView android:id="@+id/emptyList"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/no_results"
        android:textSize="@dimen/repo_name_size"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

재시도 버튼을 누르면 PagingData가 새로고침됩니다. 이렇게 하기 위해 머리글/바닥글과 마찬가지로 onClickListener 구현에서 adapter.retry()를 호출합니다.

// SearchRepositoriesActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.retryButton.setOnClickListener { adapter.retry() }
}

다음으로 SearchRepositoriesActivity.initAdapter의 로드 상태 변경에 대응해 보겠습니다. 새 쿼리가 있을 때만 진행률 표시줄을 표시하려고 하므로 페이징 소스, 특히 CombinedLoadStates.source.refresh의 로드와 LoadState: Loading 또는 Error를 사용해야 합니다. 또한 이전 단계에서 주석 처리한 기능 하나가 오류 발생 시 Toast를 표시했으므로 이 기능도 가져와 보겠습니다. 오류 메시지를 표시하려면 CombinedLoadStates.prepend 또는 CombinedLoadStates.appendLoadState.Error의 인스턴스인지 확인하고 오류에서 오류 메시지를 가져와야 합니다.

이 기능을 포함하도록 SearchRepositoriesActivity.initAdapter 메서드를 업데이트하겠습니다.

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)

        // Only show the list if refresh succeeds.
        binding.list.isVisible = loadState.source.refresh is LoadState.NotLoading
        // Show loading spinner during initial load or refresh.
        binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading
        // Show the retry state if initial load or refresh fails.
        binding.retryButton.isVisible = loadState.source.refresh is LoadState.Error

        // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
        val errorState = loadState.source.append as? LoadState.Error
                ?: loadState.source.prepend as? LoadState.Error
                ?: loadState.append as? LoadState.Error
                ?: loadState.prepend as? LoadState.Error
        errorState?.let {
            Toast.makeText(
                    this,
                    "\uD83D\uDE28 Wooops ${it.error}",
                    Toast.LENGTH_LONG
            ).show()
        }
    }
}

이제 앱을 실행하고 작동 방식을 확인해 보겠습니다.

모든 작업이 완료되었습니다. 현재 설정을 사용하면 Paging 라이브러리 구성요소가 적절한 시점에 API 요청을 트리거하고 메모리 내 캐시를 처리하고 데이터를 표시합니다. 앱을 실행하고 저장소를 검색해 보세요.

분기 step11_loading_state에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.

구분자를 추가하면 목록의 가독성을 개선할 수 있습니다. 예를 들어, 앱에서는 저장소가 별표 수의 내림차순으로 정렬되므로 별표 10,000개마다 구분자를 사용할 수 있습니다. Paging 3.0 API에서는 PagingData에 구분자를 삽입할 수 있어 이 기능을 구현하는 데 도움이 됩니다.

170f5fa2945e7d95.png

PagingData에 구분자를 추가하면 화면에 표시되는 목록이 수정됩니다. 더 이상 Repo 객체만 표시되지 않고 구분자 객체도 표시됩니다. 따라서 ViewModel에서 노출하는 UI 모델을 Repo에서 RepoItemSeparatorItem 유형을 모두 캡슐화할 수 있는 다른 유형으로 변경해야 합니다. 그런 다음 구분자를 지원하도록 UI를 업데이트해야 합니다.

  • 구분자를 위한 레이아웃 및 ViewHolder를 추가합니다.
  • 구분자와 저장소를 모두 만들고 바인딩할 수 있도록 RepoAdapter를 업데이트합니다.

이 과정을 단계별로 진행하고 구현된 코드를 확인하겠습니다.

UI 모델 변경

현재 SearchRepositoriesViewModel.searchRepo()에서는 Flow<PagingData<Repo>>를 반환합니다. 저장소와 구분자를 모두 지원하기 위해 SearchRepositoriesViewModel를 사용하여 동일한 파일에 UiModel 봉인 클래스를 만듭니다. RepoItemSeparatorItem, 두 가지 유형의 UiModel 객체를 포함할 수 있습니다.

sealed class UiModel {
    data class RepoItem(val repo: Repo) : UiModel()
    data class SeparatorItem(val description: String) : UiModel()
}

별표 10,000개를 기반으로 저장소를 구분하려고 하므로 RepoItem에 별표 수를 올림하는 확장 속성을 만들어 보겠습니다.

private val UiModel.RepoItem.roundedStarCount: Int
    get() = this.repo.stars / 10_000

구분자 삽입

이제 SearchRepositoriesViewModel.searchRepo()에서 Flow<PagingData<UiModel>>을 반환합니다. currentSearchResult를 동일한 유형으로 지정합니다.

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<UiModel>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
        ...
    }
}

구현이 어떻게 변경되었는지 살펴보겠습니다. 현재 repository.getSearchResultStream(queryString)에서 Flow<PagingData<Repo>>를 반환하므로 먼저 각 RepoUiModel.RepoItem으로 변환하는 작업을 추가해야 합니다. Flow.map 연산자를 사용한 후 각 PagingData를 매핑하여 현재 Repo 항목의 새 UiModel.Repo를 빌드하면 Flow<PagingData<UiModel.RepoItem>>가 생성됩니다.

...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
                .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...

이제 구분자를 삽입할 수 있습니다. Flow이 표시될 때마다 PagingData.insertSeparators()를 호출합니다. 이 메서드에서는 전후 요소가 지정된 경우 각 원본 요소가 포함된 PagingData를 선택적 구분자와 함께 반환합니다. 경계 조건(목록의 시작 또는 끝)에서 각 전후 요소는 null이 됩니다. 구분자를 만들 필요가 없는 경우 null을 반환합니다.

PagingData 요소의 유형을 UiModel.Repo에서 UiModel로 변경하므로 insertSeparators() 메서드의 유형 인수를 명시적으로 설정해야 합니다.

searchRepo() 메서드는 다음과 같이 표시됩니다.

fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
    val lastResult = currentSearchResult
    if (queryString == currentQueryValue && lastResult != null) {
        return lastResult
    }
    currentQueryValue = queryString
    val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators<UiModel.RepoItem, UiModel> { before, after ->
                    if (after == null) {
                        // we're at the end of the list
                        return@insertSeparators null
                    }

                    if (before == null) {
                        // we're at the beginning of the list
                        return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                    }
                    // check between 2 items
                    if (before.roundedStarCount > after.roundedStarCount) {
                        if (after.roundedStarCount >= 1) {
                            UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                        } else {
                            UiModel.SeparatorItem("< 10.000+ stars")
                        }
                    } else {
                        // no separator
                        null
                    }
                }
            }
            .cachedIn(viewModelScope)
    currentSearchResult = newResult
    return newResult
}

여러 뷰 유형 지원

SeparatorItem 객체는 RecyclerView에 표시해야 합니다. 여기에서는 문자열만 표시하므로 res/layout 폴더에 TextView가 포함된 separator_view_item 레이아웃을 만들어 보겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/separatorBackground">

    <TextView
        android:id="@+id/separator_description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:padding="@dimen/row_item_margin_horizontal"
        android:textColor="@color/separatorText"
        android:textSize="@dimen/repo_name_size"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>

문자열만 TextView에 바인딩하는 ui 폴더에 SeparatorViewHolder를 만들어 보겠습니다.

class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    private val description: TextView = view.findViewById(R.id.separator_description)

    fun bind(separatorText: String) {
        description.text = separatorText
    }

    companion object {
        fun create(parent: ViewGroup): SeparatorViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.separator_view_item, parent, false)
            return SeparatorViewHolder(view)
        }
    }
}

Repo 대신 UiModel을 지원하도록 ReposAdapter를 업데이트합니다.

  • PagingDataAdapter 매개변수를 Repo에서 UiModel로 업데이트합니다.
  • UiModel 비교기를 구현하여 REPO_COMPARATOR를 대체합니다.
  • SeparatorViewHolder를 만들고 UiModel.SeparatorItem의 설명과 바인딩합니다.

이제 두 가지 ViewHolder를 표시해야 하므로 RepoViewHolder를 ViewHolder로 바꿉니다.

  • PagingDataAdapter 매개변수 업데이트
  • onCreateViewHolder 반환 유형 업데이트
  • onBindViewHolder holder 매개변수 업데이트

최종 ReposAdapter는 다음과 같이 표시됩니다.

class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == R.layout.repo_view_item) {
            RepoViewHolder.create(parent)
        } else {
            SeparatorViewHolder.create(parent)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is UiModel.RepoItem -> R.layout.repo_view_item
            is UiModel.SeparatorItem -> R.layout.separator_view_item
            null -> throw UnsupportedOperationException("Unknown view")
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val uiModel = getItem(position)
        uiModel.let {
            when (uiModel) {
                is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
                is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
            }
        }
    }

    companion object {
        private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
            override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
                return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
                        oldItem.repo.fullName == newItem.repo.fullName) ||
                        (oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
                                oldItem.description == newItem.description)
            }

            override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
                    oldItem == newItem
        }
    }
}

모든 작업이 완료되었습니다. 앱을 실행하면 구분자를 볼 수 있습니다.

분기 step12_separators에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.

로컬 데이터베이스에 데이터를 저장하여 앱에 오프라인 지원을 추가해 보겠습니다. 이렇게 하면 데이터베이스가 앱의 정보 소스가 되고 항상 데이터베이스에서 데이터가 로드됩니다. 더 이상 데이터가 없을 때마다 네트워크에 더 많은 데이터를 요청한 다음 데이터베이스에 저장합니다. 데이터베이스가 정보 소스이므로 더 많은 데이터가 저장되면 UI가 자동으로 업데이트됩니다.

오프라인 지원을 추가하려면 다음 단계를 따르세요.

  1. Room 데이터베이스, Repo 객체를 저장할 테이블, Repo 객체로 작업하는 데 사용할 DAO를 만듭니다.
  2. RemoteMediator를 구현하여 데이터베이스에서 데이터 끝에 도달했을 때 네트워크에서 데이터를 로드하는 방법을 정의합니다.
  3. Repos 테이블을 기반으로 Pager를 데이터 소스로 만들고 데이터를 로드하고 저장하기 위한 RemoteMediator를 만듭니다.

각 단계를 실행해 보겠습니다.

Repo 객체는 데이터베이스에 저장해야 하므로 먼저 Repo 클래스를 tableName = "repos"인 항목으로 만들어 보겠습니다. 여기서 Repo.id는 기본 키입니다. 이렇게 하려면 Repo 클래스에 @Entity(tableName = "repos")로 주석을 추가하고 @PrimaryKey 주석을 id에 추가하세요. 이제 Repo 클래스가 다음과 같이 표시됩니다.

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey @field:SerializedName("id") val id: Long,
    @field:SerializedName("name") val name: String,
    @field:SerializedName("full_name") val fullName: String,
    @field:SerializedName("description") val description: String?,
    @field:SerializedName("html_url") val url: String,
    @field:SerializedName("stargazers_count") val stars: Int,
    @field:SerializedName("forks_count") val forks: Int,
    @field:SerializedName("language") val language: String?
)

db 패키지를 만듭니다. 여기에서 데이터베이스의 데이터에 액세스하는 클래스와 데이터베이스를 정의하는 클래스를 구현합니다.

@Dao로 주석이 추가된 RepoDao 인터페이스를 만들어 repos 테이블에 액세스하기 위한 데이터 액세스 객체(DAO)를 구현합니다. Repo에 다음 작업이 필요합니다.

  • Repo 객체의 목록을 삽입합니다. Repo 객체가 이미 테이블에 있는 경우 이 객체를 바꿉니다.
 @Insert(onConflict = OnConflictStrategy.REPLACE)
 suspend fun insertAll(repos: List<Repo>)
  • 이름 또는 설명에 쿼리 문자열이 포함된 저장소를 쿼리하고 결과를 별표 수의 내림차순으로 정렬한 후 이름의 가나다순으로 정렬합니다. List<Repo>를 반환하는 대신 PagingSource<Int, Repo>를 반환합니다. 이렇게 하면 repos 테이블이 Paging의 데이터 소스가 됩니다.
@Query("SELECT * FROM repos WHERE " +
  "name LIKE :queryString OR description LIKE :queryString " +
  "ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
  • Repos 테이블의 모든 데이터를 지웁니다.
@Query("DELETE FROM repos")
suspend fun clearRepos()

RepoDao는 다음과 같이 표시됩니다.

@Dao
interface RepoDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(repos: List<Repo>)

    @Query("SELECT * FROM repos WHERE " +
   "name LIKE :queryString OR description LIKE :queryString " +
   "ORDER BY stars DESC, name ASC")
    fun reposByName(queryString: String): PagingSource<Int, Repo>

    @Query("DELETE FROM repos")
    suspend fun clearRepos()
}

Repo 데이터베이스를 구현합니다.

  • RoomDatabase를 확장하는 추상 클래스 RepoDatabase를 만듭니다.
  • 클래스에 @Database로 주석을 추가하고 Repo 클래스를 포함하도록 항목 목록을 설정하고 데이터베이스 버전을 1로 설정합니다. 이 Codelab의 목적상 스키마를 내보낼 필요가 없습니다.
  • ReposDao를 반환하는 추상 함수를 정의합니다.
  • companion objectRepoDatabase 객체가 아직 존재하지 않는 경우 객체를 빌드하는 getInstance() 함수를 만듭니다.

RepoDatabase는 다음과 같습니다.

@Database(
    entities = [Repo::class],
    version = 1,
    exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao

    companion object {

        @Volatile
        private var INSTANCE: RepoDatabase? = null

        fun getInstance(context: Context): RepoDatabase =
                INSTANCE ?: synchronized(this) {
                    INSTANCE
                            ?: buildDatabase(context).also { INSTANCE = it }
                }

        private fun buildDatabase(context: Context) =
                Room.databaseBuilder(context.applicationContext,
                        RepoDatabase::class.java, "Github.db")
                        .build()
    }
}

이제 데이터베이스를 설정했으므로 네트워크에 데이터를 요청하고 데이터베이스에 저장하는 방법을 살펴보겠습니다.

Paging 라이브러리에서는 데이터베이스를 UI에 표시해야 하는 데이터의 정보 소스로 사용합니다. 데이터베이스에 더 이상 데이터가 없을 때마다 네트워크에 더 많은 데이터를 요청해야 합니다. 이 작업에 도움이 되도록 Paging 3.0에서는 구현해야 하는 load() 메서드와 함께 RemoteMediator 추상 클래스를 정의합니다. 네트워크에서 더 많은 데이터를 로드해야 할 때마다 이 메서드가 호출됩니다. 이 클래스는 MediatorResult 객체(다음 중 하나)를 반환합니다.

  • Error: 네트워크에 데이터를 요청하는 동안 오류가 발생한 경우
  • Success: 네트워크에서 데이터를 가져온 경우. 여기에서 더 많은 데이터를 로드할 수 있는지 여부를 나타내는 신호도 전달해야 합니다. 예를 들어 네트워크 응답에 성공했지만 저장소 목록이 비어 있으면 더 이상 로드할 데이터가 없는 것입니다.

data 패키지에서 RemoteMediator를 확장하는 GithubRemoteMediator라고 하는 새 클래스를 만들어 보겠습니다. 이 클래스는 새 쿼리마다 다시 생성되므로 다음 항목을 매개변수로 수신합니다.

  • 쿼리 String
  • GithubService: 네트워크 요청을 만들 수 있습니다.
  • RepoDatabase: 네트워크 요청에서 가져온 데이터를 저장할 수 있습니다.
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
    private val query: String,
    private val service: GithubService,
    private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {

   }
}

네트워크 요청을 작성할 수 있도록 로드 메서드에는 필요한 정보를 모두 제공하는 두 개의 매개변수가 있습니다.

  • PagingState: 이전에 로드된 페이지, 목록에서 가장 최근에 액세스한 색인, 페이징 스트림을 초기화할 때 정의한 PagingConfig에 관한 정보를 제공합니다.
  • LoadType: 이전에 로드한 데이터의 끝 부분(LoadType.APPEND) 또는 시작 부분(LoadType.PREPEND)에서 데이터를 로드해야 하는지 또는 데이터를 처음으로 로드하는지(LoadType.REFRESH)를 나타냅니다.

예를 들어 로드 유형이 LoadType.APPEND이면 PagingState에서 로드된 마지막 항목을 가져옵니다. 이 항목을 기반으로 로드될 다음 페이지를 계산하여 Repo 객체의 다음 배치를 로드하는 방법을 확인할 수 있습니다.

다음 섹션에서는 로드될 다음 페이지와 이전 페이지의 키를 계산하는 방법을 설명합니다.

GitHub API의 목적상 저장소의 페이지를 요청하는 데 사용하는 페이지 키는 다음 페이지를 가져올 때 증가하는 페이지 색인입니다. 즉 Repo 객체가 제공된 경우 페이지 색인 + 1을 기반으로 Repo 객체의 다음 배치를 요청할 수 있습니다. Repo 객체의 이전 배치는 페이지 색인 - 1을 기반으로 요청할 수 있습니다. 특정 페이지 응답에서 수신된 모든 Repo 객체는 다음 키와 이전 키가 동일합니다.

PagingState에서 마지막으로 로드된 항목을 가져오면 항목이 속한 페이지의 색인을 알 수 없습니다. 이 문제를 해결하기 위해 remote_keys라고 하는 각 Repo의 다음 및 이전 페이지 키를 저장하는 다른 테이블을 추가할 수 있습니다. Repo 테이블에서 이 작업을 실행할 수 있지만 Repo와 연결된 다음 및 이전 원격 키의 새 테이블을 만들면 관심사를 더 효과적으로 구분할 수 있습니다.

db 패키지에서 RemoteKeys라고 하는 새로운 데이터 클래스를 만들고 @Entity로 주석을 추가하고 저장소 id(기본 키이기도 함), 이전 키와 다음 키(앞이나 뒤에 데이터를 추가할 수 없는 경우 null일 수 있음), 세 가지 속성을 추가해 보겠습니다.

@Entity(tableName = "remote_keys")
data class RemoteKeys(
    @PrimaryKey
    val repoId: Long,
    val prevKey: Int?,
    val nextKey: Int?
)

RemoteKeysDao 인터페이스를 만들어 보겠습니다. 다음과 같은 기능이 필요합니다.

  • RemoteKeys의 목록 삽입. 네트워크에서 Repos를 가져올 때마다 원격 키를 생성하므로 필요합니다.
  • Repo id를 기준으로 RemoteKey 가져오기.
  • RemoteKeys 지우기. 새 쿼리가 있을 때마다 사용됩니다.
@Dao
interface RemoteKeysDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
    suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

    @Query("DELETE FROM remote_keys")
    suspend fun clearRemoteKeys()
}

데이터베이스에 RemoteKeys 테이블을 추가하고 RemoteKeysDao 액세스 권한을 제공해 보겠습니다. 이렇게 하려면 RepoDatabase를 다음과 같이 업데이트합니다.

  • 항목 목록에 RemoteKeys를 추가합니다.
  • RemoteKeysDao를 추상 함수로 노출합니다.
@Database(
        entities = [Repo::class, RemoteKeys::class],
        version = 1,
        exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao
    abstract fun remoteKeysDao(): RemoteKeysDao

    ...
    // rest of the class doesn't change
}

이제 원격 키를 저장했으므로 GithubRemoteMediator로 돌아가 키를 사용하는 방법을 살펴보겠습니다. 이 클래스는 GithubPagingSource를 대체합니다. GithubRemoteMediatorGithubPagingSource에서 GITHUB_STARTING_PAGE_INDEX 선언을 복사하고 GithubPagingSource 클래스를 삭제하겠습니다.

GithubRemoteMediator.load() 메서드를 구현하는 방법을 알아보겠습니다.

  1. LoadType을 기반으로 네트워크에서 로드해야 하는 페이지를 확인합니다.
  2. 네트워크 요청을 트리거합니다.
  3. 네트워크 요청이 완료된 후 수신된 저장소 목록이 비어 있지 않으면 다음 작업을 실행합니다.
  4. 모든 RepoRemoteKeys를 계산합니다.
  5. 새로운 쿼리(loadType = REFRESH)인 경우 데이터베이스를 지웁니다.
  6. RemoteKeysRepos를 데이터베이스에 저장합니다.
  7. MediatorResult.Success(endOfPaginationReached = false)를 반환합니다.
  8. 저장소 목록이 비어 있는 경우 MediatorResult.Success(endOfPaginationReached = true)를 반환합니다. 데이터를 요청하는 중에 오류가 발생하면 MediatorResult.Error를 반환합니다.

코드는 전체적으로 다음과 같이 표시됩니다. 나중에 TODO를 대체합니다.

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
    val page = when (loadType) {
        LoadType.REFRESH -> {
         // TODO
        }
        LoadType.PREPEND -> {
        // TODO
        }
        LoadType.APPEND -> {
        // TODO
        }
    }
    val apiQuery = query + IN_QUALIFIER

    try {
        val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)

        val repos = apiResponse.items
        val endOfPaginationReached = repos.isEmpty()
        repoDatabase.withTransaction {
            // clear all tables in the database
            if (loadType == LoadType.REFRESH) {
                repoDatabase.remoteKeysDao().clearRemoteKeys()
                repoDatabase.reposDao().clearRepos()
            }
            val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
            val nextKey = if (endOfPaginationReached) null else page + 1
            val keys = repos.map {
                RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
            }
            repoDatabase.remoteKeysDao().insertAll(keys)
            repoDatabase.reposDao().insertAll(repos)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    } catch (exception: IOException) {
        return MediatorResult.Error(exception)
    } catch (exception: HttpException) {
        return MediatorResult.Error(exception)
    }
}

LoadType를 기반으로 로드할 페이지를 찾는 방법을 알아보겠습니다.

이제 페이지 키가 있을 때 GithubRemoteMediator.load() 메서드에서 어떤 일이 발생하는지 알 수 있으므로 페이지 키를 계산하는 방법을 알아보겠습니다. 방법은 LoadType에 따라 다릅니다.

LoadType.APPEND

현재 로드된 데이터 세트의 끝 부분에서 데이터를 로드해야 하는 경우 로드 매개변수는 LoadType.APPEND입니다. 따라서 이제 데이터베이스의 마지막 항목을 기반으로 네트워크 페이지 키를 계산해야 합니다.

  1. 데이터베이스에서 로드된 마지막 Repo 항목의 원격 키를 가져와야 합니다. 함수에서 이 기능을 구분해 보겠습니다.
    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
        // Get the last page that was retrieved, that contained items.
        // From that last page, get the last item
        return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
                ?.let { repo ->
                    // Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
                }
    }
  1. remoteKeys가 null이면 새로고침 결과가 아직 데이터베이스에 없는 것입니다. RemoteKeys가 null이 아니게 되면 Paging이 이 메서드를 호출하므로 endOfPaginationReached = false와 함께 Success를 반환할 수 있습니다. remoteKeys는 null아니지만 prevKeynull인 경우 추가할 수 있는 페이지로 나누기의 끝에 도달했다는 의미입니다.
val page = when (loadType) {
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        // We can return Success with endOfPaginationReached = false because Paging
        // will call this method again if RemoteKeys becomes non-null.
        // If remoteKeys is NOT NULL but its prevKey is null, that means we've reached
        // the end of pagination for append.
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
      ...
  }

LoadType.PREPEND

현재 로드된 데이터 세트의 시작 부분에서 데이터를 로드해야 하는 경우 로드 매개변수는 LoadType.PREPEND입니다. 데이터베이스의 첫 번째 항목을 기반으로 네트워크 페이지 키를 계산해야 합니다.

  1. 데이터베이스에서 로드된 첫 번째 Repo 항목의 원격 키를 가져와야 합니다. 함수에서 이 기능을 구분해 보겠습니다.
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
    // Get the first page that was retrieved, that contained items.
    // From that first page, get the first item
    return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let { repo ->
                // Get the remote keys of the first items retrieved
                repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
            }
}
  1. remoteKeys가 null이면 새로고침 결과가 아직 데이터베이스에 없는 것입니다. RemoteKeys가 null이 아니게 되면 Paging이 이 메서드를 호출하므로 endOfPaginationReached = false와 함께 Success를 반환할 수 있습니다. remoteKeys는 null아니지만 prevKeynull인 경우 추가할 수 있는 페이지로 나누기의 끝에 도달했다는 의미입니다.
val page = when (loadType) {
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        // We can return Success with `endOfPaginationReached = false` because Paging
        // will call this method again if RemoteKeys becomes non-null.
        // If remoteKeys is NOT NULL but its prevKey is null, that means we've reached
        // the end of pagination for prepend.
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }

      ...
  }

LoadType.REFRESH

LoadType.REFRESH는 데이터를 처음 로드할 때 또는 PagingDataAdapter.refresh()가 호출되는 경우 호출됩니다. 따라서 이제 데이터를 로드하기 위한 참조 지점은 state.anchorPosition입니다. 첫 번째 로드인 경우 anchorPositionnull입니다. PagingDataAdapter.refresh()가 호출되면 anchorPosition이 표시된 목록에 처음으로 표시되는 위치이므로 그 특정 항목이 포함된 페이지를 로드해야 합니다.

  1. stateanchorPosition을 기준으로 state.closestItemToPosition()을 호출하여 가장 가까운 Repo 항목을 그 위치로 가져올 수 있습니다.
  2. Repo 항목을 기준으로 데이터베이스에서 RemoteKeys를 가져올 수 있습니다.
private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Repo>
): RemoteKeys? {
    // The paging library is trying to load data after the anchor position
    // Get the item closest to the anchor position
    return state.anchorPosition?.let { position ->
        state.closestItemToPosition(position)?.id?.let { repoId ->
   repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
        }
    }
}
  1. remoteKey가 null이 아니면 데이터베이스에서 nextKey를 가져올 수 있습니다. GitHub API에서는 페이지 키가 순차적으로 증가합니다. 따라서 현재 항목이 포함된 페이지를 가져오려면 remoteKey.nextKey에서 1을 빼면 됩니다.
  2. RemoteKeynull이면 (anchorPositionnull이었으므로) 초기 페이지(GITHUB_STARTING_PAGE_INDEX)를 로드해야 합니다.

이제 전체 페이지 계산은 다음과 같습니다.

val page = when (loadType) {
    LoadType.REFRESH -> {
        val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
        remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
    }
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
}

이제 ReposDao에서 GithubRemoteMediatorPagingSource를 구현했으므로 이를 사용하려면 GithubRepository.getSearchResultStream을 업데이트해야 합니다.

이렇게 하려면 GithubRepository에서 데이터베이스에 액세스할 수 있어야 합니다. 데이터베이스를 생성자의 매개변수로 전달하겠습니다. 또한 이 클래스에서는 GithubRemoteMediator을 사용하므로

class GithubRepository(
        private val service: GithubService,
        private val database: RepoDatabase
) { ... }

Injection 파일을 업데이트합니다.

  • provideGithubRepository 메서드는 컨텍스트를 매개변수로 가져오고 GithubRepository 생성자에서 RepoDatabase.getInstance를 호출해야 합니다.
  • provideViewModelFactory 메서드는 컨텍스트를 매개변수로 가져와서 provideGithubRepository에 전달해야 합니다.
object Injection {

    private fun provideGithubRepository(context: Context): GithubRepository {
        return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
    }

    fun provideViewModelFactory(context: Context): ViewModelProvider.Factory {
        return ViewModelFactory(provideGithubRepository(context))
    }
}

SearchRepositoriesActivity.onCreate() 메서드를 업데이트하고 컨텍스트를 Injection.provideViewModelFactory()에 전달합니다.

// get the view model
viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(this))
        .get(SearchRepositoriesViewModel::class.java)

GithubRepository로 돌아가겠습니다. 먼저 이름으로 저장소를 검색하려면 %를 쿼리 문자열의 시작과 끝에 추가해야 합니다. 그런 다음 reposDao.reposByName을 호출할 때 PagingSource를 가져옵니다. 데이터베이스에서 변경할 때마다 PagingSource가 무효화되므로 PagingSource의 새 인스턴스를 가져오는 방법을 Paging에 알려야 합니다. 이렇게 하기 위해 데이터베이스 쿼리를 호출하는 함수를 만듭니다.

// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory =  { database.reposDao().reposByName(dbQuery)}

이제 GithubRemoteMediatorpagingSourceFactory를 사용하도록 Pager 빌더를 변경할 수 있습니다. Pager는 시험용 API이므로 @OptIn으로 주석을 추가해야 합니다.

@OptIn(ExperimentalPagingApi::class)
return Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
        remoteMediator = GithubRemoteMediator(
                query,
                service,
                database
        ),
        pagingSourceFactory = pagingSourceFactory
).flow

이제 완료됐습니다. 앱을 실행해 보겠습니다.

로드 상태 소스 업데이트

현재 앱은 네트워크에서 데이터를 로드하고 데이터베이스에 저장하지만, SearchRepositoriesActivity.initAdapter로 초기 페이지 로드 중 로딩 스피너를 표시하는 경우에는 여전히 LoadState.source를 사용합니다. 이제는 RemoteMediator의 로드에 관한 로딩 스피너만 표시하려고 합니다. 이렇게 하려면 다음과 같이 LoadState.source에서 LoadState.mediator로 변경해야 합니다.

private fun initAdapter() {
         ...
        adapter.addLoadStateListener { loadState ->
            // Only show the list if refresh succeeds.
            binding.list.isVisible = loadState.mediator?.refresh is LoadState.NotLoading
            // Show loading spinner during initial load or refresh.
            binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
            // Show the retry state if initial load or refresh fails.
            binding.retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error
            ... // everything else stays the same
    }

분기 step13-19_network_and_database에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.

이제 모든 구성요소를 추가했으므로 지금까지 배운 내용을 요약해 보겠습니다.

  • PagingSource는 사용자가 정의한 소스에서 데이터를 비동기식으로 로드합니다.
  • Pager.flow는 구성 및 PagingSource를 인스턴스화하는 방법을 정의하는 함수를 기반으로 Flow<PagingData>를 생성합니다.
  • PagingSource에서 새 데이터를 로드할 때마다 Flow에서 새 PagingData를 내보냅니다.
  • UI는 변경된 PagingData를 관찰하고 PagingDataAdapter를 사용하여 데이터를 표시하는 RecyclerView를 업데이트합니다.
  • UI에서 실패한 로드를 다시 시도하려면 PagingDataAdapter.retry 메서드를 사용하세요. 내부적으로 Paging 라이브러리가 PagingSource.load() 메서드를 트리거합니다.
  • 목록에 구분자를 추가하려면 구분자를 지원되는 유형 중 하나로 사용하여 상위 수준 유형을 만듭니다. 그런 다음 PagingData.insertSeparators() 메서드를 사용하여 구분자 생성 로직을 구현합니다.
  • 로드 상태를 머리글 또는 바닥글로 표시하려면 PagingDataAdapter.withLoadStateHeaderAndFooter() 메서드를 사용하고 LoadStateAdapter를 구현합니다. 로드 상태를 기반으로 다른 작업을 실행하려면 PagingDataAdapter.addLoadStateListener() 콜백을 사용합니다.
  • 네트워크 및 데이터베이스를 사용하려면 RemoteMediator를 구현합니다.