Android Paging 고급 Codelab

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

1. 소개

과정 내용

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

빌드할 항목

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

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

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

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

23643514cb9cf43e.png

준비물

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

2. 환경 설정

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

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

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

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

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

  • 분기 step5-9_paging_3.0: 프로젝트에 최신 버전의 Paging을 추가하는 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 스튜디오를 엽니다.
  2. 기기 또는 에뮬레이터에서 app 실행 구성을 실행합니다.

89af884fa2d4e709.png

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

50d1d2aa6e79e473.png

3. 프로젝트 개요

앱을 사용하면 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의 콘텐츠가 화면에 표시됩니다. ViewModel에 다음이 노출됩니다.

  1. LiveData<UiState>
  2. (UiAction) -> Unit 함수

UiState는 앱의 UI를 렌더링하는 데 필요한 모든 것을 표현한 것으로, 각기 다른 필드는 각기 다른 UI 구성요소에 해당합니다. 이 객체는 변경할 수 없습니다. 하지만 이 객체의 새 버전은 UI에 의해 생성 및 관찰 가능합니다. 여기에서는 사용자가 새 쿼리를 검색하거나 목록을 스크롤하여 더 많은 항목을 가져오는 작업의 결과로 객체의 새 버전이 생성됩니다.

사용자 작업은 적절하게 UiAction 유형으로 표현됩니다. ViewModel로의 상호작용을 위해 API를 단일 유형으로 묶으면 다음과 같은 이점이 있습니다.

  • 작은 API 노출 영역: 작업을 추가, 삭제 또는 변경할 수 있지만 ViewModel의 메서드 서명은 변경되지 않습니다. 이렇게 하면 리팩터링이 국소화되어 추상화 또는 인터페이스 구현이 유출될 가능성이 줄어듭니다.
  • 더 쉬운 동시 실행 관리: Codelab의 뒷부분에서 살펴보겠지만, 특정 요청의 실행 순서를 보장하는 것이 중요합니다. UiAction으로 API를 강력하게 입력하면 무엇이 언제 발생할 수 있는지에 관한 엄격한 요구사항을 포함하여 코드를 작성할 수 있습니다.

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

  • 사용자가 목록 로드 상태를 알 수 없습니다. 사용자가 새 저장소를 검색할 때 빈 화면이 표시되거나 동일한 쿼리의 결과가 더 로드되는 동안 갑자기 목록의 끝이 표시됩니다.
  • 사용자가 실패한 쿼리를 다시 시도할 수 없습니다.
  • 목록은 방향이 변경되거나 프로세스가 종료된 후 항상 맨 위로 스크롤됩니다.

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

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

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

4. 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에서는 위에 설명된 각 구성요소의 예를 구현합니다.

5. 데이터 소스 정의

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에서 두 가지 함수(load()getRefreshKey())를 구현해야 합니다.

사용자가 스크롤할 때 표시할 더 많은 데이터를 비동기식으로 가져오기 위해 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)
        }
    }

}

6. PagingData 빌드 및 구성

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

RepoSearchResult에서는 성공 및 오류 사례를 모두 캡슐화합니다. 성공 사례에는 저장소 데이터가 포함됩니다. 오류 사례에는 Exception 이유가 포함됩니다. Paging 3을 사용하면 라이브러리가 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은 많은 작업을 실행합니다.

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

즉, 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 {
        const val NETWORK_PAGE_SIZE = 50
    }
}

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

7. ViewModel에서 PagingData 요청 및 캐시

컴파일 오류를 해결하기 전에 ViewModel의 유형을 검토해 보겠습니다.

sealed class UiAction {
    data class Search(val query: String) : UiAction()
    data class Scroll(
        val visibleItemCount: Int,
        val lastVisibleItemPosition: Int,
        val totalItemCount: Int
    ) : UiAction()
}

data class UiState(
    val query: String,
    val searchResult: RepoSearchResult
)

UiState에서 searchResult를 노출합니다. searchResult는 구성 변경 이후에도 지속되는 결과 검색의 메모리 내 캐시로 사용됩니다. Paging 3을 사용하면 더 이상 FlowLiveData로 변환할 필요가 없습니다. 대신 SearchRepositoriesViewModel은 이제 StateFlow<UiState>를 노출합니다. 또한 searchResult val을 완전히 드롭하여 searchResult와 동일한 용도로 사용되는 별도의 Flow<PagingData<Repo>>를 대신 노출하도록 합니다.

PagingDataRecyclerView에 표시되는 데이터의 변경 가능한 업데이트 스트림을 포함하는 독립된 유형입니다. PagingData의 각 내보내기는 완전히 독립적이며 하나의 쿼리에 관해 여러 개의 PagingData를 내보낼 수 있습니다. 따라서 PagingDataFlows는 다른 Flows와 독립적으로 노출되어야 합니다.

