Android Paging 기본사항

1. 소개

학습할 내용

  • Paging 라이브러리의 주요 구성요소
  • Paging 라이브러리를 프로젝트에 추가하는 방법

빌드할 항목

이 Codelab에서는 이미 기사 목록을 표시하는 샘플 앱을 사용하여 시작합니다. 목록은 정적이며 기사가 500개 포함되어 있고 모두 휴대전화의 메모리에 저장되어 있습니다.

7d256d9c74e3b3f5.png

Codelab을 진행하면서 다음 내용을 알아봅니다.

  • 페이지로 나누기 개념
  • Paging 라이브러리의 핵심 구성요소
  • Paging 라이브러리를 사용하여 페이지로 나누기를 구현하는 방법

완료되면 다음과 같은 앱이 생성됩니다.

  • 페이지로 나누기를 구현합니다.
  • 더 많은 데이터를 가져올 때 사용자에게 효과적으로 전달됩니다.

다음은 최종 UI의 미리보기입니다.

6277154193f7580.gif

필요한 항목

도움이 될 지식

2. 환경 설정

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

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

git을 설치했다면 아래 명령어를 실행하면 됩니다. git이 설치되어 있는지 확인하려면 터미널이나 명령줄에 git --version을 입력하여 올바르게 실행되는지 확인합니다.

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

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

코드는 basic 폴더와 advanced 폴더 두 개로 구성됩니다. 이 Codelab에서는 basic 폴더만 사용합니다.

basic 폴더에도 start 폴더와 end 폴더가 있습니다. start 폴더의 코드로 작업을 시작할 텐데, Codelab이 끝나면 start 폴더의 코드가 end 폴더의 코드와 같아집니다.

  1. Android 스튜디오의 basic/start 디렉터리에서 프로젝트를 엽니다.
  2. 기기 또는 에뮬레이터에서 app 실행 구성을 실행합니다.

89af884fa2d4e709.png

기사 목록이 표시됩니다. 끝까지 스크롤하여 목록이 정적인지 확인합니다. 정적 목록이라면 목록의 끝에 도달했을 때 항목을 더 가져오지 않아야 합니다. 상단으로 스크롤하여 여전히 모든 항목이 있는지 확인합니다.

3. 페이지로 나누기 소개

사용자에게 정보를 표시하는 가장 일반적인 방법 중 하나는 목록을 사용하는 것입니다. 그러나 때때로 이러한 목록은 전체 콘텐츠로 통하는 작은 창에 불과합니다. 사용자는 제공되는 정보를 스크롤할 때 이미 확인한 정보를 보완할 수 있는 더 많은 데이터가 있을 것이라고 예상하는 경우가 많습니다. 데이터를 가져올 때 효율적이고 원활하게 진행되어 계속 늘어나는 로드가 사용자 환경을 저해하지 않아야 합니다. 점진적 로드는 성능 향상에도 도움이 됩니다. 앱에서 많은 양의 데이터를 한 번에 메모리에 저장할 필요가 없기 때문입니다.

정보를 점진적으로 가져오는 이 프로세스를 페이지로 나누기라고 하며 각 페이지는 가져올 데이터 청크에 상응합니다. 페이지를 요청하려면 페이징되는 데이터 소스에는 필요한 정보를 정의하는 쿼리가 필요한 경우가 많습니다. 이 Codelab의 나머지 부분에서는 Paging 라이브러리를 소개하고 이 라이브러리를 통해 앱에서 빠르고 효율적으로 페이지로 나누기를 구현할 수 있는 방법을 설명합니다.

Paging 라이브러리의 핵심 구성요소

