[안드로이드] Paging

이상욱·2022년 12월 24일
0

안드로이드

목록 보기
13/17
post-thumbnail

✅ Paging

Paging은 하나의 문서를 분리된 페이지로 나누는 것
Android Jetpack에서 Paging 라이브러리를 지원합니다.

Paging 구성요소

Repository

  • PaingSource : 데이터 소스와 그 소스에서 데이터를 검색하는 방법을 정의
  • RemoteMediator : 로컬 데이터베이스네 네트워크 데이터를 캐시하는 동작을 관리

ViewModel

  • Pager : Repository에서 정의한 PagingSource와 PagingConfig를 생성자로 받아 PagingData를 반환하는 API를 제공
  • PagingData : Pager에 의해 페이징 된 데이터를 담는 컨테이너

UI

  • PagingAdapter : PagingData를 표시할 수 있는 RecyclerView 어댑터

1. Room + Paging 예제

먼저 라이브러리를 추가해줍니다.


   //Room
    def room_version = "2.4.3"
    implementation("androidx.room:room-runtime:$room_version")
    implementation("androidx.room:room-ktx:$room_version")
    kapt("androidx.room:room-compiler:$room_version")
    implementation("androidx.room:room-paging:$room_version")
    
//Paging
    def paging_version = "3.1.1"
    implementation("androidx.paging:paging-runtime:$paging_version")
    
    

BookSearchDao.kt

@Dao
interface BookSearchDao {
	...
    //Paging
    @Query("SELECT * FROM books")
    fun getFavoritePagingBooks(): PagingSource<Int, Book>
}

Room Dao에서 PagingSource<Key,Value>를 반환 받는 쿼리문을 작성합니다.

BookSearchRepositoryImpl.kt

    //Paging
    fun getFavoritePagingBooks(): Flow<PagingData<Book>> {
        val pagingSourceFactory = { bookSearchDao.getFavoritePagingBooks() }
        return Pager(
            config = PagingConfig(
                pageSize = PAGING_SIZE,
                enablePlaceholders = false,
                maxSize = PAGING_SIZE * 3
            ),
            pagingSourceFactory = pagingSourceFactory
        ).flow
    }

Repository에서는 Pager를 구현합니다. Pager를 구현하기 위해서는 PagingConfig를 통해서 파라미터를 전달해줘야 합니다. PagingConfig는 3가지 파라미터를 갖습니다.
pagingSize는 어떤 기기가 되더라도 뷰 홀더에 표시할 데이터가 모자라지 않은 값으로 설정해줍니다.
enablePlaceholders는 true로 되어있으면 repositoy전체 데이터 사이즈를 받아와서 RecyclerView에 PlaceHolder를 미리 만들어 놓고 화면에 표시되지 않는 항목을 null로 표시합니다. 저는 데이터를 필요한 만큼만 로딩할 것이므로 false로 지정해줍니다.
maxSize는 Pager가 메모리에 최대로 가지고 있을 수 있는 개수를 의미합니다.

BookSearchViewModel.kt

    val favoritePagingBooks: StateFlow<PagingData<Book>> =
        bookSearchRepository.getFavoritePagingBooks()
            .cachedIn(viewModelScope)
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PagingData.empty())

ViewModel에서는 Reporitory의 페이징 함수에 cachedIn을 붙여서 코루틴이 데이터 스트림을 캐쉬하고 공유 가능하게 만들어줍니다.
그리고 UI에서 감시해야하는 데이터이므로 stateIn을 써서 StateFlow로 만들어 줍니다.

PagingAdapter.kt

페이징을 위한 리사이클러뷰 어댑터를 생성합니다.
기존의 어댑터와 다른점은 PagingAdater를 상속받고 있고 onBindViewHolder에서 pagedBook이 null이 될 수 있으므로 null처리를 해줍니다.

package com.example.booksearchapp.ui.adapter

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.example.booksearchapp.data.model.Book
import com.example.booksearchapp.databinding.ItemBookPreviewBinding

class BookSearchPagingAdapter : PagingDataAdapter<Book, BookSearchViewHolder>(bookDiffCallback) {

    override fun onBindViewHolder(holder: BookSearchViewHolder, position: Int) {
        val pagedBook = getItem(position)
        pagedBook?.let { book ->
            holder.bind(book)
            holder.itemView.setOnClickListener {
                onItemClickListener?.let { it(book) }
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookSearchViewHolder {
        return BookSearchViewHolder(
            ItemBookPreviewBinding.inflate(
                LayoutInflater.from(parent.context), parent, false
            )
        )
    }

    private var onItemClickListener: ((Book) -> Unit)? = null
    fun setOnItemClickListener(listener: (Book) -> Unit) {
        onItemClickListener = listener
    }

    companion object {
        val bookDiffCallback = object : DiffUtil.ItemCallback<Book>() {

            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                return oldItem.isbn == newItem.isbn
            }

            override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                return oldItem == newItem
            }

        }
    }
}

Fragment.kt

private lateinit var bookSearchPagingAdapter: BookSearchPagingAdapter

