Paging 3, MVVM, Coroutine, Hilt, Flow를 사용해 RecyclerView 구현하기

김명진·2022년 5월 1일
1

안드로이드

목록 보기
25/25

💡 Paging


페이지 라이브러리는 네트워크에서 또는 로컬 데이터베이스에서 대규모 데이터를 페이지 단위로 불러와 표시할 수 있게 되와주는 라이브러리입니다. 모든 데이터를 한번에 불어오는것이 아니고 페이지 단위로 부분적으로 불러와 화면에서 표시를 하고 다음 해당 페이지가 끝이 나면 다음 페이지를 불러와 표시하여 보다 효율적으로 데이터를 불러올 수 있습니다.

이번 글에서는 페이징 기능을 MVVM, Coroutine, Hilt 그리고 flow를 사용해 구현해보도록 하겠습니다.

MVVM, coroutine, hilt 그리고 flow의 기본적인 사용법을 알고 있어야 해당 글을 이해하는데 더 수월합니다.

0. 받아올 데이터 설정

이번 예제에서는 카카오 검색 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에 맞게 데이터 형식을 설정해줍니다.

1. Dependancy 설정

우선 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'
    
}

2. API 구현

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로 해줍니다.

3. PagingSource 구현

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

getRefreshKey()

데이터의 업데이트 또는 새로고침을 할때 현재 리스트를 대체할 새로운 데이터를 로그할 때 사용이되는 함수입니다. 즉 데이터를 새로고침할때 적절한 key 값을 반환해준다.

load(params: LoadParams)

실제 데이터를 로드하는 함수입니다. 데이터는 로컬 또는 remote에서 받아오는데 이 예제에서는 아까 설정했던 카카오 책 검색 API를 사용하였다. 여기서 withContext를 사용하여 IO 디스패처 코루틴에서 실행해 비동기로 데이터를 불러온다.

load에서 nextKey는 해당 다음 페이지를 로드할때 사용하는데 카카오 API에서 is_end 변수가 페이지 끝을 의미하기때문에 만약 is_end가 true라면 null값을 nextKey로 반환하여 더이상 페이지를 불러오지 않도록 설정합니다.
이렇게 data, prevKey 그리고 nextKey를 LoadResult.Page()를 통해 리턴해준다.

paging 도중 네트워크 오류와 같이 오류가 발생할 수 있기 때문에 LoadResult.Error()를 통해 오류도 반환해준다.

  • LoadResult.Page : 로드에 성공한 경우, 데이터와 이전 다음 페이지 Key가 포함된다.
  • LoadResult.Error : 오류가 발생한 경우

4. PagingData 구현

해당 프로젝트는 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개의 리스트가 남았을때 다음 페이지르 불러온다라는 뜻이다.

5. ViewModel에서 PagingData 요청하기

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에서 데이터를 캐싱할 수 있다. 화면 회전을 해도 데이터가 유지된다.

6. PagingAdapter (RecyclerViewAdapter) 구현하기


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


}

6. UI에서 데이터 보여주기

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로 전달해주면 된다.

profile
꿈꾸는 개발자

0개의 댓글