Paging 라이브러리의 핵심 구성요소는 다음과 같습니다.

  • PagingSource: 특정 페이지 쿼리의 데이터 청크를 로드하는 기본 클래스입니다. 데이터 레이어의 일부이며 일반적으로 DataSource 클래스에서 노출되고 이후에 ViewModel에서 사용하기 위해 Repository에 의해 노출됩니다.
  • PagingConfig: 페이징 동작을 결정하는 매개변수를 정의하는 클래스입니다. 여기에는 페이지 크기, 자리표시자의 사용 설정 여부 등이 포함됩니다.
  • Pager: PagingData 스트림을 생성하는 클래스입니다. PagingSource에 따라 다르게 실행되며 ViewModel에서 만들어야 합니다.
  • PagingData: 페이지로 나눈 데이터의 컨테이너입니다. 데이터를 새로고침할 때마다 자체 PagingSource로 지원되는 상응하는 PagingData 내보내기가 별도로 생성됩니다.
  • PagingDataAdapter: RecyclerViewPagingData를 표시하는 RecyclerView.Adapter 서브클래스입니다. PagingDataAdapter는 팩토리 메서드를 사용하여 Kotlin FlowLiveData, RxJava Flowable, RxJava Observable 또는 정적 목록에도 연결할 수 있습니다. PagingDataAdapter는 내부 PagingData 로드 이벤트를 수신 대기하고 페이지가 로드될 때 UI를 효율적으로 업데이트합니다.

566d0f6506f39480.jpeg

다음 섹션에서는 위에서 설명한 각 구성요소의 예시를 구현합니다.

4. 프로젝트 개요

현재 형식의 앱은 정적인 기사 목록을 표시합니다. 각 기사에는 제목과 설명, 작성된 날짜가 있습니다. 정적 목록은 항목이 적을 때는 유용하지만 데이터 세트가 커질수록 확장성이 떨어집니다. Paging 라이브러리를 사용하여 페이지로 나누기를 구현해 문제를 해결하겠지만 먼저 앱에 이미 있는 구성요소를 살펴보겠습니다.

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

데이터 레이어

  • ArticleRepository: 기사 목록을 제공하고 메모리에 저장합니다.
  • Article: 데이터 레이어에서 가져온 정보의 표현인 데이터 모델을 나타내는 클래스입니다.

UI 레이어

  • Activity, RecyclerView.Adapter, RecyclerView.ViewHolder: UI에 목록을 표시하는 클래스입니다.
  • ViewModel: UI가 표시해야 하는 상태를 생성하는 상태 홀더입니다.

저장소는 Flow의 모든 기사를 articleStream 필드로 노출합니다. 그러면 UI 레이어의 ArticleViewModel에서 읽은 후 state 필드 StateFlowArticleActivity의 UI에서 사용할 수 있도록 준비합니다.

저장소에서 기사를 Flow로 노출하면 시간이 지남에 따라 기사가 변경될 때 제공된 기사를 저장소에서 업데이트할 수 있습니다. 예를 들어 기사의 제목이 변경되면 articleStream 수집기에 이러한 변경사항이 쉽게 전달될 수 있습니다. ViewModel에서 UI 상태에 StateFlow를 사용하면 UI 상태 수집을 중지하더라도(예: 구성 변경 중에 Activity가 다시 생성될 때) 다시 수집을 시작하는 시점에 직전에 중단한 지점부터 시작할 수 있습니다.

앞서 언급했듯이 저장소의 현재 articleStream은 현재 날짜의 뉴스만 표시합니다. 일부 사용자는 이것으로도 충분할 수 있지만 제공되는 모든 현재 날짜의 기사를 스크롤했을 때 오래된 기사를 확인하고자 하는 사용자도 있을 수 있습니다. 이러한 기대로 인해 기사 표시는 페이지로 나누기를 구현하기에 적합한 후보입니다. 기사를 통해 페이징을 살펴봐야 하는 다른 이유는 다음과 같습니다.

  • ViewModel은 메모리에 로드된 모든 항목을 items StateFlow에 유지합니다. 데이터 세트가 너무 커지면 성능에 영향을 줄 수 있기 때문에 이는 매우 중요한 문제입니다.
  • 기사가 변경된 경우 목록에서 하나 이상의 기사를 업데이트하는 작업은 기사 목록이 커질수록 비용이 더 많이 듭니다.

