이전에 KAKAO REST API를 사용하여 이미지를 검색하는 앱을 만들었는데 이전에는 검색할 페이지 번호나 한 페이지에 보여질 문서 수를 상수로 정의하여 구현했었는데, 코드를 리팩토링하여 Retrofit 응답에 Paging을 적용하는 부분을 정리해보려고 한다.
🧐 Paging을 사용하는 이유는?
➡️ 페이징 라이브러리를 사용하면 로컬 저장소 또는 네트워크를 통해 큰 데이터를 잘게 쪼개어 로드하고 표시 할 수 있다. 이 방식을 사용하면 앱에서 네트워크 대역폭과 시스템 리소스를 더 효율적으로 사용할 수 있다.
Paging : 하나의 문서를 분리 된 페이지로 나누는 것
Retrofit 요청 결과를 load result 객체로 반환하는 PagingSource를 정의한다.
STARTING_PAGE_INDEX = 1
로 시작 페이지를 1로 지정한다. load(params: LoadParams<Int>): LoadResult<Int, SearchModel>
: Pager가 데이터를 호출할 때마다 불리는 함수이다. class ImageSearchPagingSource(
private val query: String,
private val sort: String
) : PagingSource<Int, SearchModel>() {
override val keyReuseSupported: Boolean = true
// 페이지를 갱신해야 될 때 수행되는 함수
override fun getRefreshKey(state: PagingState<Int, SearchModel>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, SearchModel> {
return try {
// params.key로부터 key 값을 받아와서 pageNumber에 대입하고 값을 Retrofit 서비스에 전달해서 해당하는 데이터를 받아온다.
val pageNumber = params.key ?: STARTING_PAGE_INDEX
val response = imageNetWork.searchImage(query, sort, pageNumber, params.loadSize)
// endOfPaginationReached = true면 데이터의 끝
val endOfPaginationReached = response.metaData?.isEnd!!
val data = response.documents?.map { imageDocument ->
SearchModel(
thumbnailUrl = imageDocument.thumbnailUrl,
siteName = imageDocument.displaySiteName,
datetime = imageDocument.dateTime,
itemType = SearchListType.IMAGE
)
} ?: emptyList()
val prevKey = if (pageNumber == STARTING_PAGE_INDEX) null else pageNumber - 1
val nextKey = if (endOfPaginationReached) {
null
} else {
pageNumber + (params.loadSize / MAX_SIZE_IMAGE)
}
LoadResult.Page(
data = data,
prevKey = prevKey, // 이전 페이지의 키 값
nextKey = nextKey // 다음 페이지의 키 값
)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
}
Repository에서 Pager가 ImageSearchPagingSource의 결과를 Paging 데이터로 변환하는 작업을 정의한다.
interface ImageSearchRepository {
fun searchImagePaging(query: String, sort: String): Flow<PagingData<SearchModel>>
...
}
const val MAX_SIZE_IMAGE = 15
class ImageSearchRepositoryImpl(context: Context) : ImageSearchRepository {
override fun searchImagePaging(query: String, sort: String): Flow<PagingData<SearchModel>> {
val pagingSourceFactory = { ImageSearchPagingSource(query, sort) }
return Pager(
config = PagingConfig(
pageSize = MAX_SIZE_IMAGE,
enablePlaceholders = false,
maxSize = MAX_SIZE_IMAGE * 3
),
// ImageSearchPagingSource의 결과는 pagingSourceFactory 통해서 전달 한 다음 Flow를 반환하도록 한다.
pagingSourceFactory = pagingSourceFactory
).flow
}
...
}
ViewModel에서 Paging 된 데이터를 사용한다.
class SearchViewModel(
private val imageSearchRepository: ImageSearchRepository
) : ViewModel() {
private val _searchPagingResult = MutableStateFlow<PagingData<SearchModel>>(PagingData.empty())
val searchPagingResult: StateFlow<PagingData<SearchModel>> = _searchPagingResult.asStateFlow()
fun searchImagePaging(query: String) {
viewModelScope.launch {
imageSearchRepository.searchImagePaging(query, Constants.SORT_TYPE)
.cachedIn(viewModelScope)
.collect { imageResult ->
_searchPagingResult.value = imageResult
}
}
}
...
}
class SearchPagingAdapter(
private val itemClickListener: (SearchModel) -> Unit
) : PagingDataAdapter<SearchModel, SearchPagingAdapter.ViewHolder>(
object : DiffUtil.ItemCallback<SearchModel>() {
override fun areItemsTheSame(oldItem: SearchModel, newItem: SearchModel): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: SearchModel, newItem: SearchModel): Boolean {
return oldItem == newItem
}
}
) {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val pagedItem = getItem(position)
pagedItem?.let {item ->
holder.bind(item)
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ViewHolder {
val binding =
ImageSearchItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding, itemClickListener)
}
class ViewHolder(
private val binding: ImageSearchItemBinding,
private val itemClickListener: ((SearchModel) -> Unit)?
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: SearchModel) = with(binding) {
item.thumbnailUrl?.let { ivImage.loadImage(it) }
ivHeart.isVisible = item.isSaved
tvImageSiteName.text = item.siteName
tvItemType.text = SearchListType.from(item.itemType)
tvImageDatetime.text = item.datetime?.let { FormatManager.formatDateToString(it) }
ivImage.setOnClickListener {
itemClickListener?.invoke(item)
}
}
}
}
// Fragment
private val searchPagingAdapter by lazy {
SearchPagingAdapter(
itemClickListener = {
viewModel.updateStorageItem(it)
}
)
}
private fun initViewModel() = with(viewModel) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
searchPagingResult.collectLatest {
searchPagingAdapter.submitData(it)
}
}
}
}