수많은 데이터를 한번에 불러오지 않고, 설정한 페이지에 따라 부분적으로 불러오도록 처리하는 것입니다.
class ExamplePagingSource(
val backend: ExampleBackendService,
val query: String
) : PagingSource<Int, User>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, User> {
try {
// Start refresh at page 1 if undefined.
val nextPageNumber = params.key ?: 1
val response = backend.searchUsers(query, nextPageNumber)
return LoadResult.Page(
data = response.users,
prevKey = null, // Only paging forward.
nextKey = response.nextPageNumber
)
} catch (e: IOException) {
// 네트워크 요청 실해
return LoadResult.Error(e)
} catch (e: HttpException) {
// API 상태 오류 발생
return LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, User>): Int? {
// Try to find the page key of the closest page to anchorPosition from
// either the prevKey or the nextKey; you need to handle nullability
// here.
// * prevKey == null -> anchorPage is the first page.
// * nextKey == null -> anchorPage is the last page.
// * both prevKey and nextKey are null -> anchorPage is the
// initial page, so return null.
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
실질적으로 load 함수를 여러번 호출하여 분할된 데이터를 불러옵니다.
이 때 파라미터와 반환값은 다음을 의미합니다.
getRefreshKey() 메서드는 예기치 못한 오류로 인해 key를 유실했을 때, 정한 key 값을 전달합니다.
내부적으로 이를 처리해주는 부분을 통해, PagingSource에서 우리는 Paging3 라이브러리의 덕을 한 번 볼 수 있습니다.
val flow = Pager(
// Configure how data is loaded by passing additional properties to
// PagingConfig, such as prefetchDistance.
PagingConfig(pageSize = 20)
) {
ExamplePagingSource(backend, query)
}.flow
.cachedIn(viewModelScope)
PagingSource 구현에서 페이징된 데이터의 스트림을 만듭니다.
PagingConfig 구성 객체와 PagingSource 구현 인스턴스를 가져오는 방법을 Pager에 지시하고, cachedIn() 연산자로 페이징 데이터를 캐싱합니다.
pager.flow // Type is Flow<PagingData<User>>.
// Map the outer stream so that the transformations are applied to
// each new generation of PagingData.
.map { pagingData ->
// Transformations in this block are applied to the items
// in the paged data.
}
Pager로 존재하는 flow를 변형합니다.
class UserAdapter(diffCallback: DiffUtil.ItemCallback<User>) :
PagingDataAdapter<User, UserViewHolder>(diffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): UserViewHolder {
return UserViewHolder(parent)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val item = getItem(position)
// Note that item can be null. ViewHolder must support binding a
// null item as a placeholder.
holder.bind(item)
}
}
RecyclerView.Adapter나 ListAdapter와는 다르게 현재 가지고있는 아이템들이나 보여지고있는 아이템들에 대한 접근할 수 있는 방식이 없습니다.
이를 사용하려면, PagingDataAdapter를 호출하는 부분에서 리스너를 달아서 어댑터 외부에서 접근해야합니다.
PagingAdapter에 데이터를 넘겨줄 때는, submitData()를 사용합니다.
한 가지 특이한 것은, recyclerView에서 사용하던 submitData()는 일반 함수이지만, PagingAdapter에서 사용하는 submitData()는 suspend 함수입니다.
이유는, 넘겨주는 PagingData가 flow를 내부적으로 가지고 있기 때문입니다.
object UserComparator : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
// Id is unique.
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
adapter.addLoadStateListener { loadState ->
if (loadState.source.refresh is LoadState.NotLoading && loadState.append.endOfPaginationReached && adapter.itemCount < 1) {
recycleView?.isVisible = false
emptyView?.isVisible = true
} else {
recycleView?.isVisible = true
emptyView?.isVisible = false
}
}
현재 어댑터에 제공되는 데이터의 개수를 파악하여 로딩을 처리 등을 구현하고 싶다면, loadstatelistener를 달아서 확인합니다.
## CachedPagingData.kt
internal fun <T : Any> Flow<PagingData<T>>.cachedIn(
scope: CoroutineScope,
// used in tests
tracker: ActiveFlowTracker? = null
): Flow<PagingData<T>> {
return this.simpleMapLatest {
MulticastedPagingData(
scope = scope,
parent = it,
tracker = tracker
)
}.simpleRunningReduce { prev, next ->
prev.close()
next
}.map {
it.asPagingData()
}.onStart {
tracker?.onStart(PAGED_DATA_FLOW)
}.onCompletion {
tracker?.onComplete(PAGED_DATA_FLOW)
}.shareIn(
scope = scope,
started = SharingStarted.Lazily,
// replay latest multicasted paging data since it is re-connectable.
replay = 1
)
}
위의 코드에서 asPagingData() 에 집중합니다.
asPagingData()에서 캐싱을 하고 가져오고가 모두 이루어집니다.
## CachedPagingData.kt
private class MulticastedPagingData<T : Any>(
val scope: CoroutineScope,
val parent: PagingData<T>,
// used in tests
val tracker: ActiveFlowTracker? = null
) {
private val accumulated = CachedPageEventFlow(
src = parent.flow,
scope = scope
).also {
tracker?.onNewCachedEventFlow(it)
}
fun asPagingData() = PagingData(
flow = accumulated.downstreamFlow.onStart {
tracker?.onStart(PAGE_EVENT_FLOW)
}.onCompletion {
tracker?.onComplete(PAGE_EVENT_FLOW)
},
uiReceiver = parent.uiReceiver,
hintReceiver = parent.hintReceiver,
cachedPageEvent = { accumulated.getCachedEvent() }
)
suspend fun close() = accumulated.close()
}
해당 메서드는 MulticastedPagingData의 함수입니다.
asPagingData()의 캐싱을 하는 부분은 다음과 같습니다.
flow = accumulated.downstreamFlow
asPagingData()의 캐싱된 데이터를 가져오는 부분입니다.
cachedPageEvent = { accumulated.getCachedEvent() }
## CachedPageEventFlow.kt
val downstreamFlow = flow {
// track max event index we've seen to avoid race condition between history and the shared
// stream
var maxEventIndex = Int.MIN_VALUE
sharedForDownstream
.takeWhile {
// shared flow cannot finish hence we have a special marker to finish it
it != null
}
.collect { indexedValue ->
// we take until null so this cannot be null
if (indexedValue!!.index > maxEventIndex) {
emit(indexedValue.value)
maxEventIndex = indexedValue.index
}
}
}
그리고 sharedForDownstream은 아래의 job을 실행합니다.
## CachedPageEventFlow.kt
/**
* Shared flow used for downstream which also sends the history. Each downstream connects to
* this where it first receives a history event and then any other event that was emitted by
* the upstream.
*/
private val sharedForDownstream = mutableSharedSrc.onSubscription {
val history = pageController.getStateAsEvents()
// start the job if it has not started yet. We do this after capturing the history so that
// the first subscriber does not receive any history.
job.start()
history.forEach {
emit(it)
}
}
/**
* The actual job that collects the upstream.
*/
private val job = scope.launch(start = CoroutineStart.LAZY) {
src.withIndex()
.collect {
mutableSharedSrc.emit(it)
pageController.record(it)
}
}.also {
it.invokeOnCompletion {
// Emit a final `null` message to the mutable shared flow.
// Even though, this tryEmit might technically fail, it shouldn't because we have
// unlimited buffer in the shared flow.
mutableSharedSrc.tryEmit(null)
}
}
PagingDataAdapter는 RecyclerView.Adapter를 상속합니다.
RecyclerView.Adapter에는 구성 변경 시, focus position을 유지할 수 있도록 해주는 위의 함수가 존재합니다.
PagingDataAdapter가 이를 오버라이딩하여, 내부적으로 실행해줍니다.
엄연하게 말하면, Paging은 다수의 데이터를 분할하여 불러오는 데에 특화되어 있는 라이브러리입니다.
라이브러리의 목적 자체가 지협적인 것이지, 그것만으로 라이브러리가 별로라고 말하는 것은 조금은 무리가 있어보입니다.
Paging3 라이브러리 같은 경우, dependency가 다음과 같습니다.
상위 두 개만 집중해서 보면, 기본적인 객체나 상태 관련 코드들은 common에 있습니다.
RecyclerView.Adapter를 상속받는 PagingAdapter 같이 안드로이드 종속성이 짙게 있는 코드들의 경우, runtime dependency에 존재하며 implement는 runtime만 해줘도 common은 함께 적용됩니다.
reference :
https://junyoung-developer.tistory.com/186
https://developer.android.com/topic/libraries/architecture/paging/v3-transform?hl=ko#kotlin
https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data#pagingdata-stream
https://stackoverflow.com/questions/64370736/how-to-show-empty-view-while-using-android-paging-3-library
https://leveloper.tistory.com/202
https://leveloper.tistory.com/208
https://www.charlezz.com/?p=44562
https://velog.io/@hs0204/Paging-library%EB%A5%BC-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90
https://ogyong.tistory.com/45
https://heeeju4lov.tistory.com/12