Paging 라이브러리는 이 모든 문제를 해결하는 동시에 앱에서 점진적으로 데이터를 가져오는(페이지로 나누기) 일관된 API를 제공합니다.

5. 데이터 소스 정의

페이지로 나누기를 구현할 때 다음 조건을 충족하는지 확인해야 합니다.

  • UI의 데이터 요청을 올바르게 처리하여 동일한 쿼리에 여러 요청이 동시에 트리거되지 않도록 합니다.
  • 관리 가능한 양의 가져온 데이터를 메모리에 유지합니다.
  • 이미 가져온 데이터를 보완하기 위해 추가 데이터를 가져오라는 요청을 트리거합니다.

PagingSource를 사용하면 이 작업을 모두 실행할 수 있습니다. PagingSource는 증분 청크로 데이터를 가져오는 방법을 지정하여 데이터 소스를 정의합니다. 그러면 PagingData 객체는 사용자가 RecyclerView에서 스크롤할 때 생성되는 힌트가 로드되면 PagingSource에서 데이터를 가져옵니다.

PagingSource는 기사를 로드합니다. data/Article.kt에서 다음과 같이 정의된 모델을 확인할 수 있습니다:

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

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

  • 페이징 키의 유형: 추가 데이터를 요청하는 데 사용하는 페이지 쿼리 유형의 정의입니다. 여기서는 특정 기사 ID 앞이나 뒤에 기사를 가져옵니다. ID가 정렬되고 증가한다고 보장되기 때문입니다.
  • 로드된 데이터의 유형: 각 페이지가 기사 List를 반환하므로 유형은 Article입니다.
  • 데이터를 가져오는 위치: 일반적으로 데이터베이스나 네트워크 리소스, 페이지로 나눈 데이터의 다른 소스입니다. 하지만 이 Codelab에서는 로컬에서 생성된 데이터를 사용합니다.

data 패키지에서 ArticlePagingSource.kt라는 새 파일에 PagingSource 구현을 만들어 보겠습니다.

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource
import androidx.paging.PagingState

class ArticlePagingSource : PagingSource<Int, Article>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        TODO("Not yet implemented")
    }
}

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

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

  • 로드할 페이지의 키: load()가 처음 호출되는 경우 LoadParams.keynull입니다. 여기서는 초기 페이지 키를 정의해야 합니다. 이 프로젝트에서는 기사 ID를 키로 사용합니다. 초기 페이지 키의 ArticlePagingSource 파일 상단에 STARTING_KEY 상수 0도 추가해 보겠습니다.
  • 로드 크기: 로드 요청된 항목의 수입니다.

load() 함수는 LoadResult를 반환합니다. LoadResult는 다음 유형 중 하나일 수 있습니다.

  • LoadResult.Page: 로드에 성공한 경우
  • LoadResult.Error: 오류가 발생한 경우
  • LoadResult.Invalid: PagingSource가 더 이상 결과의 무결성을 보장할 수 없으므로 무효화되어야 하는 경우

LoadResult.Page에는 다음과 같은 세 가지 필수 인수가 있습니다.

  • data: 가져온 항목의 List입니다.
  • prevKey: 현재 페이지 앞에 항목을 가져와야 하는 경우 load() 메서드에서 사용하는 키입니다.
  • nextKey: 현재 페이지 뒤에 항목을 가져와야 하는 경우 load() 메서드에서 사용하는 키입니다.

다음과 같은 선택적 인수 두 개도 있습니다.

  • itemsBefore: 로드된 데이터 앞에 표시할 자리표시자의 수입니다.
  • itemsAfter: 로드된 데이터 뒤에 표시할 자리표시자의 수입니다.