	/* 리사이클러 뷰 초기화 */
    private fun initRecyclerView() {
        val itemClickListener: (Book) -> Unit = {
            /** safe-args **/
            val action = FavoriteFragmentDirections.actionFavoriteFragmentToBookFragment(it)
            findNavController().navigate(action)
        }

        bookSearchPagingAdapter = BookSearchPagingAdapter()
        bookSearchPagingAdapter.setOnItemClickListener(itemClickListener)

        binding.rvFavoriteBooks.apply {
            setHasFixedSize(true)
            addItemDecoration(
                DividerItemDecoration(
                    requireContext(), DividerItemDecoration.VERTICAL
                )
            )
            adapter = bookSearchPagingAdapter
        }

    }
        
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        	super.onViewCreated(view, savedInstanceState)
            initRecyclerView()
        	collectLatestStateFlow(bookSearchViewModel.favoritePagingBooks) {
            bookSearchPagingAdapter.submitData(it)
        }
        
        
		}    

2. Retrofit + Paging 예제

서버와 통신해서 페이징을 할 때 임의로 페이징 처리를 해주기 위해 PagingSource를 만들어줍니다.

BookSearchPagingSource.kt

package com.example.booksearchapp.data.repository

import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.example.booksearchapp.data.api.BookSearchApi
import com.example.booksearchapp.data.model.Book
import com.example.booksearchapp.util.Constants.PAGING_SIZE
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject

class BookSearchPagingSource @Inject constructor(
    private val query: String, private val sort: String, private val bookSearchApi: BookSearchApi
) : PagingSource<Int, Book>() {

    override fun getRefreshKey(state: PagingState<Int, Book>): 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, Book> {
        return try {

            /** Key의 초기값이 null 이므로 1로 설정해줍니다. **/
            val pageNumber = params.key ?: STARTING_PAGE_INDEX
            val response = bookSearchApi.searchBook(query, sort, pageNumber, params.loadSize)

            /** 받아오는 페이지가 마지막인지 확인하는 변수 **/
            val endOfPaginationReached = response.body()?.meta?.isEnd!!

            val data = response.body()?.documents!!

            /** 현재 페이지 넘버가 1이면 prevKey = null이 되고 아니면 prevKey = 현재 페이지 넘버 - 1이 됩니다. **/
            val prevKey = if (pageNumber == STARTING_PAGE_INDEX) null else pageNumber - 1

            /** 현재 페이지가 마지막 페이지라면 nextKey = null이고 아니면 nextKey는 현재 페이지 넘버에서 로드한 사이즈 / 페이징 할 사이즈를 더해준 값이 됩니다. **/
            val nextKey = if (endOfPaginationReached) {
                null
            } else {
                pageNumber + (params.loadSize / PAGING_SIZE)
            }

            LoadResult.Page(
                data = data, prevKey = prevKey, nextKey = nextKey
            )
        } catch (exception: IOException) {
            LoadResult.Error(exception)
        } catch (exception: HttpException) {
            LoadResult.Error(exception)
        }

    }

    companion object {
        const val STARTING_PAGE_INDEX = 1
    }
}

viewModel.kt

뷰모델에서 MutableStateFlow로 변수를 하나만들어주고 페이징한 결과값을 외부에서 접근하지 못하도록 searchPagingResult변수에 담아줍니다.

    private val _searchPagingResult = MutableStateFlow<PagingData<Book>>(PagingData.empty())
    val searchPagingResult: StateFlow<PagingData<Book>> = _searchPagingResult.asStateFlow()

    fun searchBooksPaging(query: String) {
        viewModelScope.launch {
            bookSearchRepository.searchBooksPaging(query, getSortMode()).cachedIn(viewModelScope)
                .collect {
                    _searchPagingResult.value = it
                }
        }
    }

fragment.kt

private lateinit var bookSearchPagingAdapter: BookSearchPagingAdapter

	/* 리사이클러 뷰 초기화 */
    private fun initRecyclerView() {
                val itemClickListener: (Book) -> Unit = {
            /** safe-args **/
            val action = SearchFragmentDirections.actionSearchFragmentToBookFragment(it)
            findNavController().navigate(action)
        }

        bookSearchPagingAdapter = BookSearchPagingAdapter()
        bookSearchPagingAdapter.setOnItemClickListener(itemClickListener)

        binding.rvSearchResult.apply {
            setHasFixedSize(true)
            addItemDecoration(
                DividerItemDecoration(
                    requireContext(), DividerItemDecoration.VERTICAL
                )
            )
            adapter = bookSearchPagingAdapter
        }

    }
        
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        	super.onViewCreated(view, savedInstanceState)
            initRecyclerView()
        	searchBook()
        	collectLatestStateFlow(bookSearchViewModel.favoritePagingBooks) {
            bookSearchPagingAdapter.submitData(it)
        }
        
        
		}    

profile
항상 배우고 성장하는 안드로이드 개발자

0개의 댓글