Paging 라이브러리는 RecyclerView에 데이터를 페이지 단위로 효율적으로 데이터를 로드하고 화면에 출력하도록 도와줍니다. 페이징은 안드로이드 개발에서 자주 사용되지만, 구현하기 귀찮은 효율적인 리스트뷰를 쉽게 구현할 수 있도록 도와줍니다.
보통 리스트뷰를 만들면 보여줄 데이터는 많지만, 화면에 보이는 것은 일부분입니다. 데이터를 모두 로딩해놓고 필요에 따라 보여주면 빠르고 좋지만, 메모리를 많이 사용하게 됩니다. 반면에 필요에 따라서 동적으로 로딩을 하게 되면 메모리를 효율적으로 사용할 수 있습니다.
Paging은 효율적입니다. 필요한 데이터만 로딩하고, 필요한 부분만 UI에 보여줍니다. 처음 리스트뷰가 보여질 때, 일부 아이템들만 로딩합니다. 그리고 사용자가 스크롤하여 더 많은 아이템을 보기 원할 때 추가로 데이터를 로딩하여 보여줍니다. Paging을 사용하기 전에는 모두 직접 구현해야 했지만, Paging에서는 이런 부분들에 대해서 내부적으로 구현되어 있어 추가로 구현할 부분이 없습니다.
리스트뷰에 보여지는 데이터는 Backend(Rest api)에서 가져올 수 있고 또는 로컬(MySQL)에서 가져올 수 있습니다.
또는, Backend + 로컬DB
을 함께 사용할 수 있습니다.
Backend에서 데이터를 받아 로컬에 캐싱하고, 갑작스럽게 액티비티가 종료될 때 로컬로부터 데이터를 로딩할 수 있습니다.
캐싱을 하면 네트워크 비용이 절감될 수 있고 로딩 속도의 이점이 있습니다.
Paging은 LiveData, ViewModel, Room 등과 사용하도록 구현이 되었습니다. 또한 RxJava를 지원하기 때문에 LiveData 대신에 RxJava를 사용할 수도 있습니다.
데이터 흐름
페이징이 데이터를 가져와 RecylcerView에 보여준다는 것은 이해했습니다. 하지만 데이터를 어떻게 가져와 어떻게 출력할까요?
먼저 페이징의 다음 핵심 클래스들을 먼저 알아야 합니다.
- DataSource: 데이터를 로딩하는 객체입니다. 로컬 또는 Backend의 데이터를 가져오는 역할입니다.
- PagedList: DataSource에서 가져온 데이터는 모두 PagedList로 전달됩니다. 데이터 로딩이 필요하면 DataSource를 통해 가져옵니다. 또한, UI에 데이터를 제공하는 역할을 합니다.
- PagedListAdapter: PagedList의 데이터를 RecyclerView에 보여주기 위한 RecyclerView.Adapter입니다.
PagedList는 DataSource를 이용하여 Local또는 Backend로부터 데이터를 가져옵니다. 그 데이터는 PagedListAdapter로 전달되어 RecyclerView에 출력됩니다.
데이터 로딩 방식
DataSource는 로컬 또는 Backend의 데이터를 가져옵니다. 각각의 데이터에 따라서 로딩하는 방법이 다를 수 있습니다.
다음 클래스들은 DataSource의 파생클래스이며 각각 데이터를 로딩하는 방식이 다릅니다.
- PositionalDataSource: 위치기반의 데이터를 로딩하는 DataSource입니다. 셀 수 있는 데이터, 고정된 사이즈의 데이터를 로딩할 때 사용됩니다.
만약 끝을 알 수 없는 무한대의 아이템이라면, ItemKeyedDataSource 또는 PageKeyedDataSource이 적합합니다. Room은 PositionalDataSource 타입의 소스를 제공합니다.
- ItemKeyedDataSource: 키 기반의 아이템을 로딩하는 DataSource입니다.
- PageKeyedDataSource: 페이지 기반의 아이템을 로딩하는 DataSource입니다.
3개의 클래스는 모두 DataSource를 상속합니다. 공통점은 데이터를 가져온다는 것이고, 차이점은 데이터 덩어리를 가져오는 방식이 다르다는 것입니다. 간단히 살펴보겠습니다.
PageKeyedDataSource
페이지-키 기반의 데이터를 가져올 때 사용할 수 있습니다. 만약 로드할 데이터에서 이전 페이지와 다음 페이지의 key를 알 수 있다면 PageKeyedDataSource을 사용할 수 있습니다.
GitHub는 rest api를 제공하고 있고, 아래처럼 페이지 단위로 쿼리를 할 수 있습니다. 페이지 key는 1부터 +1씩 순차적으로 증가합니다.
https://api.github.com/search/repositories?sort=stars&q=android&page=1
PageKeyedDataSource은 아래 3개의 함수를 구현해야 합니다. 이 함수들만 구현해주면 Paging이 데이터가 필요한 순간에 알아서 호출하고 데이터를 가져갑니다.
- loadInitial: PagedList가 처음 데이터를 가져올 때 호출되는 함수입니다.
- loadAfter: 다음 페이지의 데이터를 로딩할 때 호출됩니다.
- loadBefore: 이전 페이지의 데이터를 로딩할 때 호출됩니다.
처음에 loadInitial
이 호출되면 초기 key를 설정하고 이에 대한 데이터를 가져옵니다.
가져온 데이터는 항상 callback.onResult
로 Paging에 넘겨줍니다.
onResult는 previousPageKey와 nextPageKey를 인자로 받습니다.
아래 코드는 PageKeyedDataSource를 구현한 예제입니다.
GitHub는 다음 페이지의 key가 순차적으로 증가하기 숫자이기 때문에 nextPageKey를 curPage + 1
로 전달하였습니다.
이제 다음 페이지의 데이터 로딩이 필요할 때 loadAfter
가 호출됩니다.
loadInitial
에서 설정한 nextPageKey를 인자로 전달받습니다.
이 key로 데이터를 로딩하고 다시 nextPageKey, previousPageKey를 설정합니다.
loadBefore
는 이전 페이지에 대한 데이터를 불러올 때 호출됩니다.
만약 데이터가 init부터 순차적으로, 한방향으로 로딩된다면, loadBefore
는 구현하지 않아도 됩니다.
class RepoDataSource(...) : PageKeyedDataSource<Int, Repo>() {
...
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, Repo>) {
Log.i(TAG, "Initial Loading, count: ${params.requestedLoadSize}")
val curPage = 1
val nextPage = curPage + 1
searchRepos(service, query, curPage, params.requestedLoadSize, { repos ->
callback.onResult(repos, null, nextPage)
}...)
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Repo>) {
Log.i(TAG, "Loading key: ${params.key}, count: ${params.requestedLoadSize}")
searchRepos(service, query, params.key, params.requestedLoadSize, { repos ->
val nextKey = params.key + 1
callback.onResult(repos, nextKey)
}...)
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Repo>) {
...
}
}
위 코드를 실행해보면 loadInitial
에서 설정한 page key가 loadAfter
로 전달된 것을 알 수 있습니다.
2019-01-06 23:16:57.452 6685-6705/? I/RepoDataSource: Initial Loading, count: 50
2019-01-06 23:17:01.878 6685-6712/? I/RepoDataSource: Loading key: 2, count: 50
requestedLoadSize는 50인 것은 PageKeyedDataSource를 생성할 때 옵션을 50으로 설정했기 때문입니다. 아래와 같이 옵션들을 설정할 수 있습니다.
val pagedListConfig = PagedList.Config.Builder()
.setPageSize(50)
.setInitialLoadSizeHint(50) // default: page size * 3
.setPrefetchDistance(10) // default: page size
.setEnablePlaceholders(false) // default: true
.build()
위 예제가 포함된 프로젝트는 GitHub에 있습니다.
ItemKeyedDataSource
Item-key 기반의 데이터를 가져올 때 사용할 수 있습니다.
이 클래스는 PageKeyedDataSource와 거의 비슷합니다.
차이점은 PageKeyedDataSource는 처음 페이지를 가져올 때 다음 또는 이전 페이지의 key값을 정해야합니다.
하지만 ItemKeyedDataSource는 다음에 가져올 key를 지정하지 않습니다.
만약 첫번째 데이터 key가 N
이고 다음에 가져올 데이터의 key가 N+1
과 같은 규칙이라면 ItemKeyedDataSource를 적용할 수 있습니다.
ItemKeyedDataSource는 아래 3개의 함수를 구현해야 합니다.
- loadInitial: 처음 데이터를 가져올 때 호출되는 함수입니다.
- loadAfter: 다음 key의 아이템을 로딩할 때 호출됩니다.
- loadBefore: 이전 key의 아이템을 로딩할 때 호출됩니다.
아래 코드는 이해를 돕기위한 예제입니다.
실제로 Rest api를 구현하지 않았고 getWordItems
가 가상으로 생성한 데이터를 리턴해줍니다.
이 함수는 인자로 전달받은 key부터 size만큼의 데이터를 backend에서 가져오는 경우를 표현하려 하였습니다.
함수가 호출되면 key
, key+1
, ..., key+size
에 대한 쿼리 결과를 List에 담아 리턴해줍니다.
class WordDataSource : ItemKeyedDataSource<Int, Word>() {
....
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Word>) {
Log.i(TAG, "Initial Loading, count: ${params.requestedLoadSize}")
val initKey = 1
val items = getWordItems(initKey, params.requestedLoadSize)
callback.onResult(items)
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Word>) {
Log.i(TAG, "Loading key: ${params.key + 1}, count: ${params.requestedLoadSize}")
val items = getWordItems(params.key + 1, params.requestedLoadSize)
callback.onResult(items)
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Word>) {
}
override fun getKey(item: Word): Int {
return item.getKey()
}
fun getWordItems(key: Int, size: Int): List<Word> {
val list = ArrayList<Word>()
for (i in 0..(size - 1)) {
val itemKey = key + i
list.add(Word("Content of key $itemKey", itemKey))
}
return list
}
}
이 코드를 실행해보면 처음 데이터를 로딩할 때 loadInitial
에서 key가 120인 데이터를 로딩합니다.
그리고 추가로 로딩이 필요한 경우 40인 데이터가 로딩됩니다.
loadAfter
가 호출되고, key가 21loadAfter
의 인자에서 params.key
는 onResult
로 전달된 마지막 데이터의 key입니다.
이 예제에서는 그 다음 key 데이터를 가져오기 위해 +1
하였습니다.
2019-01-06 23:11:43.581 6266-6286/? I/WordDataSource: Initial Loading, count: 20
2019-01-06 23:11:43.717 6266-6286/? I/WordDataSource: Loading key: 21, count: 20
위 예제가 포함된 프로젝트는 GitHub에 있습니다.
PositionalDataSource
이름처럼 위치 기반 데이터에 사용될 수 있습니다. 만약 특정 위치(index)에서 원하는 개수만큼 데이터를 가져올 수 있다면 PositionalDataSource를 적용할 수 있습니다. 참고로, Room과 Paging을 함께 사용할 때 Room은 PositionalDataSource 객체를 제공합니다.
PositionalDataSource는 두개의 함수를 구현해야 합니다.
- loadInitial: 처음 데이터를 가져올 때 호출되는 함수입니다.
- loadRange: 다음 데이터를 가져올 때 호출됩니다.
다음 코드는 이해를 돕기위한 예제입니다.
여기서도 실제 데이터베이스를 사용하지 않고 범위의 데이터를 리턴하는 getWordItems
를 구현하였습니다.
각 함수들은 startPosition
과 loadSize
를 인자로 받습니다. callback.onResult
로 데이터를 리턴해줍니다.
class WordDataSource : PositionalDataSource<Word>() {
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<Word>) {
Log.i(TAG, "Initial Loading, start: ${params.requestedStartPosition}, size: ${params.requestedLoadSize}")
callback.onResult(
getWordItems(params.requestedStartPosition, params.requestedLoadSize), 0)
}
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<Word>) {
Log.i(TAG, "Initial Loading, start: ${params.startPosition}, size: ${params.loadSize}")
callback.onResult(
getWordItems(params.startPosition, params.loadSize))
}
fun getWordItems(startPosition: Int, loadSize: Int): List<Word> {
val list = ArrayList<Word>()
for (i in 0..(loadSize - 1)) {
val itemPos = startPosition + i
list.add(Word("Content position $itemPos", itemPos))
}
return list
}
}
로그를 확인해보면 loadInitial
에서 처음 20개의 데이터를 가져오고, 필요한 경우 loadRange
에서 추가의 데이터를 가져왔습니다.
2019-01-07 21:12:57.209 4862-4881/com.sample.basicsample I/WordDataSource: Initial Loading, start: 0, size: 20
2019-01-07 21:12:57.302 4862-4881/com.sample.basicsample I/WordDataSource: Loading, start: 20, size: 20
2019-01-07 21:12:59.261 4862-4883/com.sample.basicsample I/WordDataSource: Loading, start: 40, size: 20
위 예제가 포함된 프로젝트는 GitHub에 있습니다.
PagedList는 어떻게 생성되나
PagedList와 PagedListAdapter의 관계를 쉽게 설명하면, PagedList는 DataSource를 이용하여 데이터를 가져오고 PagedListAdapter에 전달합니다.
구조적으로 설명하면, PagedListAdapter는 PagedList를 데이터의 스냅샷으로 인식합니다. 스냅샷이 찍혔을 때의 데이터를 화면에 보여줍니다. 만약 데이터베이스에 데이터가 추가되는 경우, PagedList도 새로 생성되어야 합니다. PagedListAdapter는 새로 변경된 PagedList의 데이터를 화면에 출력해줍니다.
이런 PagedList는 어떻게 생성될까요? 먼저 다음 주요 클래스들을 알아야 합니다.
- DataSource.Factory: DataSource를 생성하는 역할을 합니다.
- LivePagedListBuilder: PagedList를 생성하는 빌더입니다. 빌더는 LiveData
로 리턴합니다.
PagedList는 데이터를 로딩하기 위해 내부 필드에 DataSource 객체를 갖고 있습니다. 그렇기 때문에 LivePagedListBuilder가 PagedList를 생성하려면 DataSource.Factory가 필요합니다.
아래 코드를 보시면, LivePagedListBuilder는 DataSource.Factory를 인자로 받고 LiveData
val dataSourceFactory = RepoDataFactory(query, service)
val data: LiveData<PageList<Repo>> = LivePagedListBuilder(dataSourceFactory, pagedListConfig)
.build()
LivePagedListBuilder.java
를 코드를 살펴보면, LivePagedListBuilder는 DataSource.Factory로 DataSource를 생성합니다.
DataSource를 생성한 다음에는 PagedList.Builder로 PagedList를 생성합니다.
public final class LivePagedListBuilder {
public LiveData<PagedList<Value>> build() {
...
mDataSource = dataSourceFactory.create();
mDataSource.addInvalidatedCallback(mCallback);
mList = new PagedList.Builder<>(mDataSource, config)
...
}
}
많은 팩토리와 빌더의 도움으로 DataSource를 갖고 있는 PagedList가 생성되었습니다.
PagedListAdapter는 어떻게 데이터를 전달받나
PagedListAdapter는 PagedList를 데이터의 스냅샷으로 인식합니다. PagedList의 데이터를 RecyclerView에 출력합니다.
초기화 과정에서 PagedList는 DataSource로 아이템들을 로딩합니다. 그 이후에 RecyclerView.Adapter의 notifyItemRangeInserted를 콜백하여 데이터가 추가된 것을 알려줍니다. 그럼 Adapter는 RecyclerView에 데이터를 출력합니다.
사용자가 스크롤하여 맨 마지막 아이템이 보이는 경우, PagedList는 알아서 DataSource로 다음에 보여줄 아이템을 로딩합니다. 아이템이 추가되면 notifyItemRangeInserted가 PagedListAdapter에 데이터 추가를 알려줍니다.
예제로 만든 PagedListAdapter를 상속하는 ReposAdapter입니다.
class ReposAdapter : PagedListAdapter<Repo, RecyclerView.ViewHolder>(REPO_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return RepoViewHolder.create(parent)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val repoItem = getItem(position)
if (repoItem != null) {
(holder as RepoViewHolder).bind(repoItem)
}
}
....
}
adapter.submitList
로 PagedList
viewModel.repos.observe(this, Observer<PagedList<Repo>> {
adapter.submitList(it)
})
adapter.submitList
의 코드를 따라가보면 PagedList는 DataSource로 데이터를 가져오고, PagedListAdapter에 데이터가 추가되었음을 알려줍니다.
Placeholders
Placeholders는 데이터가 로딩되지 않아 화면에 보여지지 않을 때, 가상의 객체를 미리 그리고 데이터 로딩이 완료될 때 실제 데이터를 보여주는 것을 말합니다.
PagedList를 생성할 때, 옵션으로 Placeholders를 사용할지 선택할 수 있습니다.
val pagedListConfig = PagedList.Config.Builder()
.setEnablePlaceholders(true)
.build()
val data = LivePagedListBuilder(dataSourceFactory, pagedListConfig)
Placeholders는 다음과 같은 장점들이 있습니다.
- 빠르게 스크롤 할 수 있다
- 스크롤바 위치가 정확하다
- 스피너 등으로 더 보기 같은 기능을 만들 필요가 없다
스크롤이 끊기지 않기 때문에 사용자가 찾고 싶은 것을 빠르게 찾을 수 있습니다. 그리고 실제 데이터가 화면에 보여져도 스크롤의 위치가 변경되지 않는 장점이 있죠.
Placeholders를 사용하려면 다음과 같은 조건을 충족시켜야 합니다.
- 아이템이 보여지는 View의 크기가 동일해야 한다
- Adapter가 null을 처리해야 한다
- DataSource에서 제공하는 아이템의 개수가 정해져 있어야 한다
Adapter에 null이 들어오기 때문에 null이 왔을 때 UI 등의 처리를 해줘야 합니다. 실제 데이터가 출력될 때 Placeholders의 크기와 다르다면 위치가 조금씩 달라지게 됩니다. 그래서 View의 크기를 동일하게 해야 합니다.
RxJava 지원
Paging은 LiveData뿐만 아니라 RxJava도 지원합니다. LiveData대신에 Observable을 사용하려면 Builder 클래스를 변경해야 합니다.
val data: LiveData<PagedList<Item>> =
LivePagedListBuilder(dataSourceFactory, config)
.build()
RxPagedListBuilder
는 Observable<PagedList>
형식의 객체를 리턴해줍니다.
val data: Observable<PagedList<Item>> =
RxPagedListBuilder(dataSourceFactory, config)
.buildObservable()
정리
Paging에 대해서 간단히 알아보았습니다. 다음 글에서는 Paging을 어떻게 적용해야 하는지 샘플을 만들어보면서 알아보겠습니다.
참고
Related Posts
- Android 14 - 사진/동영상 파일, 일부 접근 권한 소개
- Android - adb push, pull로 파일 복사, 다운로드
- Android 14 - 암시적 인텐트 변경사항 및 문제 해결
- Jetpack Compose - Row와 Column
- Android 13, AOSP 오픈소스 다운로드 및 빌드
- Android 13 - 세분화된 미디어 파일 권한
- Android 13에서 Notification 권한 요청, 알림 띄우기
- Android 13에서 'Access blocked: ComponentInfo' 에러 해결
- 에러 해결: android gradle plugin requires java 11 to run. you are currently using java 1.8.
- 안드로이드 - 코루틴과 Retrofit으로 비동기 통신 예제
- 안드로이드 - 코루틴으로 URL 이미지 불러오기
- Android - 진동, Vibrator, VibrationEffect 예제
- Some problems were found with the configuration of task 에러 수정
- Query method parameters should either be a type that can be converted into a database column or a List
- 우분투에서 Android 12 오픈소스 다운로드 및 빌드
- Android - ViewModel을 생성하는 방법
- Android - Transformations.map(), switchMap() 차이점
- Android - Transformations.distinctUntilChanged() 소개
- Android - TabLayout 구현 방법 (+ ViewPager2)
- Android - 휴대폰 전화번호 가져오는 방법
- Android 12 - Splash Screens 알아보기
- Android 12 - Incremental Install (Play as you Download) 소개
- Android - adb 명령어로 bugreport 로그 파일 추출
- Android - adb 명령어로 App 데이터 삭제
- Android - adb 명령어로 앱 비활성화, 활성화
- Android - adb 명령어로 특정 패키지의 PID 찾기
- Android - adb 명령어로 퍼미션 Grant 또는 Revoke
- Android - adb 명령어로 apk 설치, 삭제
- Android - adb 명령어로 특정 패키지의 프로세스 종료
- Android - adb 명령어로 screen capture 저장
- Android - adb 명령어로 System 앱 삭제, 설치
- Android - adb 명령어로 settings value 확인, 변경
- Android 12 - IntentFilter의 exported 명시적 선언
- Android - adb 명령어로 공장초기화(Factory reset)
- Android - adb logcat 명령어로 로그 출력