로드 키는 Article.id 필드입니다. 이를 키로 사용할 수 있는 이유는 기사마다 Article ID가 1씩 증가하기 때문입니다. 즉, 기사 ID는 연속적으로 일정하게 증가하는 정수입니다.

상응하는 방향으로 로드할 데이터가 더 이상 없는 경우 nextKey 또는 prevKeynull입니다. 여기서 prevKey의 경우는 다음과 같습니다.

  • startKeySTARTING_KEY와 같은 경우 null이 반환됩니다. 이 키 앞에 항목을 더 로드할 수 없기 때문입니다.
  • 그 외의 경우에는 목록의 첫 번째 항목을 가져와 앞에 LoadParams.loadSize를 로드하여 STARTING_KEY보다 작은 키가 반환되지 않도록 합니다. 이렇게 하려면 ensureValidKey() 메서드를 정의합니다.

페이징 키가 유효한지 확인하는 다음 함수를 추가합니다.

class ArticlePagingSource : PagingSource<Int, Article>() {
   ... 
   /**
     * Makes sure the paging key is never less than [STARTING_KEY]
     */
    private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
}

nextKey의 경우는 다음과 같습니다.

  • 무한 항목 로드를 지원하므로 range.last + 1을 전달합니다.

또한 각 기사에는 created 필드가 있으므로 이에 관한 값도 생성해야 합니다. 파일 상단에 다음 줄을 추가합니다.

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
}

모든 코드를 올바르게 작성했으므로 이제 load() 함수를 구현할 수 있습니다.

import kotlin.math.max
...

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        // Start paging with the STARTING_KEY if this is the first load
        val start = params.key ?: STARTING_KEY
        // Load as many items as hinted by params.loadSize
        val range = start.until(start + params.loadSize)

        return LoadResult.Page(
            data = range.map { number ->
                Article(
                    // Generate consecutive increasing numbers as the article id
                    id = number,
                    title = "Article $number",
                    description = "This describes article $number",
                    created = firstArticleCreatedTime.minusDays(number.toLong())
                )
            },

            // Make sure we don't try to load items behind the STARTING_KEY
            prevKey = when (start) {
                STARTING_KEY -> null
                else -> ensureValidKey(key = range.first - params.loadSize)
            },
            nextKey = range.last + 1
        )
    }

    ...
}

다음으로 getRefreshKey()를 구현해야 합니다. 이 메서드는 Paging 라이브러리가 UI 관련 항목을 새로고침해야 할 때 호출됩니다. 지원 PagingSource의 데이터가 변경되었기 때문입니다. PagingSource의 기본 데이터가 변경되었으며 UI에서 업데이트해야 하는 이 상황을 무효화라고 합니다. 무효화되면 Paging 라이브러리가 데이터를 새로고침할 새 PagingSource를 만들고 새 PagingData를 내보내 UI에 알립니다. 무효화에 관해서는 이후 섹션에서 자세히 알아봅니다.

PagingSource에서 로드할 때는 사용자가 새로고침 후 목록에서 현재 위치를 잃지 않도록 새 PagingSource가 로드를 시작해야 하는 키를 제공하기 위해 getRefreshKey()가 호출됩니다.

Paging 라이브러리에서 무효화가 발생하는 이유는 다음 두 가지 중 하나입니다.

  • PagingAdapter에서 refresh()를 호출했습니다.
  • PagingSource에서 invalidate()를 호출했습니다.

반환된 키(여기서는 Int)는 LoadParams 인수를 통해 새 PagingSource의 다음 load() 메서드 호출에 전달됩니다. 무효화 후 항목이 이동하지 않도록 하려면 반환된 키가 화면을 채울 만큼 충분한 항목을 로드하도록 해야 합니다. 이렇게 하면 새 항목 집합에 무효화된 데이터에 있던 항목이 포함될 가능성이 커지므로 현재 스크롤 위치를 유지하는 데 도움이 됩니다. 앱에서 구현한 내용을 살펴보겠습니다.

   // The refresh key is used for the initial load of the next PagingSource, after invalidation
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        // In our case we grab the item closest to the anchor position
        // then return its id - (state.config.pageSize / 2) as a buffer
        val anchorPosition = state.anchorPosition ?: return null
        val article = state.closestItemToPosition(anchorPosition) ?: return null
        return ensureValidKey(key = article.id - (state.config.pageSize / 2))
    }