그 외에 사용자 환경 혜택으로, 입력된 모든 새 쿼리와 관련해 목록의 상단으로 스크롤하면 첫 번째 검색결과가 표시됩니다. 하지만 페이징 데이터가 여러 번 내보내질 수 있으므로 사용자가 스크롤을 시작하지 않은 경우에만 목록 상단으로 스크롤할 수 있습니다.

이를 위해 UiState를 업데이트하고 lastQueryScrolledhasNotScrolledForCurrentSearch의 필드를 추가해 보겠습니다. 이러한 플래그는 스크롤해서는 안 될 때 목록 상단으로 스크롤하지 못하게 합니다.

data class UiState(
    val query: String = DEFAULT_QUERY,
    val lastQueryScrolled: String = DEFAULT_QUERY,
    val hasNotScrolledForCurrentSearch: Boolean = false
)

아키텍처를 다시 살펴보겠습니다. ViewModel에 관한 모든 요청이 하나의 진입점((UiAction) -> Unit으로 정의된 accept 필드)을 통과하므로 다음을 수행해야 합니다.

  • 진입점을 관심 있는 유형이 포함된 스트림으로 변환합니다.
  • 그러한 스트림을 변환합니다.
  • 스트림을 다시 StateFlow<UiState>로 결합합니다.

보다 실용적인 면에서 UiActionUiState로 내보내는 것을 reduce할 예정입니다. 이 과정은 일종의 조립 라인과 같습니다. UiAction 유형은 들어오는 원자재로서 효과(변형이라고도 함)를 유발하고, UiState는 UI에 결합할 준비가 된 완성된 출력에 해당합니다. 이는 UI를 UiState의 함수로 만드는 과정이라고도 합니다.

ViewModel을 다시 작성하여 서로 다른 두 스트림에서 각 UiAction 유형을 처리한 다음, 이를 몇 가지 Kotlin Flow 연산자를 사용하여 StateFlow<UiState>로 변환해 보겠습니다.

먼저 ViewModel에서 LiveData 대신에 StateFlow를 사용하도록 state의 정의를 업데이트하면서 동시에 PagingDataFlow를 노출하기 위한 필드를 추가합니다.

   /**
     * Stream of immutable states representative of the UI.
     */
    val state: StateFlow<UiState>

    val pagingDataFlow: Flow<PagingData<Repo>>

그다음으로 UiAction.Scroll 서브클래스의 정의를 업데이트합니다.

sealed class UiAction {
    ...
    data class Scroll(val currentQuery: String) : UiAction()
}

UiAction.Scroll 데이터 클래스의 모든 필드를 삭제하고 이들 필드를 단일 currentQuery 문자열로 대체했습니다. 이렇게 하면 스크롤 작업을 특정 쿼리와 연결할 수 있습니다. 또한 shouldFetchMore 확장 프로그램은 더 이상 사용되지 않으므로 삭제합니다. 이 프로그램은 프로세스가 중단되면 복원되어야 하는 항목이므로, SearchRepositoriesViewModel에서 onCleared() 메서드를 업데이트해야 합니다.

class SearchRepositoriesViewModel{
  ...
   override fun onCleared() {
        savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
        savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
        super.onCleared()
    }
}

// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"

이 시점에서 GithubRepository에서 실제로 pagingData Flow를 생성하는 메서드를 삽입하는 것도 좋습니다.

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {

    override fun onCleared() {
        ...
    }

    private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
        repository.getSearchResultStream(queryString)
}

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

이제 ViewModel의 accept 필드를 UiAction 스트림으로 변환하는 작업을 시작할 수 있습니다. SearchRepositoriesViewModelinit 블록을 다음으로 바꿉니다.

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {
    ...
    init {
        val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
        val actionStateFlow = MutableSharedFlow<UiAction>()
        val searches = actionStateFlow
            .filterIsInstance<UiAction.Search>()
            .distinctUntilChanged()
            .onStart { emit(UiAction.Search(query = initialQuery)) }
        val queriesScrolled = actionStateFlow
            .filterIsInstance<UiAction.Scroll>()
            .distinctUntilChanged()
            // This is shared to keep the flow "hot" while caching the last query scrolled,
            // otherwise each flatMapLatest invocation would lose the last query scrolled,
            .shareIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                replay = 1
            )
            .onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
   }
}

