이번 영상에서는 기존에 구축한 Room DB 응답에 Paging을 적용하는 방법에 대해 알아보도록 하겠습니다.
Room의 응답에 Paging을 적용하면 "SELECT * FROM books" 쿼리를 사용해도 한번에 모든 데이터를 반환하지 않고 페이지 단위로 반환하므로 페이징을 구현할 수 있습니다. 프로젝트에 Room과 Paging Dependency를 추가합니다.
// Room
implementation 'androidx.room:room-paging:2.4.2'
// Paging
implementation 'androidx.paging:paging-runtime-ktx:3.1.1'
Repository로부터 PagingSource를 반환받아야 하는데, Room은 쿼리결과를 PagingSource 타입으로 반환할 수 있습니다. Flow로 전체 DB를 받아오던 기존의 getFavoriteBooks 대신 PagingSource로 반환받는 getFavoritePagingBooks 함수를 Dao에 추가합니다.
@Dao
interface BookSearchDao {
...
+ @Query("SELECT * FROM books")
+ fun getFavoritePagingBooks(): PagingSource<Int, Book>
}
다음은 리포지토리에 PagingData를 반환하는 Pager를 정의합니다. 우선은 인터페이스에 명세를 추가합니다.
interface BookSearchRepository {
...
+ // Paging
+ fun getFavoritePagingBooks(): Flow<PagingData<Book>>
}
그리고 BookSearchRepositoryImpl에서 interface에 정의된 명세를 구현힙니다.
object Constants {
+ const val PAGING_SIZE = 15
}
class BookSearchRepositoryImpl(
private val db: BookSearchDatabase,
private val dataStore: DataStore<Preferences>
) : BookSearchRepository {
...
+ // Paging
+ override fun getFavoritePagingBooks(): Flow<PagingData<Book>> {
+ val pagingSourceFactory = { db.bookSearchDao().getFavoritePagingBooks() }
+
+ return Pager(
+ config = PagingConfig(
+ pageSize = PAGING_SIZE,
+ enablePlaceholders = false,
+ maxSize = PAGING_SIZE * 3
+ ),
+ pagingSourceFactory = pagingSourceFactory
+ ).flow
+ }
}
Pager를 구현하기 위해서는 우선 PagingConfig를 통해 파라미터를 전달해주어야 하는데 Pager의 config는 세가지 파라미터를 갖습니다.
true로 되어 있으면 리포지토리의 전체 데이터 사이즈를 받아와 리사이클러뷰에 플레이스홀더를 미리 만들어놓고 화면에 표시되지 않는 항목은 null로 표시합니다. 여기서는 데이터를 필요할 때 필요한만큼만 로딩할 것이기 때문에 false로 설정하겠습니다.Pager가 메모리에 최대로 가지고 있을 수 있는 항목개수입니다. 여기서는 pageSize의 3배로 설정하도록 하겠습니다.그리고나면 getFavoritePagingBooks 결과를 전달하고 .flow를 붙여서 PagingData 결과를 Flow로 만들어줍니다. 이렇게 해서 Flow<PagingData<Book>>를 반환하는 Pager를 구현했습니다.
다음은 ViewModel에서 페이징 된 데이터를 사용하기 위한 함수를 정의합니다.
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository,
) : ViewModel() {
...
+ // Paging
+ val favoritePagingBooks: StateFlow<PagingData<Book>> =
+ bookSearchRepository.getFavoritePagingBooks()
+ .cachedIn(viewModelScope)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PagingData.empty())
}
getFavoritePagingBooks 응답에 cachedIn 을 붙여서 코루틴이 데이터 스트림을 캐시하고 공유가능하게 만들어 줍니다. 또 UI에서 감시해야 하는 데이터이므로 stateIn을 써서 StateFlow로 변환하여 줍니다.
다음은 PagingData를 처리할 수 있는 RecyclerView Adapter를 만들어줍니다. 기존의 BookSearchAdapter와 비교하면 ListAdapter 대신 PagingDataAdapter를 상속받고, getItem이 null이 될 수 있기 때문에 그 부분에 대한 처리를 해줘야 하는 점을 빼면 사용법은 동일합니다.
class BookSearchPagingAdapter : PagingDataAdapter<Book, BookSearchViewHolder>(BookDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookSearchViewHolder {
return BookSearchViewHolder(
ItemBookPreviewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: BookSearchViewHolder, position: Int) {
val pagedBook = getItem(position)
pagedBook?.let { book ->
holder.bind(book)
holder.itemView.setOnClickListener {
onItemClickListener?.let { it(book) }
}
}
}
private var onItemClickListener: ((Book) -> Unit)? = null
fun setOnItemClickListener(listener: (Book) -> Unit) {
onItemClickListener = listener
}
companion object {
private 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
}
}
}
}
이제 페이징 된 데이터를 표시하기 위한 준비가 끝났으니 FavoriteFragment 에서 결과를 표시하도록 합니다.
class FavoriteFragment : Fragment() {
...
- private lateinit var bookSearchAdapter: BookSearchAdapter
+ private lateinit var bookSearchAdapter: BookSearchPagingAdapter
private fun setupRecyclerView() {
- bookSearchAdapter = BookSearchAdapter()
+ bookSearchAdapter = BookSearchPagingAdapter()
...
}
private fun setupTouchHelper(view: View) {
val itemTouchHelperCallback = object : ItemTouchHelper.SimpleCallback(
0, ItemTouchHelper.LEFT
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean {
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.bindingAdapterPosition
- val book = bookSearchAdapter.currentList[position]
- bookSearchViewModel.deleteBook(book)
- Snackbar.make(view, "Book has deleted", Snackbar.LENGTH_SHORT).apply {
- setAction("Undo") {
- bookSearchViewModel.saveBook(book)
- }
- }.show()
+ val pagedBook = bookSearchAdapter.peek(position)
+ pagedBook?.let { book ->
+ bookSearchViewModel.deleteBook(book)
+ Snackbar.make(view, "Book has deleted", Snackbar.LENGTH_SHORT).apply {
+ setAction("Undo") {
+ bookSearchViewModel.saveBook(book)
+ }
+ }.show()
+ }
}
}
ItemTouchHelper(itemTouchHelperCallback).apply {
attachToRecyclerView(binding.rvFavoriteBooks)
}
}
}
우선은 기존의 ListAdapter 대신 PagingAdapter를 사용하도록 설정합니다. 또 Paging의 응답은 null을 가질 수 있으므로 itemTouchHelperCallback의 onSwiped 내부도 그에 맞춰 변경하여 줍니다.
다음은 favoriteBooks의 값을 표시하던 코루틴에서 favoritePagingBooks를 구독하면 됩니다. 이 때 주의할 것은 어댑터에 대해 submitList가 아닌 submitData를 실행시켜야 한다는 점입니다. 그리고 PagingData는 시간에 따라 변화하는 특성을 갖고 있기 때문에 반드시 collect가 아닌 collectLatest로 값을 구독해서, 항상 기존 Paging 값을 캔슬하고 새 값을 구독하도록 해야 한다는 점입니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
- collectLatestStateFlow(bookSearchViewModel.favoriteBooks) {
- bookSearchAdapter.submitList(it)
- }
+ collectLatestStateFlow(bookSearchViewModel.favoritePagingBooks) {
+ bookSearchAdapter.submitData(it)
+ }
}