위 스니펫에서는 PagingState.anchorPosition을 사용합니다. Paging 라이브러리가 더 많은 항목을 가져오는 방법을 알고 있는지 궁금하다면 다음을 참고하세요. UI가 PagingData에서 항목을 읽으려고 하면 특정 색인에서 읽으려고 합니다. 데이터를 읽은 경우 이 데이터가 UI에 표시됩니다. 하지만 데이터가 없으면 Paging 라이브러리는 실패한 읽기 요청을 처리하기 위해 데이터를 가져와야 한다는 것을 인식합니다. 읽을 때 데이터를 성공적으로 가져온 마지막 색인은 anchorPosition입니다.

새로고침할 때는 anchorPosition에 가장 가까운 Article 키를 가져와 로드 키로 사용합니다. 이렇게 하면 새 PagingSource에서 로드를 다시 시작할 때 가져온 항목 집합에 이미 로드된 항목이 포함되므로 원활하고 일관된 사용자 환경이 보장됩니다.

그런 다음 PagingSource를 완전히 정의했습니다. 다음 단계는 이를 UI에 연결하는 것입니다.

6. UI용 PagingData 생성

현재 구현에서는 ArticleRepositoryFlow<List<Article>>을 사용하여 ViewModel에 로드된 데이터를 노출합니다. 그러면 ViewModel은 UI에 노출하기 위해 stateIn 연산자를 사용하여 항상 사용할 수 있는 데이터 상태를 유지합니다.

Paging 라이브러리를 사용하면 ViewModelFlow<PagingData<Article>>을 대신 노출할 수 있습니다. PagingData는 로드된 데이터를 래핑하고 Paging 라이브러리가 추가 데이터를 가져올 시기를 결정하는 데 도움을 주는 유형이며 동일한 페이지를 두 번 요청하지 않도록 합니다.

PagingData를 구성하기 위해 PagingData를 앱의 다른 레이어에 전달하는 데 사용할 API에 따라 Pager 클래스의 여러 빌더 메서드 중 하나를 사용합니다.

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

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

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

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

ArticleRepository를 수정해 보겠습니다.

ArticleRepository 업데이트

  • articlesStream 필드를 삭제합니다.
  • 방금 만든 ArticlePagingSource를 반환하는 articlePagingSource()라는 메서드를 추가합니다.
class ArticleRepository {

    fun articlePagingSource() = ArticlePagingSource()
}

ArticleRepository 정리

Paging 라이브러리는 다양한 작업을 실행합니다.

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

즉, articlePagingSource()를 제외한 ArticleRepository의 모든 값이 삭제될 수 있습니다. 이제 ArticleRepository 파일이 다음과 같이 표시됩니다.

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource

class ArticleRepository {
    fun articlePagingSource() = ArticlePagingSource()
}

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

7. ViewModel에서 PagingData 요청 및 캐시

컴파일 오류를 해결하기 전에 ViewModel을 살펴보겠습니다.

class ArticleViewModel(...) : ViewModel() {

    val items: StateFlow<List<Article>> = ...
}

ViewModel에 Paging 라이브러리를 통합하기 위해 items의 반환 유형을 StateFlow<List<Article>>에서 Flow<PagingData<Article>>로 변경합니다. 이렇게 하려면 먼저 ITEMS_PER_PAGE라는 비공개 상수를 파일 상단에 추가합니다.

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel {
    ...
}