위의 코드 스니펫에 관해 살펴보겠습니다. 먼저 두 가지 항목으로 시작합니다. 하나는 저장된 상태 또는 기본값에서 가져온 initialQuery String이고, 다른 하나는 사용자가 목록과 상호작용한 마지막 검색어를 나타내는 StringlastQueryScrolled입니다. 그다음으로 Flow를 특정 UiAction 유형으로 분할합니다.

  1. UiAction.Search - 사용자가 특정 쿼리를 입력하는 각 경우
  2. UiAction.Scroll - 사용자가 포커스가 지정된 특정 쿼리와 함께 목록을 스크롤하는 각 경우

UiAction.Scroll Flow에는 몇 가지 추가 변환이 적용됩니다. 그에 관해 살펴보겠습니다.

  1. shareIn: 이 항목이 필요한 이유는 이 Flow가 최종적으로 사용될 때 flatmapLatest 연산자를 통해 사용되기 때문입니다. 업스트림에서 내보낼 때마다 flatmapLatest는 마지막으로 작업한 Flow를 취소하고 주어진 새 흐름을 기반으로 작업하기 시작합니다. 이 경우 여기서는 사용자가 스크롤한 마지막 쿼리의 값이 손실됩니다. 따라서 새 쿼리가 수신되더라도 값이 손실되지 않도록 replay 값이 1인 Flow 연산자를 사용하여 마지막 값을 캐시합니다.
  2. onStart: 캐싱에도 사용됩니다. 앱이 종료되었지만 사용자가 이미 쿼리를 스크롤했다면 목록을 다시 맨 위로 스크롤하지 않는 것이 좋습니다. 맨 위로 스크롤하면 현재의 위치를 잃게 됩니다.

아직 state, pagingDataFlowaccept 필드를 정의하지 않아 컴파일 오류가 발생합니다. 이 문제를 해결해보겠습니다. 변환이 각 UiAction에 적용된 상태에서 이제 그 변환을 사용하여 PagingData는 물론 UiState의 흐름도 만들 수 있습니다.

init {
        ...
        pagingDataFlow = searches
            .flatMapLatest { searchRepo(queryString = it.query) }
            .cachedIn(viewModelScope)

        state = combine(
            searches,
            queriesScrolled,
            ::Pair
        ).map { (search, scroll) ->
            UiState(
                query = search.query,
                lastQueryScrolled = scroll.currentQuery,
                // If the search query matches the scroll query, the user has scrolled
                hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
            )
        }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                initialValue = UiState()
            )

        accept = { action ->
            viewModelScope.launch { actionStateFlow.emit(action) }
        }
    }
}

새로운 검색어마다 Pager를 새로 만들어야 하므로 searches 흐름에 flatmapLatest 연산자를 사용합니다. 그런 다음 cachedIn 연산자를 PagingData 흐름에 적용하여 viewModelScope 내에서 흐름을 활성 상태로 유지하고 결과를 pagingDataFlow 필드에 할당합니다. UiState 측면에서는 결합 연산자를 사용하여 필수 UiState 필드를 채우고 결과 Flow를 노출된 state 필드에 할당합니다. 또한 accept를 정의할 때는 상태 머신을 제공하는 정지 함수를 시작하게 하는 람다로 정의합니다.

이제 완료됐습니다. 이제 리터럴 프로그래밍 관점과 반응형 프로그래밍 관점에서 모두 작동하는 ViewModel이 만들어졌습니다.

8. PagingData를 사용하도록 어댑터 설정

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

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

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

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

9. 네트워크 업데이트 트리거

LiveData를 Flow로 바꾸기

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

다행히 필요한 변경이 많지 않습니다. LiveDataobserve()하는 대신 coroutinelaunch()하고 Flowcollect()합니다. UiStatePagingAdapter LoadState Flow와 결합되기 때문에, 사용자가 이미 스크롤한 경우 PagingData를 새로 내보내며 목록이 맨 위로 다시 스크롤되지 않습니다.

우선 상태를 LiveData가 아닌 StateFlow로 반환하고 있기 때문에, Activity에서 LiveData의 모든 참조를 StateFlow로 바꾸고 pagingData Flow에 관한 인수도 추가해야 합니다. 첫 번째 위치는 bindState 메서드 내입니다.

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        ...
    }

이 변경사항은 연쇄 효과를 지니는데, 이제 bindSearch()bindList()를 업데이트해야 하기 때문입니다. bindSearch()의 변경이 가장 작기 때문에 이 항목부터 시작하겠습니다.

   private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener {...}
        searchRepo.setOnKeyListener {...}

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

여기에서 중요한 변경사항은 코루틴을 실행하고 UiState Flow에서 쿼리 변경사항을 수집해야 한다는 점입니다.

스크롤 문제 해결 및 데이터 결합

