이번 영상에서는 Retrofit 응답에 Paging을 적용하는 법에 대해 알아보도록 하겠습니다. 전체적인 흐름은 Room의 응답을 변환할 때와 동일한데, 결과값을 알아서 PagingSource로 반환해주던 Room과 달리, 네트워크 응답은 우리가 직접 PagingSource로 가공하는 과정이 추가되어야 합니다.
지금까지는 ViewModel의 searchBooks에서 페이지 번호를 의미하는 pIndex 값은 1, 한 페이지당 받아올 데이터 갯수인 pSize는 15로 고정값을 사용하고 있었습니다. Paging은 이 값을 필요에 따라 변화시켜 REST 요청을 수행한 후 결과를 PagingSource 타입으로 반환하게 됩니다.
fun searchBooks(query: String) = viewModelScope.launch {
val response = bookSearchRepository.searchBooks(query, getSortMode(), 1, 15)
if (response.isSuccessful) {
response.body()?.let { body ->
_searchResult.postValue(body)
}
}
}
PagingSource는 크게 Key를 만드는 부분과 PagingSource를 만드는 부분으로 나누어집니다. Key는 읽어올 페이지 번호로 사용되고, 이 Key를 전달하여 받아온 데이터로 PagingSource를 작성하게 됩니다. 그림을 보시면 PagingSource는 Key의 초기값으로 null을 주어 페이지를 만들고, 다음 페이지 요청이 있을때까지 대기합니다. 그러다 다음 페이지 요청이 오면 키에 2를 지정하여 페이지를 만드는 과정을 반복하게 됩니다.
그럼 data/repository 아래에 Retrofit 요청결과를 LoadResult 객체로써 반환하는 BookSearchPagingSource를 정의하겠습니다.
class BookSearchPagingSource(
private val query: String,
private val sort: String,
) : PagingSource<Int, Book>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Book> {
return try {
// 키 값이 없을경우 기본값 사용
val pageNumber = params.key ?: STARTING_PAGE_INDEX
val response = api.searchBooks(query, sort, pageNumber, params.loadSize)
val endOfPaginationReached = response.body()?.meta?.is_end!!
val data = response.body()?.documents!!
val prevKey = if (pageNumber == STARTING_PAGE_INDEX) null else pageNumber - 1
val nextKey = if (endOfPaginationReached) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
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)
}
}
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)
}
}
companion object {
const val STARTING_PAGE_INDEX = 1
}
}
BookSearchPagingSource는 PagingSource를 상속받는데 <>안에는 페이지 타입과, 데이터 타입이 들어갑니다. 페이지는 1, 2..와 같이 정수형으로 관리할 것이기 때문에 Int로 지정하고, 데이터 타입으로는 Book을 지정합니다.
Key의 초기값은 null이기 때문에 companion object 안에 시작페이지를 1로 정해주었습니다.
다음은 load를 구현합니다. load는 Pager가 데이터를 호출할 때마다 불리는 함수입니다. params.key로부터 Key 값을 받아와 pageNumber에 대입하고, 그 값을 Retrofit 서비스에 전달하여 해당하는 데이터를 받아오는 구조를 만듭니다. 이 결과값에 이전페이지와 다음페이지 키 값을 LoadResult.Page에 담아 반환하면 됩니다. 이 때 API가 반환해주는 endOfPaginationReached 값이 true이면 데이터의 끝이므로 nextKey가 null이 되면 되고, 현재 페이지번호가 1이면 첫번째 페이지이기 때문에 prevKey가 null이 되도록 했습니다.
getRefreshKey에서는 여러가지 이유로 페이지를 갱신해야 할 때 수행되는 함수로 가장 최근에 접근한 페이지를 PagingState.anchorPosition으로 하고 그 주위의 페이지를 읽어오도록 키를 반환해 주는 역할을 하게 됩니다.
다음은 리포지토리에서 Pager가 BookSearchPagingSource의 결과를 PagingData로 변환하는 작업을 정의합니다. 우선은 인터페이스를 정의합니다.
interface BookSearchRepository {
...
+ // Paging
+ fun searchBooksPaging(query: String, sort: String): Flow<PagingData<Book>>
}
그리고 searchBooksPaging을 다음과 같이 구현합니다.
class BookSearchRepositoryImpl(
private val db: BookSearchDatabase,
private val dataStore: DataStore<Preferences>
) : BookSearchRepository {
...
+ // Paging
+ override fun searchBooksPaging(query: String, sort: String): Flow<PagingData<Book>> {
+ val pagingSourceFactory = { BookSearchPagingSource(query, sort) }
+
+ return Pager(
+ config = PagingConfig(
+ pageSize = PAGING_SIZE,
+ enablePlaceholders = false,
+ maxSize = PAGING_SIZE * 3
+ ),
+ pagingSourceFactory = pagingSourceFactory
+ ).flow
+ }
}
Room을 변환할 때와 동일하게 PagingConfig를 통해 pageSize와 enablePlaceholder 사용여부, 최대 데이터 크기를 정해줍니다. BookSearchPagingSource의 결과는 pagingSourceFactory를 통해 전달한 뒤 Flow<PagingData<Book>>를 반환하도록 합니다.
다음은 ViewModel에서 페이징 된 데이터를 사용하기 위한 함수를 정의합니다.
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository,
) : ViewModel() {
...
+ // Paging
+ 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
+ }
+ }
+ }
}
searchBooksPaging 응답을 StateFlow로 UI에서 표시하기 위해 우선 _searchPagingResult를 MutableStateFlow 타입으로 준비합니다. 그리고 searchBooksPaging의 결과가 _searchPagingResult를 갱신하게 하되 UI에는 변경 불가능한 searchPagingResult를 공개하도록 구성합니다.
PagingData를 처리할 수 있는 RecyclerView Adapter가 필요한데요, Room의 출력값 표시를 위해 만들었던 PagingDataAdapter를 그대로 사용하면 됩니다.
이제 페이징 된 데이터를 표시하기 위한 준비가 끝났으니 SearchFragment에서 검색 결과를 표시하도록 합니다.
class SearchFragment : Fragment() {
...
- private lateinit var bookSearchAdapter: BookSearchAdapter
+ private lateinit var bookSearchAdapter: BookSearchPagingAdapter
private fun setupRecyclerView() {
- bookSearchAdapter = BookSearchAdapter()
+ bookSearchAdapter = BookSearchPagingAdapter()
...
}
private fun searchBooks() {
var startTime = System.currentTimeMillis()
var endTime: Long
binding.etSearch.text =
Editable.Factory.getInstance().newEditable(bookSearchViewModel.query)
binding.etSearch.addTextChangedListener { text: Editable? ->
endTime = System.currentTimeMillis()
if (endTime - startTime >= SEARCH_BOOKS_TIME_DELAY) {
text?.let {
val query = it.toString().trim()
if (query.isNotEmpty()) {
- bookSearchViewModel.searchBooks(query)
+ bookSearchViewModel.searchBookPaging(query)
bookSearchViewModel.query = query
}
}
}
startTime = endTime
}
}
우선은 기존의 ListAdapter 대신 PagingAdapter를 사용하도록 설정합니다. 그리고 searchBooks 내부에서 searchBooks(query)를 searchBooksPaging(query)로 교체합니다. suspend 함수이므로 코루틴 안에서 실행시키면 됩니다.
다음은 searchResult를 구독하던 구조에서 searchPagingResult를 구독하도록 바꾸면 됩니다. FavoriteFragment에서처럼 확장함수를 만들고 다음과 같이 구현하면 SearchFragment의 결과를 무한히 스크롤 할 수 있게 됩니다.
fun <T> SearchFragment.collectLatestStateFlow(flow: Flow<T>, collect: suspend (T) -> Unit) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
flow.collectLatest(collect)
}
}
}
class SearchFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
- bookSearchViewModel.searchResult.observe(viewLifecycleOwner, Observer { response ->
- response.documents.let {
- bookSearchAdapter.submitList(it)
- }
- })
+ collectLatestStateFlow(bookSearchViewModel.searchPagingResult) {
+ bookSearchAdapter.submitData(it)
+ }
}
}