[Android / Kotlin] Retrofit 응답에 Paging 적용하기

Subeen·2024년 2월 19일
0

Android

목록 보기
63/73

이전에 KAKAO REST API를 사용하여 이미지를 검색하는 앱을 만들었는데 이전에는 검색할 페이지 번호나 한 페이지에 보여질 문서 수를 상수로 정의하여 구현했었는데, 코드를 리팩토링하여 Retrofit 응답에 Paging을 적용하는 부분을 정리해보려고 한다.

🧐 Paging을 사용하는 이유는?
➡️ 페이징 라이브러리를 사용하면 로컬 저장소 또는 네트워크를 통해 큰 데이터를 잘게 쪼개어 로드하고 표시 할 수 있다. 이 방식을 사용하면 앱에서 네트워크 대역폭과 시스템 리소스를 더 효율적으로 사용할 수 있다.

Paging

Paging : 하나의 문서를 분리 된 페이지로 나누는 것

Jetpack Jaging의 구성요소

  • Repository
    • PagingSource : 데이터 소스와 그 소스에서 데이터를 검색하는 방법을 정의
    • RemoteMediator : 로컬 데이터베이스에 네트워크 데이터를 캐시하는 동작을 관리
  • ViewModel
    • Pager : Repository에서 정의한 PagingSource와 PagingConfig를 생성자로 받아 PagingData를 반환하는 API를 제공
    • PaingData : Pager에 의해 페이징 된 데이터를 담는 컨테이너
  • UI
    • PagingDataAdapter : PagingData를 표시할 수 있는 RecyclerView 어댑터

PagingSource

Retrofit 요청 결과를 load result 객체로 반환하는 PagingSource를 정의한다.

  • PagingSource<페이지 타입, 데이터 타입>()
  • 키의 초기값은 null이기 때문에 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)
        }
    }
}

Paging Data로 변환

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

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

RecyclerView Adapter

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)
                }
            }
        }
    }
profile
개발 공부 기록 🌱

0개의 댓글