이제 스크롤 부분입니다. 먼저, 마지막 두 변경사항과 마찬가지로 LiveDataStateFlow로 바꾸고 pagingData Flow의 인수를 추가합니다. 이 작업이 완료되면 스크롤 리스너로 이동할 수 있습니다. 이전에는 RecyclerView에 연결된 OnScrollListener를 사용하여 더 많은 데이터를 트리거할 시기를 파악했습니다. Paging 라이브러리가 목록 스크롤을 자동으로 처리하지만, 사용자가 현재 쿼리의 목록을 스크롤했는지를 나타내는 신호로 OnScrollListener가 여전히 필요합니다. bindList() 메서드에서 setupScrollListener()를 인라인 RecyclerView.OnScrollListener로 바꾸겠습니다. setupScrollListener() 메서드도 완전히 삭제합니다.

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        // the rest of the code is unchanged
    }

그다음으로 shouldScrollToTop 부울 플래그를 만들도록 파이프라인을 설정합니다. 그러면 이제 collect할 수 있는 두 흐름 즉, PagingData FlowshouldScrollToTop Flow가 생깁니다.

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(...)
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }

위에서는 pagingData FlowcollectLatest를 사용합니다. 그러면 pagingData를 새로 내보낼 때 이전 pagingData 내보내기에 관한 수집이 취소 가능합니다. shouldScrollToTop 플래그의 경우 PagingDataAdapter.loadStateFlow의 내보내기가 UI에 표시되는 내용과 동기화됩니다. 따라서 내보낸 부울 플래그가 true가 되는 즉시 list.scrollToPosition(0)을 호출하는 것이 안전합니다.

LoadStateFlow의 유형은 CombinedLoadStates 객체입니다.

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

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

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

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

이 작업이 완료되면 활동은 다음과 같이 표시됩니다.

class SearchRepositoriesActivity : AppCompatActivity() {

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

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

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

        // bind the state
        binding.bindState(
            uiState = viewModel.state,
            pagingData = viewModel.pagingDataFlow,
            uiActions = viewModel.accept
        )
    }

    /**
     * Binds the [UiState] provided  by the [SearchRepositoriesViewModel] to the UI,
     * and allows the UI to feed back user actions to it.
     */
    private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter

        bindSearch(
            uiState = uiState,
            onQueryChanged = uiActions
        )
        bindList(
            repoAdapter = repoAdapter,
            uiState = uiState,
            pagingData = pagingData,
            onScrollChanged = uiActions
        )
    }

    private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }
        searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

    private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
        searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                list.scrollToPosition(0)
                onQueryChanged(UiAction.Search(query = it.toString()))
            }
        }
    }

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }
}

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

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

10. 바닥글에 로드 상태 표시

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

3f6f2cd47b55de92.png 661da51b58c32b8c.png

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

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

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

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

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

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

  • 레이아웃 파일: 진행률, 오류, 재시도 버튼을 표시하기 위한 UI 요소가 포함됩니다.
  • **ViewHolder** **파일**: Paging LoadState를 기반으로 UI 항목을 표시합니다.
  • 어댑터 파일: ViewHolder를 만들고 결합하는 방법을 정의합니다. RecyclerView.Adapter를 확장하는 대신 Paging 3에서 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: 머리글과 바닥글을 표시하려는 경우, 목록을 두 방향으로 모두 페이징할 수 있는 경우.

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

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { repoAdapter.retry() },
            footer = ReposLoadStateAdapter { repoAdapter.retry() }
        )
        ...
    }

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

앱을 실행해 보겠습니다.

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

11. 활동에서 로드 상태 표시

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

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

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

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

빈 목록 메시지 표시

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

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

SearchRepositoriesActivity.bindList()에서는 loadStateFlow에서 직접 수집합니다. CombinedLoadStatesrefresh 상태가 NotLoadingadapter.itemCount == 0인 경우 목록이 비어 있습니다. 그런 다음 emptyListlist의 공개 상태를 각각 전환합니다.

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !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

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        retryButton.setOnClickListener { repoAdapter.retry() }
        ...
}

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

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

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.source.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                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@SearchRepositoriesActivity,
                        "\uD83D\uDE28 Wooops ${it.error}",
                        Toast.LENGTH_LONG
                    ).show()
                }
            }
        }
    }

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

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

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

12. 목록 구분자 추가

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

573969750b4c719c.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>>을 반환합니다.

class SearchRepositoriesViewModel(
    private val repository: GithubRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    ...

    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() 메서드는 다음과 같이 표시됩니다.

   private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
        repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators { 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
                    }
                }
            }

