HOME > android > jetpack

Android Jetpack - Paging 소개 및 구현 방법

JSFollow30 Dec 2018

Paging 라이브러리는 RecyclerView에 데이터를 페이지 단위로 효율적으로 데이터를 로드하고 화면에 출력하도록 도와줍니다. 페이징은 안드로이드 개발에서 자주 사용되지만, 구현하기 귀찮은 효율적인 리스트뷰를 쉽게 구현할 수 있도록 도와줍니다.

보통 리스트뷰를 만들면 보여줄 데이터는 많지만, 화면에 보이는 것은 일부분입니다. 데이터를 모두 로딩해놓고 필요에 따라 보여주면 빠르고 좋지만, 메모리를 많이 사용하게 됩니다. 반면에 필요에 따라서 동적으로 로딩을 하게 되면 메모리를 효율적으로 사용할 수 있습니다.

android paging

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에 출력됩니다.

android paging

데이터 로딩 방식

DataSource는 로컬 또는 Backend의 데이터를 가져옵니다. 각각의 데이터에 따라서 로딩하는 방법이 다를 수 있습니다.

다음 클래스들은 DataSource의 파생클래스이며 각각 데이터를 로딩하는 방식이 다릅니다.

  • PositionalDataSource: 위치기반의 데이터를 로딩하는 DataSource입니다. 셀 수 있는 데이터, 고정된 사이즈의 데이터를 로딩할 때 사용됩니다. 만약 끝을 알 수 없는 무한대의 아이템이라면, ItemKeyedDataSource 또는 PageKeyedDataSource이 적합합니다. Room은 PositionalDataSource 타입의 소스를 제공합니다.
  • ItemKeyedDataSource: 키 기반의 아이템을 로딩하는 DataSource입니다.
  • PageKeyedDataSource: 페이지 기반의 아이템을 로딩하는 DataSource입니다.

android paging

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가 1~20인 데이터를 로딩합니다. 그리고 추가로 로딩이 필요한 경우 loadAfter가 호출되고, key가 21~40인 데이터가 로딩됩니다. loadAfter의 인자에서 params.keyonResult로 전달된 마지막 데이터의 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를 구현하였습니다. 각 함수들은 startPositionloadSize를 인자로 받습니다. 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를 PagedListAdapter에 전달해줄 수 있습니다.

viewModel.repos.observe(this, Observer<PagedList<Repo>> {
    adapter.submitList(it)
})

adapter.submitList의 코드를 따라가보면 PagedList는 DataSource로 데이터를 가져오고, PagedListAdapter에 데이터가 추가되었음을 알려줍니다.

Placeholders

Placeholders는 데이터가 로딩되지 않아 화면에 보여지지 않을 때, 가상의 객체를 미리 그리고 데이터 로딩이 완료될 때 실제 데이터를 보여주는 것을 말합니다.

android paging

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

RxPagedListBuilderObservable<PagedList>형식의 객체를 리턴해줍니다.

val data: Observable<PagedList<Item>> =
    RxPagedListBuilder(dataSourceFactory, config)
    .buildObservable()

정리

Paging에 대해서 간단히 알아보았습니다. 다음 글에서는 Paging을 어떻게 적용해야 하는지 샘플을 만들어보면서 알아보겠습니다.

참고