페이징 라이브러리를 사용해 로컬 저장소나 네트워크를 통해 대규모 데이터 세트의 페이지를 로드하고 표시할 수 있다.
RecyclerView
의 어댑터를 통해 스크롤의 끝에 도달할 때 자동으로 데이터를 요청함dependencies {
def paging_version = "3.1.1"
implementation "androidx.paging:paging-runtime:$paging_version"
// alternatively - without Android dependencies for tests
testImplementation "androidx.paging:paging-common:$paging_version"
// optional - RxJava2 support
implementation "androidx.paging:paging-rxjava2:$paging_version"
// optional - RxJava3 support
implementation "androidx.paging:paging-rxjava3:$paging_version"
// optional - Guava ListenableFuture support
implementation "androidx.paging:paging-guava:$paging_version"
// optional - Jetpack Compose integration
implementation "androidx.paging:paging-compose:1.0.0-alpha18"
}
총 3개의 계층으로 구성된다
레포지토리
기본 구성요소는 PagingSource
로서 이 객체는 데이터 소스와 이 소스에서 데이터를 검색하는 방법을 정의 한다. 단일 소스에서 데이터를 로드하는 책임을 가진다
RemoteMediator
는 로컬 데이터베이스에 캐시가 있는 네트워크 데이터 소스와 같은 계층화된 데이터소스의 페이징을 처리한다.
ViewModel레이어
Pager
는 PagingSource
및 PagingConfig
구성 객체를 바탕으로 반응형 스트림에 노출되는 PagingData
인스턴스를 구성하기 위한 공개 api를 제공하는 역할을 한다
PagingData
는 페이지로 나눈 데이터의 스냅샷을 보유하는 컨테이너로서 PagingSource
객체를 쿼리하여 결과를 나타냄
UI 레이어
PagingDataAdapter
를 통해 페이지로 나눈 데이터를 처리할 수 있음
여기서는 TMDB API 를 통해 영화 검색 결과를 표시하는 동작으로 코드를 구현한다.
PagingSource
의 정의.먼저 페이지를 나타낼 키와, 데이터형식을 정의한다.
영화검색 를 참조하면, 해당 api는 페이지를 나타내는 값이 있으므로 사용하기 적당하다.
PagingSource
를 아래와 같이 구현한다.
class SearchPagingSource(val service: TMDBService, private val queryString: String): PagingSource<Int, Movie>() {
override fun getRefreshKey(state: PagingState<Int, Movie>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Movie> {
try {
val nextPageNumber = params.key ?: 1
val response = service.service.searchMovie(queryString, nextPageNumber)
val prevKey = if (nextPageNumber == 1) {
null
} else {
nextPageNumber - 1
}
val nextKey = if (response.totalPages <= response.page) {
null
} else {
nextPageNumber + 1
}
return LoadResult.Page(
data = response.results,
prevKey = prevKey, nextKey = nextKey
)
} catch (e: Exception) {
return LoadResult.Error(e)
}
}
}
load()
메서드는 데이터를 로드하는 역할을 하며, 로드를 위해 필요한 키와 항목의 수가 LoadParams
객체를 통해 전달된다. 키가 없는경우 첫번째임을 의미하고, 키값 및 쿼리 결과에 따라 이전 키 다음 키를 정의해서 리턴할 수 있다.
load()
의 반환값인 LoadResult
는 로드 작업의 결과물이 포함되며, 성공시. LoadResult.Page
를 실패시 LoadResult.Error
를 반환한다.
다음 이미지는 load()
함수가 각 로드시의 키를 수신 및 후속 로드용 키를 제공하는 방법을 보여준다.
PagingSource
의 구현은 getRefreshKey()
메서드도 반드시 구현해야함. 이 메서드는 데이터가 첫 로드 후 새로고침되거나, 무효화 되었을 때 키를 반환하여 load()
로 전달하고, 페이징 라이브러리는 다음에 데이터를 새로고침할 때 자동으로 이 메서드를 호출함.
로드된 데이터를 이용하기 위해서는 페이징된 데이터의 스트림이 필요하다. 일반적으로 ViewModel에서 스트림을 설정한다
Pager
클래스는 PagingConfig
를 기반으로 PagingSourceFactory 메서드를 호출하여 PagingSource
의 구현체를 생성하고, PagingData
객체의 반응형 스트림을 노출 할 수 있도록 한다.
var bindingQuery: String = ""
// paging flow
val flow = Pager(PagingConfig(pageSize = 10, prefetchDistance = 50)) {
SearchPagingSource(TMDBService, bindingQuery)
}.flow.map {
data ->
data.map { movie ->
if (this::config.isInitialized) {
movie.posterPath?.let {
movie.copy(posterPath = config.getImageUri(it, ImageCategory.POSTER, ImageSizeType.IN_LIST).toString())
} ?: movie
} else {
movie
}
}
}.cachedIn(viewModelScope)
Pager
클래스의 팩토리메소드를 통해 페이징 소스를 생성하고, 그 결과인 PagingData
를 map 함수를 통해 변경하여 제공할 수 있다.
cachedIn()
연산자는 데이터스트림의 공유를 가능하게 하며, 제공된 코루틴스콥을 사용해 로드된 데이터를 캐시한다. 캐시된 데이터는 중복 호출의 염려 없이 사용될 수 있다는 것
Paging 데이터를 다루기 위한 PagingDataAdapter
클래스가 제공된다. DiffUtil을 사용하는 등 ListAdpater와 비슷하게 사용하면 된다.
object DiffUtils: DiffUtil.ItemCallback<Movie>() {
override fun areItemsTheSame(oldItem: Movie, newItem: Movie): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: Movie, newItem: Movie): Boolean {
return oldItem.id == newItem.id
}
}
class SearchMovieAdapter: PagingDataAdapter<Movie, SearchMovieAdapter.ItemHolder>(DiffUtils) {
inner class ItemHolder(val binding: SearchMovieItemBinding):ViewHolder(binding.root) {
fun bind(movie: Movie) {
with(binding) {
title.text = movie.title
desc.text = movie.overview
Glide.with(this.movieThumbnail).load(Uri.parse(movie.posterPath ?: ""))
.placeholder(R.drawable.poster)
.fallback(R.drawable.poster)
.into(this.movieThumbnail)
}
}
}
override fun onBindViewHolder(holder: ItemHolder, position: Int) {
holder.bind(getItem(position)!!)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder {
return ItemHolder(SearchMovieItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
}
ListAdpater와의 차이점이 있다면, currentList로 접근불가능하고, 데이터를 제출할때는 submitList
가 아닌 submitData
를 사용해야 한다는점 정도.
주의: submitData() 메서드는 PagingSource가 무효화되거나 어댑터의 새로고침 메서드가 호출될 때까지 중단되며 반환하지 않습니다. 즉 submitData() 호출 이후의 코드가 의도한 것보다 훨씬 늦게 실행될 수 있습니다.
라고 한다
refresh 및 retry를 통해 데이터를 다시 로드할 수 있는데, refresh시 주의사항이 있다.
리프레시 후 로드를 위해서는 새로운 PagingSource 객체가 필요하다. 또는 invalidate 상태여야 한다. 이를 위해 invalidate후 생성해주는 InvalidatingPagingSourceFactory 도 제공을 해주긴하더라.var bindingQuery: String = "" // paging flow val flow = Pager(PagingConfig(pageSize = 10, prefetchDistance = 50)) { SearchPagingSource(TMDBService, bindingQuery) } 위와 같이 팩토리 메소드에 매번 새로운 소스를 생성하는 코드를 추가한 것이 그때문이다. 이것땜에 삽질좀 했다....
https://developer.android.com/topic/libraries/architecture/paging/v3-overview?hl=ko
https://www.charlezz.com/?p=44562
https://stackoverflow.com/questions/66803759/refresh-in-paging-3-library-when-using-rxjava