여러 뷰 유형 지원

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에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.

13. 네트워크 및 데이터베이스에서 페이징

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

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

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

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

14. Room 데이터베이스, 테이블 및 DAO 정의

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

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

15. 데이터 요청 및 저장 - 개요

Paging 라이브러리에서는 데이터베이스를 UI에 표시해야 하는 데이터의 정보 소스로 사용합니다. 데이터베이스에 더 이상 데이터가 없을 때마다 네트워크에 더 많은 데이터를 요청해야 합니다. 이 작업에 도움이 되도록 Paging 3에서는 구현해야 하는 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 객체의 다음 배치를 로드하는 방법을 확인할 수 있습니다.

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

16. 원격 페이지 키 계산 및 저장

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
}

17. 데이터 요청 및 저장 - 구현

이제 원격 키를 저장했으므로 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를 기반으로 로드할 페이지를 찾는 방법을 알아보겠습니다.

18. 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아니지만 nextKeynull인 경우 추가할 수 있는 페이지로 나누기의 끝에 도달했다는 의미입니다.
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 nextKey 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.
        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
    }
}

19. 페이징 흐름 만들기 업데이트

이제 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, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
        return ViewModelFactory(owner, provideGithubRepository(context))
    }
}

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

       // get the view model
        val viewModel = ViewModelProvider(
            this, Injection.provideViewModelFactory(
                context = this,
                owner = 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

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

RemoteMediator 사용 시 로드 상태에 반응

지금까지 CombinedLoadStates에서 읽을 때는 항상 CombinedLoadStates.source에서 읽어왔습니다. 그러나 RemoteMediator를 사용할 경우 CombinedLoadStates.sourceCombinedLoadStates.mediator를 모두 확인해야만 정확한 로드 정보를 가져올 수 있습니다. 특히 현재는 source LoadStateNotLoading일 때 새 쿼리의 목록 상단으로 스크롤되도록 트리거하고 있습니다. 또한 새로 추가된 RemoteMediatorNotLoadingLoadState도 있어야 합니다.

이를 위해 Pager에서 가져온 목록의 표시 상태를 요약하는 enum을 정의합니다.

enum class RemotePresentationState {
    INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}

그러면 위 정의에서 연속으로 내보낸 CombinedLoadStates를 비교하고 그에 따라 목록에 있는 항목의 정확한 상태를 확인할 수 있습니다.

@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
    scan(RemotePresentationState.INITIAL) { state, loadState ->
        when (state) {
            RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
                is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
                else -> state
            }
            RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
                is LoadState.NotLoading -> RemotePresentationState.PRESENTED
                else -> state
            }
        }
    }
        .distinctUntilChanged()

위 코드를 사용하여, 목록의 상단으로 스크롤할 수 있는지 확인하는 데 사용하는 notLoading Flow의 정의를 업데이트할 수 있습니다.

       val notLoading = repoAdapter.loadStateFlow
            .asRemotePresentationState()
            .map { it == RemotePresentationState.PRESENTED }

이와 유사하게 초기 페이지 로드 중 로딩 스피너를 표시하는 경우(SearchRepositoriesActivity에서 bindList 확장 프로그램 사용)에는 여전히 LoadState.source가 사용됩니다. 이제는 RemoteMediator의 로드에 관한 로딩 스피너만 표시하려고 합니다. 공개 상태가 LoadStates에 종속된 다른 UI 요소에도 이 문제가 있습니다. 따라서 다음과 같이 LoadStates의 결합을 UI 요소에 업데이트합니다.

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                ...
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds, either from the the local db or the remote.
                list.isVisible =  loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
                }
            }
        }
    }

또한 데이터베이스를 하나의 정보 소스로 사용하므로 데이터베이스에 데이터가 있는 상태에서 앱을 실행할 수는 있습니다. 하지만 RemoteMediator를 사용해 새로고침을 할 수는 없습니다. 이는 흥미로운 특수 사례이지만 쉽게 처리할 수 있습니다. 이렇게 하려면 새로고침 상태에 오류가 있는 경우에만 헤더 LoadStateAdapter의 참조를 유지하고 LoadState를 RemoteMediator의 참조가 되도록 재정의하면 됩니다. 그러지 않으면 기본값이 사용됩니다.

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                // Show a retry header if there was an error refreshing, and items were previously
                // cached OR default to the default prepend state
                header.loadState = loadState.mediator
                    ?.refresh
                    ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
                    ?: loadState.prepend
                ...
            }
        }
    }

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

20. 요약정리

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

  • 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를 구현합니다.
  • RemoteMediator를 추가하면 LoadStatesFlowmediator 필드가 업데이트됩니다.