페이지 라이브러리는 네트워크에서 또는 로컬 데이터베이스에서 대규모 데이터를 페이지 단위로 불러와 표시할 수 있게 되와주는 라이브러리입니다. 모든 데이터를 한번에 불어오는것이 아니고 페이지 단위로 부분적으로 불러와 화면에서 표시를 하고 다음 해당 페이지가 끝이 나면 다음 페이지를 불러와 표시하여 보다 효율적으로 데이터를 불러올 수 있습니다.
이번 글에서는 페이징 기능을 MVVM, Coroutine, Hilt 그리고 flow를 사용해 구현해보도록 하겠습니다.
MVVM, coroutine, hilt 그리고 flow의 기본적인 사용법을 알고 있어야 해당 글을 이해하는데 더 수월합니다.
이번 예제에서는 카카오 검색 API에서 책 검색를 사용하도록 하겠습니다.
카카오 책 검색 API 문서 링크
카카오 API에 필요한 API KEY 발급후 프로젝트에 저장해주는것도 까먹지말것
data class BookListResponse(
val meta: Meta,
val documents: List<BookEntity>
) {
data class Meta(
@SerializedName("total_count") val totalCount: Int,
@SerializedName("pageable_count") val pageableCount: Int,
@SerializedName("is_end") val isEnd: Boolean,
)
}
data class BookEntity(
@SerializedName("title") val title: String,
@SerializedName("contents") val contents: String,
@SerializedName("url") val url: String,
@SerializedName("isbn") val isbn: String,
@SerializedName("datetime") val datetime: String,
@SerializedName("authors") val authors: List<String>,
@SerializedName("publisher") val publisher: String,
@SerializedName("translators") val translators: List<String>,
@SerializedName("price") val price: Int,
@SerializedName("sale_price") val salePrice: Int,
@SerializedName("thumbnail") val thumbnail: String,
@SerializedName("status") val status: String,
)
카카오 책 검색 API의 response에 맞게 데이터 형식을 설정해줍니다.
우선 build.gradle에 paging 라이브러리를 추가해줍니다. 여기서 Coroutine 사용하기 때문에 해당 라이브러리들도 추가해줍니다.
dependencies {
...
// paging
implementation 'androidx.paging:paging-runtime-ktx:3.1.1'
// coroutine
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
}
interface KakaoApiService {
@GET("/v3/search/book")
suspend fun getBookList(
@Query("query") query: String,
@Query("sort") sort: String? = null,
@Query("page") page: Int? = null,
@Query("size") size: Int? = null
): Response<BookListResponse>
}
Retrofit API 인터페이스를 추가해 줍니다. sort, page, size 쿼리는 필수가 아니기 때문에 초기값을 null로 해줍니다.
paging에서 PagingSource는 데이터 소스와 이 소스에서 데이터를 검색하는 방법을 정의합니다. PagingSource 객체는 네트워크 소스 및 로컬 데이터베이스를 포함한 단일 소스에서 데이터를 로드할 수 있습니다.
class BookInfoPagingSource(
private val searchString: String,
private val ioDispatcher: CoroutineDispatcher,
private val kakaoApiService: KakaoApiService,
) : PagingSource<Int, BookInfo>() {
override fun getRefreshKey(state: PagingState<Int, BookInfo>): 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, BookInfo> {
return try {
val page = params.key ?: 1
val response = withContext(ioDispatcher) {
kakaoApiService.getBookList(query = searchString, page = page, size = 50)
}
val bookList = response.body()?.documents?.toBookInfoList() ?: listOf()
val prevKey = if (page == 1) null else page - 1
val nextKey = if (bookList.isEmpty() || response.body()?.meta?.isEnd == true) null else page + 1
LoadResult.Page(
data = bookList,
prevKey = prevKey,
nextKey = nextKey
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
}
데이터의 업데이트 또는 새로고침을 할때 현재 리스트를 대체할 새로운 데이터를 로그할 때 사용이되는 함수입니다. 즉 데이터를 새로고침할때 적절한 key 값을 반환해준다.
실제 데이터를 로드하는 함수입니다. 데이터는 로컬 또는 remote에서 받아오는데 이 예제에서는 아까 설정했던 카카오 책 검색 API를 사용하였다. 여기서 withContext를 사용하여 IO 디스패처 코루틴에서 실행해 비동기로 데이터를 불러온다.
load에서 nextKey는 해당 다음 페이지를 로드할때 사용하는데 카카오 API에서 is_end 변수가 페이지 끝을 의미하기때문에 만약 is_end가 true라면 null값을 nextKey로 반환하여 더이상 페이지를 불러오지 않도록 설정합니다.
이렇게 data, prevKey 그리고 nextKey를 LoadResult.Page()를 통해 리턴해준다.
paging 도중 네트워크 오류와 같이 오류가 발생할 수 있기 때문에 LoadResult.Error()를 통해 오류도 반환해준다.
해당 프로젝트는 MVVM 구조를 갖고 있기 때문에 Repository에 PagingData를 구현해준다.
class DefaultBookRepository @Inject constructor(
private val kakaoApiService: KakaoApiService,
private val ioDispatcher: CoroutineDispatcher
) : BookRepository {
override fun getBookPagingData(searchString: String): Flow<PagingData<BookInfo>> {
return Pager(PagingConfig(pageSize = 10)) {
BookInfoPagingSource(searchString, ioDispatcher, kakaoApiService)
}.flow
}
}
PagingData 객체는 페이지로 나눈 데이터의 스냅샷을 보유하는 컨테이너입니다. PagingSource 객체를 쿼리하여 결과를 저장합니다.
여기서 PagingConfig에서 pageSize를 설정해 다음 페이지를 어느 시점에서 불러올지 설정할 수 있다. 만약 10으로 설정을 한다면 10개의 리스트가 남았을때 다음 페이지르 불러온다라는 뜻이다.
class GetBookListUseCase @Inject constructor(
private val bookRepository: BookRepository
) {
operator fun invoke(searchString: String): Flow<PagingData<BookInfo>> {
return bookRepository.getBookPagingData(searchString)
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val getBookListUseCase: GetBookListUseCase
) : BaseViewModel() {
private fun getBookList(searchString: String): Flow<PagingData<BookInfo>> {
return getBookListUseCase(searchString).cachedIn(viewModelScope)
}
}
cachedIn()를 사용하면 해당 scope에서 데이터를 캐싱할 수 있다. 화면 회전을 해도 데이터가 유지된다.
class BookInfoListAdapter() : PagingDataAdapter<BookInfo, BookInfoListAdapter.PagingViewHolder>(diffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PagingViewHolder {
val binding = ViewholderBookInfoBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return PagingViewHolder(binding)
}
override fun onBindViewHolder(holder: PagingViewHolder, position: Int) {
val item = getItem(position)
item?.let { holder.bind(it, position) }
}
inner class PagingViewHolder(private val binding: ViewholderBookInfoBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(bookInfo: BookInfo, position: Int) = with(binding) {
textviewBookTitle.text = bookInfo.title
Glide.with(root)
.load(bookInfo.thumbnail)
.placeholder(R.drawable.image_book_cover)
.error(R.drawable.image_book_cover)
.into(imageviewThumbnail)
}
companion object {
private val diffCallback = object : DiffUtil.ItemCallback<BookInfo>() {
override fun areItemsTheSame(oldItem: BookInfo, newItem: BookInfo): Boolean {
return oldItem.title == newItem.title
}
override fun areContentsTheSame(oldItem: BookInfo, newItem: BookInfo): Boolean {
return oldItem == newItem
}
}
}
}
class SearchFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
setAdapter()
observeData()
viewModel.getBookList("해리포터")
}
private fun observeData() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.pagingDataFlow.collectLatest {
bookInfoRecyclerViewAdapter.submitData(it)
}
}
private fun setAdapter() {
adapter = BookInfoListAdapter()
binding.recyclerView.adapter = adapter
}
}
Activity 또는 Fragment에서 Adapter를 설정하고 viewModel에서 전달받은 데이터를 Paging Adapter로 전달해주면 된다.