1. 소개
학습할 내용
- Paging 라이브러리의 주요 구성요소
- Paging 라이브러리를 프로젝트에 추가하는 방법
빌드할 항목
이 Codelab에서는 이미 기사 목록을 표시하는 샘플 앱을 사용하여 시작합니다. 목록은 정적이며 기사가 500개 포함되어 있고 모두 휴대전화의 메모리에 저장되어 있습니다.
Codelab을 진행하면서 다음 내용을 알아봅니다.
- 페이지로 나누기 개념
- Paging 라이브러리의 핵심 구성요소
- Paging 라이브러리를 사용하여 페이지로 나누기를 구현하는 방법
완료되면 다음과 같은 앱이 생성됩니다.
- 페이지로 나누기를 구현합니다.
- 더 많은 데이터를 가져올 때 사용자에게 효과적으로 전달됩니다.
다음은 최종 UI의 미리보기입니다.
필요한 항목
도움이 될 지식
- ViewModel, 뷰 결합 등 아키텍처 구성요소와 앱 아키텍처 가이드에서 권장하는 아키텍처에 관한 기본 지식 아키텍처 구성요소에 관한 소개는 뷰를 사용한 Room Codelab을 확인하세요.
- 코루틴 및 Kotlin Flow에 관한 기본 지식 Flow에 관한 소개는 Kotlin Flow 및 LiveData를 사용한 고급 코루틴 Codelab을 확인하세요.
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
폴더의 코드와 같아집니다.
- Android 스튜디오의
basic/start
디렉터리에서 프로젝트를 엽니다. - 기기 또는 에뮬레이터에서
app
실행 구성을 실행합니다.
기사 목록이 표시됩니다. 끝까지 스크롤하여 목록이 정적인지 확인합니다. 정적 목록이라면 목록의 끝에 도달했을 때 항목을 더 가져오지 않아야 합니다. 상단으로 스크롤하여 여전히 모든 항목이 있는지 확인합니다.
3. 페이지로 나누기 소개
사용자에게 정보를 표시하는 가장 일반적인 방법 중 하나는 목록을 사용하는 것입니다. 그러나 때때로 이러한 목록은 전체 콘텐츠로 통하는 작은 창에 불과합니다. 사용자는 제공되는 정보를 스크롤할 때 이미 확인한 정보를 보완할 수 있는 더 많은 데이터가 있을 것이라고 예상하는 경우가 많습니다. 데이터를 가져올 때 효율적이고 원활하게 진행되어 계속 늘어나는 로드가 사용자 환경을 저해하지 않아야 합니다. 점진적 로드는 성능 향상에도 도움이 됩니다. 앱에서 많은 양의 데이터를 한 번에 메모리에 저장할 필요가 없기 때문입니다.
정보를 점진적으로 가져오는 이 프로세스를 페이지로 나누기라고 하며 각 페이지는 가져올 데이터 청크에 상응합니다. 페이지를 요청하려면 페이징되는 데이터 소스에는 필요한 정보를 정의하는 쿼리가 필요한 경우가 많습니다. 이 Codelab의 나머지 부분에서는 Paging 라이브러리를 소개하고 이 라이브러리를 통해 앱에서 빠르고 효율적으로 페이지로 나누기를 구현할 수 있는 방법을 설명합니다.
Paging 라이브러리의 핵심 구성요소
Paging 라이브러리의 핵심 구성요소는 다음과 같습니다.
PagingSource
: 특정 페이지 쿼리의 데이터 청크를 로드하는 기본 클래스입니다. 데이터 레이어의 일부이며 일반적으로DataSource
클래스에서 노출되고 이후에ViewModel
에서 사용하기 위해Repository
에 의해 노출됩니다.PagingConfig
: 페이징 동작을 결정하는 매개변수를 정의하는 클래스입니다. 여기에는 페이지 크기, 자리표시자의 사용 설정 여부 등이 포함됩니다.Pager
:PagingData
스트림을 생성하는 클래스입니다.PagingSource
에 따라 다르게 실행되며ViewModel
에서 만들어야 합니다.PagingData
: 페이지로 나눈 데이터의 컨테이너입니다. 데이터를 새로고침할 때마다 자체PagingSource
로 지원되는 상응하는PagingData
내보내기가 별도로 생성됩니다.PagingDataAdapter
:RecyclerView
에PagingData
를 표시하는RecyclerView.Adapter
서브클래스입니다.PagingDataAdapter
는 팩토리 메서드를 사용하여 KotlinFlow
나LiveData
, RxJavaFlowable
, RxJavaObservable
또는 정적 목록에도 연결할 수 있습니다.PagingDataAdapter
는 내부PagingData
로드 이벤트를 수신 대기하고 페이지가 로드될 때 UI를 효율적으로 업데이트합니다.
다음 섹션에서는 위에서 설명한 각 구성요소의 예시를 구현합니다.
4. 프로젝트 개요
현재 형식의 앱은 정적인 기사 목록을 표시합니다. 각 기사에는 제목과 설명, 작성된 날짜가 있습니다. 정적 목록은 항목이 적을 때는 유용하지만 데이터 세트가 커질수록 확장성이 떨어집니다. Paging 라이브러리를 사용하여 페이지로 나누기를 구현해 문제를 해결하겠지만 먼저 앱에 이미 있는 구성요소를 살펴보겠습니다.
이 앱은 앱 아키텍처 가이드에서 권장하는 아키텍처를 따릅니다. 각 패키지에는 다음과 같은 항목이 포함됩니다.
데이터 레이어
ArticleRepository
: 기사 목록을 제공하고 메모리에 저장합니다.Article
: 데이터 레이어에서 가져온 정보의 표현인 데이터 모델을 나타내는 클래스입니다.
UI 레이어
Activity
,RecyclerView.Adapter
,RecyclerView.ViewHolder
: UI에 목록을 표시하는 클래스입니다.ViewModel
: UI가 표시해야 하는 상태를 생성하는 상태 홀더입니다.
저장소는 Flow
의 모든 기사를 articleStream
필드로 노출합니다. 그러면 UI 레이어의 ArticleViewModel
에서 읽은 후 state
필드 StateFlow
로 ArticleActivity
의 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.key
는null
입니다. 여기서는 초기 페이지 키를 정의해야 합니다. 이 프로젝트에서는 기사 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
또는 prevKey
는 null
입니다. 여기서 prevKey
의 경우는 다음과 같습니다.
startKey
가STARTING_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 생성
현재 구현에서는 ArticleRepository
의 Flow<List<Article>>
을 사용하여 ViewModel
에 로드된 데이터를 노출합니다. 그러면 ViewModel
은 UI에 노출하기 위해 stateIn
연산자를 사용하여 항상 사용할 수 있는 데이터 상태를 유지합니다.
Paging 라이브러리를 사용하면 ViewModel
의 Flow<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
에 매개변수 두 개를 전달하면 됩니다.
pageSize
가ITEMS_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
인스턴스를 내보낼 수 있습니다. 따라서 PagingData
의 Flows
는 다른 Flows
와 독립적으로 노출되어야 합니다.
이제 완료됐습니다. ViewModel
에 페이징 기능이 있습니다.
8. PagingData를 사용하도록 어댑터 설정
PagingData
를 RecyclerView
에 바인딩하려면 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
로 전환합니다.ArticleAdapter
에submitList()
가 아닌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 라이브러리로 앱을 이전했습니다.
10. UI에 로드 상태 표시
Paging 라이브러리에서 UI에 표시할 항목을 더 많이 가져올 때는 사용자에게 더 많은 데이터를 가져오고 있다고 표시하는 것이 좋습니다. Paging 라이브러리는 CombinedLoadStates
유형으로 로드 상태에 간단하게 액세스하는 방법을 제공합니다.
CombinedLoadStates
인스턴스는 데이터를 로드하는 Paging 라이브러리에 있는 모든 구성요소의 로드 상태를 설명합니다. 여기서는 ArticlePagingSource
의 LoadState
에만 관심이 있으므로 주로 CombinedLoadStates.source
필드의 LoadStates
유형을 사용합니다. PagingDataAdapter.loadStateFlow
를 통해 PagingDataAdapter
를 통한 CombinedLoadStates
에 액세스할 수 있습니다.
CombinedLoadStates.source
는 LoadStates
유형으로, 세 가지 유형의 LoadState
필드가 있습니다.
LoadStates.append
: 사용자의 현재 위치 후에 가져오는 항목의LoadState
용입니다.LoadStates.prepend
: 사용자의 현재 위치 전에 가져오는 항목의LoadState
용입니다.LoadStates.refresh
: 초기 로드의LoadState
용입니다.
각 LoadState
자체는 다음 중 하나일 수 있습니다.
LoadState.Loading
: 항목을 로드하고 있습니다.LoadState.NotLoading
: 항목을 로드하고 있지 않습니다.LoadState.Error
: 로드 오류가 발생했습니다.
여기서는 LoadState
가 LoadState.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 라이브러리가 더 많은 항목을 가져오는 동안 하단 진행률 표시줄이 표시되고 완료 시 사라집니다.
11. 요약
지금까지 다룬 내용을 간략하게 요약해 보겠습니다.
- 페이지로 나누기에 관해 간략히 살펴보고 필요한 이유를 알아봤습니다.
Pager
를 만들고PagingSource
를 정의하고PagingData
를 내보내 앱에 페이지로 나누기를 추가했습니다.cachedIn
연산자를 사용하여ViewModel
에PagingData
를 캐시했습니다.PagingDataAdapter
를 사용하여 UI에서PagingData
를 사용했습니다.PagingDataAdapter.loadStateFlow
를 사용하여CombinedLoadStates
에 반응했습니다.
이제 완료됐습니다. 고급 페이징 개념에 관한 자세한 내용은 고급 Paging Codelab을 확인하세요.