Paging 구현 (기초)
코드랩으로 연습해 보자. 이 글은 아래의 코드랩을 요약하여 작성하였다.
의존성 정의
Paging 라이브러리 의존성을 정의하자.
// architecture components
implementation("androidx.paging:paging-common-ktx:3.1.0")
implementation("androidx.paging:paging-runtime-ktx:3.1.0")
Data source 정의
페이징은 ``PagingSource``에서 시작된다. 각 페이지를 어떻게 받아올지 ``PagingSource``에서 정의해야 한다.
코드랩에서 사용하는 데이터 객체는 ``Article``이다. ``Article``을 불러오는 ``PagingSource``를 정의해 보자.
data class Article(
val id: Int,
val title: String,
val description: String,
val created: LocalDateTime,
)
페이징을 구현하기 전에, 다음 세 가지를 결정해야 한다.
- 각 페이지의 key: 페이지에서 불러올 데이터의 범위를 어떻게 결정할 지 생각해야 한다. 코드랩에서는 연속된 정수로 주어지는 ``Article.id``를 key로 사용하여, 특정 ``id``값부터 50개를 불러오도록 구현하였다.
- 로드할 데이터: 말 그대로이다. 여기서는 ``Article``이 되겠다.
- 데이터를 가져올 곳: 로컬 DB, 서버 내지는 가능한 모든 source가 될 수 있다. 여기서는 페이징 기초를 연습하고 있으므로 간단하게 in-memory 데이터로 구현하자.
``PagingSource``를 implement하는 ``ArticlePagingSource``를 정의하자.
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()`` 함수는 매개변수 ``params``에 상응하는 데이터를 불러오는 함수이다. ``params``에는 페이지의 key, 불러올 데이터의 개수 등이 포함된다.
- 페이지의 Key: ``load()`` 함수가 처음 호출된다면 ``LoadParams.key``는 ``null``이다. 따라서 key가 ``null``일 때에는 맨 처음 키 값을 직접 정의해야 한다. 코드랩에서는 맨 처음 ``id``값, 즉 0으로 정의했다.
- 불러올 데이터의 개수: 말 그대로.
``load()`` 함수는 sealed class인 ``LoadResult``를 반환한다. ``LoadResult``는 다음의 세 하위 타입 중 하나가 될 수 있다.
- ``LoadResult.Page``: 페이지를 정상적으로 불러온 경우에 반환한다.
- ``LoadResult.Error``: 페이지를 불러오던 중 에러가 발생했을 때 반환한다.
- ``LoadResult.Invalid``: ``PagingSource``가 더 이상 데이터의 무결성을 보장할 수 없을 때 반환한다. 갑자기 DB 스키마가 바뀌었다던가.. 물론 흔한 경우는 아니다.
``LoadResult.Page``를 만들려면 아래의 세 가지 값이 필요하다.
- ``data``: 데이터를 담고 있는 ``List``이다.
- ``prevKey``: 이 페이지의 이전 페이지를 나타내는 key이다.
- ``nextKey``: 이 페이지의 다음 페이지를 나타내는 key이다.
아래의 두 개는 optional argument이다.
- ``itemsBefore``: 이 페이지의 이전 페이지를 로딩할 때 보여줘야 할 placeholder의 개수이다.
- ``itemsAfter``:이 페이지의 다음 페이지를 로딩할 때 보여줘야 할 placeholder의 개수이다.
``prevKey``나 ``nextKey``의 값은 더 이상 데이터가 없을 때 ``null``이다. 페이지당 불러올 데이터의 수를 ``pageSize``라고 하자.
``key``가 ``0``인 페이지는 첫 번째 페이지이므로 ``prevKey``는 ``null``이고, 그렇지 않다면 ``prevKey``는 ``key``에서 ``pageSize``를 뺀 값과 같다. 물론 이 값이 0보다 작으면 안 된다.
``nextKey``는 현재 ``key``에 ``pageSize``를 더한 값과 같다.
따라서 ``load()`` 함수를 다음과 같이 구현할 수 있다.
private const val STARTING_KEY = 0
private val firstArticleCreatedTime = LocalDateTime.now()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val start = params.key ?: STARTING_KEY
val range = start.until(start + params.loadSize)
return LoadResult.Page(
data = range.map { number ->
Article(
id = number,
title = "Article $number",
description = "This describes article $number",
created = firstArticleCreatedTime.minusDays(number.toLong())
)
},
prevKey = when (start) {
STARTING_KEY -> null
else -> ensureValidKey(key = range.first - params.loadSize)
},
nextKey = range.last + 1
)
}
이제 ``getRefreshKey()``를 구현하자. 이 함수는 ``PagingSource``의 데이터가 바뀌어 페이지를 다시 불러와야 할 때 호출된다. 예를 들어 데이터베이스에서 값이 삭제되는 경우가 있다. 이렇게 페이지를 다시 불러오는 과정을 invalidation이라고 한다. Invalidate가 발생하면 ``PagingSource``가 새로 만들어지고, 새 데이터가 ``PagingData``의 형태로 UI에 전달된다. Invalidate는 밑에서 더 공부할 것이다.
새 ``PagingSource``로부터 데이터를 가져와야 할 때, 데이터가 로드된 후에도 스크롤 위치가 바뀌지 않도록 ``getRefreshKey()``를 호출하여 적절한 key를 얻는다. 반환된 key 값이 다음 ``load()`` 호출에 사용된다. 이제 적절한 key를 반환하는 코드를 작성해 보자.
Invalidate 이후 처음으로 페이지를 불러올 때, 이전 스크롤 위치와 너무 먼 페이지를 로드하면 스크롤 위치가 바뀔 수도 있다. 스크롤 위치를 유지하기 위해 적당히 가까우면서 화면을 가득 채울 정도로 충분한 아이템을 로드할 key값을 반환해야 한다.
대략 마지막 위치에서 페이지의 절반 정도 올라가면 될 것 같다.
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
val anchorPosition = state.anchorPosition ?: return null
val article = state.closestItemToPosition(anchorPosition) ?: return null
return (article.id - (state.config.pageSize / 2)).coerceAtLeast(0)
}
``state.anchorPosition``은 마지막으로 로드된 아이템의 인덱스를 의미한다. 이 위치에서 가장 가까이 있는 아이템을 고르고, 해당 아이템에서 ``pageSize``의 절반 정도 올라간 값을 key로 반환한다. 이렇게 하면 데이터가 새로고침된 후에도 스크롤 위치가 바뀌지 않을 것 같다.
이제 데이터를 UI에 연결해 보자.
Repository, ViewModel 정의
``ViewModel``에서 페이징 데이터를 제공할 때는 ``PagingData<Article>``의 stream으로 제공해야 한다. Stream이란 데이터의 흐름을 나타낼 수 있는 객체로, ``Flow``, ``LiveData``, RxJava ``Flowable`` 등이 있다. 여기서는 ``Flow``를 사용해 보자.
``Pager`` 클래스를 통해 ``PagingData``의 스트림을 만들 수 있다. ``Pager``를 만들려면 다음 매개변수가 필요하다.
- ``PagingConfig``: ``PagingSource``로부터 데이터를 불러올 때 사용하는 매개변수이다. 필수 파라미터는 불러올 데이터의 개수 하나뿐이고, 필요에 따라 ``enablePlaceholder``나 다른 매개변수를 사용할 수 있다. 개인적으로는 ``enablePlaceholder``를 제외하면 데이터가 어지간히 크지 않은 이상 사용하지 않을 것 같다.
- 불러올 데이터의 크기는 화면을 가득 채울 정도로 충분해야 한다. 스마트폰 뿐 아니라 태블릿이나 크롬북 같이 큰 화면도 가득 채울 정도로 넉넉해야 한다. 동시에 로딩 성능을 고려하여 적당히 작은 값으로 설정해야 한다.
- Paging 라이브러리는 기본적으로 로드된 데이터를 모두 메모리에 저장한다. 메모리를 절약하고 싶다면 데이터를 저장할 개수(``PagingConfig.maxSize``)를 지정하면 되지만, 데이터가 자주 삭제되면 잠재적으로 더 많은 요청을 보내야 한다. 웬만하면 꽤 큰 값으로 설정하는 편이 좋다.
- ``PagingSource``를 생성하는 람다식: ``ArticlePagingSource``를 생성하는 코드를 작성하면 된다.
Repository 정의
``ArticlePagingSource``를 반환하는 Repository를 정의하자.
class ArticleRepository() {
fun articlePagingSource() = ArticlePagingSource()
}
Paging 라이브러리에서 로드된 데이터를 메모리에 모두 저장하고, 상황에 따라 데이터를 불러온다. Repository는 이 정도면 충분하고, 이제 ViewModel로 가자.
ViewModel에서 페이징 데이터 정의
우선 불러올 데이터의 개수를 정의하자.
private const val ITEMS_PER_PAGE = 50
이제 ``PagingData<Article>``의 스트림을 정의하자. ``PagingData``는 ``Pager``로부터 얻을 수 있다. ``Pager``를 생성하려면 위에서 말했던 두 개의 매개변수가 필요하다.
class ArticleViewModel(...) : ViewModel() {
val items: Flow<PagingData<Article>> = Pager(
config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
pagingSourceFactory = { repository.articlePagingSource() }
).flow
}
기초 실습이므로 placeholder는 생략한다. ``pagingSourceFactory``에는 ``ArticlePagingSource``를 반환하는 코드를 작성하면 된다. ``PagingSource``는 재사용되지 않기 때문에 매번 새로운 객체를 반환해야 한다.
# 재사용 불가능?
페이지를 요청할 때마다 매번 새로운 ``PagingSource``가 만들어진다. 로드할 데이터의 상태를 쉽게 관리하기 위해, 하나의 ``PagingSource``가 하나의 페이지만을 로드하도록 강제하기 위해서이다.
맨 마지막 ``flow``는 ``PagingData``의 스트림을 ``Flow``로 반환하라는 의미이다. ``liveData``나 ``flowable``도 사용할 수 있다.
Configuration change가 발생했을 때에도 데이터를 보존하기 위해 ``cachedIn()`` 함수를 호출하자. 참고로 코드랩에 따르면 ``stateIn()``이나 ``shareIn()``을 사용해서는 안 된다고 한다. 그런데 그 이유가 ``Flow<PagingData>``는 cold flow가 아니기 때문이라는데, 공식 문서에는 cold flow라고 나와있다. 뭐지?
뭐.. 쓰지 말라면 쓰지 말아야지..
``Flow<PagingData>``에 ``map``이나 ``filter`` 같은 연산을 할 수도 있지만, ``cachedIn``을 반드시 마지막에 호출해야 한다. 같은 연산을 반복하지 않기 위해서이다. 원문에서는 다음과 같이 언급하고 있다.
Also if you're doing any operations on the Flow, like map or filter, make sure you call cachedIn after you execute these operations to ensure you don't need to trigger them again. In other words, cachedIn should be used as a terminal operator.
완성된 ViewModel 코드는 다음과 같다.
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)
}
마지막으로 ``Flow<PagingData``를 다른 ``Flow``와 combine하면 안 된다. ``PagingData``는 다른 ``Flow``와 독립적으로 consume되어야 하기 때문이다.
RecyclerViewAdapter가 PagingData를 받아들일 수 있게 수정
다른 코드는 그대로 두고, ```RecyclerView``의 adapter를 ``PagingDataAdapter``로만 바꾸면 된다. 참 쉽죠?
class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
// body is unchanged
}
UI에서 PagingData를 collect하기
``Activity``에서 ``Flow<PagingData>``를 collect해 보자. ``collect`` 대신 ``collectLatest``를, ``submitList()`` 대신 ``submitData()``를 호출하면 된다.
class ArticleActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
items.collectLatest {
articleAdapter.submitData(it)
}
}
}
}
}
완료!
번외: 데이터 로딩 상태를 표시하기
유저에게 페이징 데이터를 불러오고 있다는 사실을 알려주는 편이 좋다. Paging 라이브러리를 사용하면 로딩 상태를 편리하게 보여줄 수 있다.
모든 로딩 상태는 ``CombinedLoadStates`` 객체로부터 얻을 수 있다. 지금은 ``ArticlePagingSource``의 로딩 상태만 사용할 거라 ``LoadStates`` 타입의 ``CombinedLoadStates.source`` 필드만 보면 된다.
``LoadStates``는 세 가지 ``LoadState``(로딩 상태)를 제공한다.
- ``LoadStates.append``: 현재 위치 뒤의 데이터를 가져오고 있는지?
- ``LoadStates.prepend``: 현재 위치 앞의 데이터를 가져오고 있는지?
- ``LoadStates.refresh``: 최초(initial)로 데이터를 가져오고 있는지?
각 ``LoadState``는 세 가지 값을 가질 수 있다.
- ``LoadState.Loading``: 로딩 중이다.
- ``LoadState.NotLoading``: 로딩 중이 아니다.
- ``LoadState.Error``: 에러가 발생했다.
지금은 로딩 중인지만 확인하면 되므로 현재 값이 ``LoadState.Loading``일 때 사용자에게 로딩 중이라는 UI를 보여주면 된다.
``CombinedLoadState``는 ``PagingDataAdapter``로부터 얻을 수 있다. ``PagingDataAdapter``로부터 얻는 이유는 ``RecyclerView``와의 상호작용에서 페이지 로딩 작업이 만들어지기 때문이다. 스크롤을 끝까지 내렸을 때 페이지를 더 가져와야 하는데, 스크롤 위치를 ``PagingDataAdapter``에서 관리하기 때문이다.
다음과 같이 로딩 상태를 가져올 수 있다. ``LoadState``는 sealed class이기 때문에 ``is`` 연산자로 비교해야 한다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
articleAdapter.loadStateFlow.collect {
binding.prependProgress.isVisible = it.source.prepend is LoadState.Loading
binding.appendProgress.isVisible = it.source.append is LoadState.Loading
}
}
}
}
``binding.preprendProgress``와 ``binding.appendProgress``는 모두 ``LinearProgressIndicator``이다.
이제 ``ArticlePagingSource``에서 실제로 데이터를 로드하는 것처럼 delay를 주면...
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)
// load...
}
}
LoadingIndicator가 제대로 보인다!
요약
- ``PagingSource``, ``Pager``, ``PagingData``를 정의했다.
- ``ViewModel``에서 ``Flow<PagingData>``를 ``cachedIn(viewModelScope)``했다.
- ``Flow<PagingData>``를 UI에서 ``collectLatest``했다.
- ``PagingDataAdapter.loadStateFlow``로부터 로딩 상태를 추적하였다.
끝!