다음으로, Pager 인스턴스의 출력 결과가 되도록 items를 업데이트합니다. Pager에 매개변수 두 개를 전달하면 됩니다.

  • pageSizeITEMS_PER_PAGE이고 자리표시자가 사용 중지된 PagingConfig
  • 방금 만든 ArticlePagingSource의 인스턴스를 제공하는 PagingSourceFactory
class ArticleViewModel(...) : ViewModel() {

   val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        ...
}

이제 구성 또는 탐색 변경사항에도 페이징 상태를 유지하려면 androidx.lifecycle.viewModelScope를 전달하는 cachedIn() 메서드를 사용합니다.

위 변경을 완료하면 ViewModel이 다음과 같이 표시됩니다.

package com.example.android.codelabs.paging.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.android.codelabs.paging.data.Article
import com.example.android.codelabs.paging.data.ArticleRepository
import com.example.android.codelabs.paging.data.ITEMS_PER_PAGE
import kotlinx.coroutines.flow.Flow

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel(
    private val repository: ArticleRepository,
) : ViewModel() {

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        .cachedIn(viewModelScope)
}

PagingData의 또 다른 유의 사항은 RecyclerView에 표시할 데이터에 관한 변경 가능한 업데이트 스트림이 포함된 독립적인 유형이라는 것입니다. 각 PagingData 내보내기는 완전히 독립적이며 지원 PagingSource가 기본 데이터 세트 변경으로 인해 무효화되는 경우 단일 쿼리에 관해 여러 PagingData 인스턴스를 내보낼 수 있습니다. 따라서 PagingDataFlows는 다른 Flows와 독립적으로 노출되어야 합니다.

이제 완료됐습니다. ViewModel에 페이징 기능이 있습니다.

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

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

PagingData 스트림을 사용하도록 ArticleAdapter 업데이트

  • 현재는 ArticleAdapter에서 ListAdapter를 구현합니다. 대신 PagingDataAdapter를 구현하도록 합니다. 클래스 본문의 나머지 부분은 변경되지 않습니다.
import androidx.paging.PagingDataAdapter
...

class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
// body is unchanged
}

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

9. UI에서 PagingData 사용

현재 구현에는 특정 조건이 충족되면 더 많은 데이터를 로드하도록 ViewModel에서 호출하는 binding.setupScrollListener()라는 메서드가 있습니다. Paging 라이브러리는 이 모든 작업을 자동으로 실행하므로 이 메서드와 그 사용법을 삭제할 수 있습니다.

이제 ArticleAdapter가 더 이상 ListAdapter가 아니라 PagingDataAdapter이므로 간단하게 두 가지를 변경합니다.

  • Flow의 터미널 연산자를 ViewModel에서 collect 대신 collectLatest로 전환합니다.
  • ArticleAdaptersubmitList()가 아닌 submitData() 변경사항을 알립니다.

pagingData 인스턴스를 내보낼 때 이전 pagingData 내보내기에 관한 컬렉션이 취소되도록 pagingData Flow에서 collectLatest를 사용합니다.

이 변경사항에 따라 Activity는 다음과 같이 표시됩니다.

import kotlinx.coroutines.flow.collectLatest

class ArticleActivity : AppCompatActivity() {

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

        val viewModel by viewModels<ArticleViewModel>(
            factoryProducer = { Injection.provideViewModelFactory(owner = this) }
        )

        val items = viewModel.items
        val articleAdapter = ArticleAdapter()

        binding.bindAdapter(articleAdapter = articleAdapter)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                items.collectLatest {
                    articleAdapter.submitData(it)
                }
            }
        }
    }
}

private fun ActivityArticlesBinding.bindAdapter(
    articleAdapter: ArticleAdapter
) {
    list.adapter = articleAdapter
    list.layoutManager = LinearLayoutManager(list.context)
    val decoration = DividerItemDecoration(list.context, DividerItemDecoration.VERTICAL)
    list.addItemDecoration(decoration)
}

