Paging3

이석규·2024년 3월 11일
0
post-custom-banner

Paging은 무엇입니까

수많은 데이터를 한번에 불러오지 않고, 설정한 페이지에 따라 부분적으로 불러오도록 처리하는 것입니다.

Paging3 의 장,단점은 무엇입니까

장점

  • 여러 플랫폼에서 사용할 수 있습니다.
    • AOS iOS 웹페이지 세 곳의 개발을 한 번에 할 수 있습니다.
  • 배포 없이 업데이트를 할 수 있습니다.
    • 앱 배포에 필요한 심사 없이 이를 업데이트 할 수 있습니다.
  • 인터넷 연결이 지속적으로 필요한 데이터를 불러오는데에 유용합니다.
    • 항상 인터넷 연결이 필요한 이메일과 같은 데이터는 웹 뷰로 보여주는 것이 더 쉽습니다.

단점

  • 어댑터에 접근해서 직접 아이템을 수정, 삭제, 추가할 수 없습니다.
    • 의도가 데이터의 수정을 고려하고 나온 라이브러리가 아니긴 합니다.
  • 내부 value를 보기 어렵습니다.
    • 어댑터에 listener를 달아서 확인할 수 있습니다.
  • 안드로이드 종속성을 해칠 우려가 있습니다.
    • test 종속성을 implement하여 임의로 안드로이드 종속성을 배제할 수 있습니다.

Paging3는 어떻게 사용합니까

1. PagingSource

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 함수를 여러번 호출하여 분할된 데이터를 불러옵니다.
이 때 파라미터와 반환값은 다음을 의미합니다.

  • LoadParams(파라미터)
    • load 할 유형(Refresh, Append, Prepend)
    • load 할 크기
  • LoadResults(반환값)
    • 결과 유형(Error, Invalid, Page)
      • Page = (data, prevKey, nextKey)

getRefreshKey() 메서드는 예기치 못한 오류로 인해 key를 유실했을 때, 정한 key 값을 전달합니다.

내부적으로 이를 처리해주는 부분을 통해, PagingSource에서 우리는 Paging3 라이브러리의 덕을 한 번 볼 수 있습니다.

2. Pager

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() 연산자로 페이징 데이터를 캐싱합니다.

3. PagingData

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를 변형합니다.

4. PagingDataAdapter

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를 내부적으로 가지고 있기 때문입니다.

5. PagingDataDiffer

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

6. LoadStateListener


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를 달아서 확인합니다.

Paging3의 작동

1. Paging3는 어떻게 데이터를 캐싱하고 가져오는가

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

2. Paging3는 어떻게 구성 변경에도 focus position을 유지하는가

PagingDataAdapter는 RecyclerView.Adapter를 상속합니다.

RecyclerView.Adapter에는 구성 변경 시, focus position을 유지할 수 있도록 해주는 위의 함수가 존재합니다.

PagingDataAdapter가 이를 오버라이딩하여, 내부적으로 실행해줍니다.

Paging3에 대한 오해

1. Paging3는 데이터 추가, 삭제도 못하는 별로인 라이브러리다.

엄연하게 말하면, Paging은 다수의 데이터를 분할하여 불러오는 데에 특화되어 있는 라이브러리입니다.
라이브러리의 목적 자체가 지협적인 것이지, 그것만으로 라이브러리가 별로라고 말하는 것은 조금은 무리가 있어보입니다.

2. Paging3는 안드로이드 종속성이 짙어서, 아키텍처를 해친다.

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

profile
안드안드안드
post-custom-banner

0개의 댓글