안드로이드에서 데이터 로딩 전략은 사용자 경험을 향상시키고, 성능을 최적화하며, 배터리 및 네트워크 자원을 효율적으로 사용하기 위한 중요한 요소이다.
대규모 리스트 데이터를 가져올 때 주로 고려되는 UX패턴은 다음과 같다.
사용자가 '다음', '이전', 페이지 번호와 같은 링크를 사용하여 페이지 간에 이동할 수 있다.
이때 검색결과가 한 번에 한 페이지씩 표시된다.
장점
단점
사용자가 더보기 버튼을 클릭하여 검색결과 목록을 펼칠 수 있다.
장점
단점
사용자가 페이지의 끝까지 스크롤하면 더 많은 콘텐츠가 로드된다.
장점
단점
리스트의 하단에 도달하면 자동으로 추가 데이터를 불러와 표시하는 스크롤 리스너를 추가한다.
(이 글에서는 직접 구현을 다루지 않을 것이므로 간략하게만 적어보았다..)
구글에서 제공하는 페이징 라이브러리인 Paging3
라이브러리를 사용하는 방법이 있다. 해당 라이브러리는 대규모 리스트 데이터를 효율적으로 로드하고 표시할 수 있도록 도와준다.
Android Jectpack의 구성요소로써 공식문서에서는 아래와 같이 설명하고 있다.
Paging 라이브러리를 사용하면 로컬 저장소에서나 네트워크를 통해 대규모 데이터 세트의 데이터 페이지를 로드하고 표시할 수 있습니다. 이 방식을 사용하면 앱에서 네트워크 대역폭과 시스템 리소스를 모두 더 효율적으로 사용할 수 있습니다. Paging 라이브러리의 구성요소는 권장 Android 앱 아키텍처에 맞게 설계되었으며 다른 Jetpack 구성요소와 원활하게 통합되고 최고 수준으로 Kotlin을 지원합니다.
Paging라이브러리를 활용했을 때의 장점은 다음과 같다.
RecyclerView
어댑터가 자동으로 데이터를 요청한다.LiveData
및 RxJava를 최고 수준으로 지원한다.Paging 라이브러리의 흐름은 아래와 같다.
PagingSource
객체는 데이터 소스와 이 소스에서 데이터를 검색하는 방법을 정의한다.
PagingSource
객체는 네트워크 소스 및 로컬 데이터베이스를 포함한 단일 소스에서 데이터를 로드할 수 있다.
사용할 수 있는 다른 페이징 라이브러리 구성요소는 RemoteMediator
이다. RemoteMediator
객체는 로컬 데이터베이스 캐시가 있는 네트워크 데이터 소스와 같은 계층화된 데이터 소스의 페이징을 처리한다.
Pager
구성요소는 PagingSource 객체 및 PagingConfig 구성 객체를 바탕으로 반응형 스트림에 노출되는 PagingData 인스턴스를 구성하기 위한 공개 API를 제공한다.
ViewModel 레이어를 UI에 연결하는 구성요소는 PagingData
이다. PagingData
객체는 페이지로 나눈 데이터의 스냅샷을 보유하는 컨테이너이다. PagingSource
객체를 쿼리하여 결과를 저장한다.
UI 레이어의 기본 페이징 라이브러리 구성요소는 페이지로 나눈 데이터를 처리하는 RecyclerView 어댑터인 PagingDataAdapter
이다.
이제 코드를 통해 더 알아보도록 하자!
class OfferingPagingSource(
private val offeringsRepository: OfferingRepository,
) : PagingSource<Long, Offering>() {
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, Offering> {
val lastOfferingId = params.key
return runCatching {
val offerings =
offeringsRepository.fetchOfferings(
lastOfferingId = lastOfferingId,
pageSize = params.loadSize,
).getOrThrow()
val prevKey = if (lastOfferingId == null) null else lastOfferingId + DEFAULT_PAGE_SIZE
val nextKey =
if (offerings.isEmpty() || offerings.size < DEFAULT_PAGE_SIZE) null else offerings.last().id
LoadResult.Page(
data = offerings,
prevKey = prevKey,
nextKey = nextKey,
)
}.onFailure { throwable ->
LoadResult.Error<Long, Offering>(throwable)
}.getOrThrow()
}
override fun getRefreshKey(state: PagingState<Long, Offering>): Long? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.minus(DEFAULT_PAGE_SIZE)
}
}
companion object {
private const val DEFAULT_PAGE_SIZE = 10
}
}
데이터를 불러오는 방식은 No Offest방식으로 마지막 id를 기준으로 pageSize만큼 데이터를 가져오고 있다.
PagingSource<Key, Value>
는 두 가지 유형 매개변수를 사용한다. Key
는 데이터를 로드하는 데 필요한 식별자, Value
는 실제 데이터 유형을 나타낸다. 현재 코드에서는 Long
타입의 ID를 식별자로, Offering
타입의 데이터를 사용하므로 PagingSource<Long, Offering>()
로 정의되어 있다.
load()
메서드는 PagingSource
의 핵심 메서드로, 데이터를 비동기적으로 로드하여 RecyclerView
에 제공한다. 성공 시 LoadResult.Page
객체를 반환하고, 실패 시 LoadResult.Error
를 반환한다.
params
: 페이징 요청 정보를 담고 있음.key
: 마지막 아이템 ID 또는 특정 키값loadSize
: 한 번에 로드할 데이터 수prevKey
: 이전 페이지를 요청할 때 사용할 키
nextKey
: 다음 페이지를 요청할 때 사용할 키
getRefreshKey()
메서드는 PagingSource
에서 새로고침(refresh) 시 어떤 키를 기준으로 데이터를 다시 로드할지를 결정하는 메서드이다.
사용자가 스크롤을 멈춘 위치에서 데이터 새로고침을 할 때, 해당 위치와 가까운 페이지의 키를 기준으로 새로 데이터를 가져온다.
코드를 설명해보자면 다음과 같다.
state.anchorPosition
anchorPosition
은 사용자가 현재 보고 있는 스크롤 위치의 인덱스이다.closestPageToPosition(anchorPosition)
prevKey
를 기준으로 새로고침prevKey
(이전 페이지 키값)를 기준으로 새로고침할 키값을 계산한다.prevKey?.minus(DEFAULT_PAGE_SIZE)
로 새로고침 기준이 되는 키값을 설정한다.prevKey
에서 페이지 크기만큼 빼서 이전 데이터의 첫 번째 항목부터 다시 로드하게끔 만든다.//viewModel
private val _offerings = MutableLiveData<PagingData<Offering>>()
val offerings: LiveData<PagingData<Offering>> get() = _offerings
fun fetchOfferings() {
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
pagingSourceFactory = {
OfferingPagingSource(
offeringRepository,
)
},
).flow.cachedIn(viewModelScope).collectLatest { pagingData ->
_offerings.value = pagingData
}
}
}
config
: PagingConfig를 통해 페이징 처리에 대한 설정을 정의
pageSize
: 한 페이지에서 로드할 데이터의 개수cachedIn()
: 페이징 데이터의 상태를 제공된 CoroutineScope
을 사용해 로드된 데이터를 캐싱하여, 이미 로드된 데이터를 재사용할 수 있도록 한다.
class OfferingAdapter : PagingDataAdapter<Offering, OfferingViewHolder>(productComparator) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): OfferingViewHolder {
val binding =
ItemOfferingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return OfferingViewHolder(binding)
}
override fun onBindViewHolder(
holder: OfferingViewHolder,
position: Int,
) {
getItem(position)?.let { offering ->
holder.bind(offering)
}
}
companion object {
private val productComparator =
object : DiffUtil.ItemCallback<Offering>() {
override fun areItemsTheSame(
oldItem: Offering,
newItem: Offering,
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: Offering,
newItem: Offering,
): Boolean {
return oldItem == newItem
}
}
}
}
Paging 라이브러리에서 제공하는 PagingDataAdapter
를 확장하여 Adapter를 만들어 준다.
또한 Adpater는 DiffUtil.ItemCallback
을 지정해주어야한다.