이제 앱이 컴파일되고 실행됩니다. Paging 라이브러리로 앱을 이전했습니다.

f97136863cfa19a0.gif

10. UI에 로드 상태 표시

Paging 라이브러리에서 UI에 표시할 항목을 더 많이 가져올 때는 사용자에게 더 많은 데이터를 가져오고 있다고 표시하는 것이 좋습니다. Paging 라이브러리는 CombinedLoadStates 유형으로 로드 상태에 간단하게 액세스하는 방법을 제공합니다.

CombinedLoadStates 인스턴스는 데이터를 로드하는 Paging 라이브러리에 있는 모든 구성요소의 로드 상태를 설명합니다. 여기서는 ArticlePagingSourceLoadState에만 관심이 있으므로 주로 CombinedLoadStates.source 필드의 LoadStates 유형을 사용합니다. PagingDataAdapter.loadStateFlow를 통해 PagingDataAdapter를 통한 CombinedLoadStates에 액세스할 수 있습니다.

CombinedLoadStates.sourceLoadStates 유형으로, 세 가지 유형의 LoadState 필드가 있습니다.

  • LoadStates.append: 사용자의 현재 위치 후에 가져오는 항목의 LoadState용입니다.
  • LoadStates.prepend: 사용자의 현재 위치 전에 가져오는 항목의 LoadState용입니다.
  • LoadStates.refresh: 초기 로드의 LoadState용입니다.

LoadState 자체는 다음 중 하나일 수 있습니다.

  • LoadState.Loading: 항목을 로드하고 있습니다.
  • LoadState.NotLoading: 항목을 로드하고 있지 않습니다.
  • LoadState.Error: 로드 오류가 발생했습니다.

여기서는 LoadStateLoadState.Loading인지만 중요합니다. ArticlePagingSource에 오류 사례가 포함되어 있지 않기 때문입니다.

가장 먼저 할 일은 UI 상단과 하단에 진행률 표시줄을 추가하여 양방향으로 가져오기의 로드 상태를 나타내는 것입니다.

activity_articles.xml에서 다음과 같이 LinearProgressIndicator 막대 두 개를 추가합니다.

<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.ArticleActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scrollbars="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/prepend_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/append_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

이제 PagingDataAdapter에서 LoadStatesFlow를 수집하여 CombinedLoadState에 반응합니다. ArticleActivity.kt에서 상태를 수집합니다.

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                articleAdapter.loadStateFlow.collect {
                    binding.prependProgress.isVisible = it.source.prepend is Loading
                    binding.appendProgress.isVisible = it.source.append is Loading
                }
            }
        }
        lifecycleScope.launch {
        ...
    }

마지막으로 ArticlePagingSource에 약간의 지연을 추가하여 로드를 시뮬레이션합니다.

private const val LOAD_DELAY_MILLIS = 3_000L

class ArticlePagingSource : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = startKey.until(startKey + params.loadSize)

        if (start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
        return ...

}

앱을 다시 실행하고 목록 하단까지 스크롤합니다. Paging 라이브러리가 더 많은 항목을 가져오는 동안 하단 진행률 표시줄이 표시되고 완료 시 사라집니다.

6277154193f7580.gif

11. 요약

지금까지 다룬 내용을 간략하게 요약해 보겠습니다.

  • 페이지로 나누기에 관해 간략히 살펴보고 필요한 이유를 알아봤습니다.
  • Pager를 만들고 PagingSource를 정의하고 PagingData를 내보내 앱에 페이지로 나누기를 추가했습니다.
  • cachedIn 연산자를 사용하여 ViewModelPagingData를 캐시했습니다.
  • PagingDataAdapter를 사용하여 UI에서 PagingData를 사용했습니다.
  • PagingDataAdapter.loadStateFlow를 사용하여 CombinedLoadStates에 반응했습니다.

이제 완료됐습니다. 고급 페이징 개념에 관한 자세한 내용은 고급 Paging Codelab을 확인하세요.