페이징 데이터 로드 및 표시

Paging 라이브러리는 대규모 데이터 세트에서 페이징된 데이터를 로드하고 표시하는 강력한 기능을 제공합니다. 이 가이드에서는 Paging 라이브러리를 사용하여 네트워크 데이터 소스에서 페이징된 데이터의 스트림을 설정하고 RecyclerView에 표시하는 방법을 보여줍니다.

데이터 소스 정의

첫 번째 단계는 데이터 소스를 식별하기 위해 PagingSource 구현을 정의하는 것입니다. PagingSource API 클래스에는 load() 메서드가 포함되어 있으며, 이 메서드는 상응하는 데이터 소스에서 페이징된 데이터를 검색하는 방법을 나타내기 위해 반드시 재정의해야 합니다.

PagingSource 클래스를 직접 사용하여 비동기 로드에 Kotlin 코루틴을 사용합니다. Paging 라이브러리는 다른 비동기 프레임워크를 지원하는 클래스도 제공합니다.

키 및 값 유형 선택

PagingSource<Key, Value>에는 KeyValue의 두 유형 매개변수가 있습니다. 키는 데이터를 로드하는 데 사용되는 식별자를 정의하며, 값은 데이터 자체의 유형입니다. 예를 들어 Int 페이지 번호를 Retrofit에 전달하여 네트워크에서 User 객체의 페이지를 로드하는 경우 Key 유형으로 Int를, Value 유형으로 User를 선택합니다.

PagingSource 정의

다음 예에서는 페이지 번호를 기준으로 항목 페이지를 로드하는 PagingSource를 구현합니다. Key 유형은 Int이고 Value 유형은 User입니다.

Kotlin

class ExamplePagingSource(
    val backend: ExampleBackendService,
    val query: String
) : PagingSource<Int, User>() {
  override suspend fun load(
    params: LoadParams<Int>
  ): LoadResult<Int, User> {
    try {
      // Start refresh at page 1 if undefined.
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber)
      return LoadResult.Page(
        data = response.users,
        prevKey = null, // Only paging forward.
        nextKey = response.nextPageNumber
      )
    } catch (e: Exception) {
      // Handle errors in this block and return LoadResult.Error for
      // expected errors (such as a network failure).
    }
  }

  override fun getRefreshKey(state: PagingState<Int, User>): Int? {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    return state.anchorPosition?.let { anchorPosition ->
      val anchorPage = state.closestPageToPosition(anchorPosition)
      anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
    }
  }
}

Java

class ExamplePagingSource extends RxPagingSource<Integer, User> {
  @NonNull
  private ExampleBackendService mBackend;
  @NonNull
  private String mQuery;

  ExamplePagingSource(@NonNull ExampleBackendService backend,
    @NonNull String query) {
    mBackend = backend;
    mQuery = query;
  }

  @NotNull
  @Override
  public Single<LoadResult<Integer, User>> loadSingle(
    @NotNull LoadParams<Integer> params) {
    // Start refresh at page 1 if undefined.
    Integer nextPageNumber = params.getKey();
    if (nextPageNumber == null) {
      nextPageNumber = 1;
    }

    return mBackend.searchUsers(mQuery, nextPageNumber)
      .subscribeOn(Schedulers.io())
      .map(this::toLoadResult)
      .onErrorReturn(LoadResult.Error::new);
  }

  private LoadResult<Integer, User> toLoadResult(
    @NonNull SearchUserResponse response) {
    return new LoadResult.Page<>(
      response.getUsers(),
      null, // Only paging forward.
      response.getNextPageNumber(),
      LoadResult.Page.COUNT_UNDEFINED,
      LoadResult.Page.COUNT_UNDEFINED);
  }

  @Nullable
  @Override
  public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    Integer anchorPosition = state.getAnchorPosition();
    if (anchorPosition == null) {
      return null;
    }

    LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
    if (anchorPage == null) {
      return null;
    }

    Integer prevKey = anchorPage.getPrevKey();
    if (prevKey != null) {
      return prevKey + 1;
    }

    Integer nextKey = anchorPage.getNextKey();
    if (nextKey != null) {
      return nextKey - 1;
    }

    return null;
  }
}

Java

class ExamplePagingSource extends ListenableFuturePagingSource<Integer, User> {
  @NonNull
  private ExampleBackendService mBackend;
  @NonNull
  private String mQuery;
  @NonNull
  private Executor mBgExecutor;

  ExamplePagingSource(
    @NonNull ExampleBackendService backend,
    @NonNull String query, @NonNull Executor bgExecutor) {
    mBackend = backend;
    mQuery = query;
    mBgExecutor = bgExecutor;
  }

  @NotNull
  @Override
  public ListenableFuture<LoadResult<Integer, User>> loadFuture(@NotNull LoadParams<Integer> params) {
    // Start refresh at page 1 if undefined.
    Integer nextPageNumber = params.getKey();
    if (nextPageNumber == null) {
      nextPageNumber = 1;
    }

    ListenableFuture<LoadResult<Integer, User>> pageFuture =
      Futures.transform(mBackend.searchUsers(mQuery, nextPageNumber),
      this::toLoadResult, mBgExecutor);

    ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture =
      Futures.catching(pageFuture, HttpException.class,
      LoadResult.Error::new, mBgExecutor);

    return Futures.catching(partialLoadResultFuture,
      IOException.class, LoadResult.Error::new, mBgExecutor);
  }

  private LoadResult<Integer, User> toLoadResult(@NonNull SearchUserResponse response) {
    return new LoadResult.Page<>(response.getUsers(),
    null, // Only paging forward.
    response.getNextPageNumber(),
    LoadResult.Page.COUNT_UNDEFINED,
    LoadResult.Page.COUNT_UNDEFINED);
  }

  @Nullable
  @Override
  public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    Integer anchorPosition = state.getAnchorPosition();
    if (anchorPosition == null) {
      return null;
    }

    LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
    if (anchorPage == null) {
      return null;
    }

    Integer prevKey = anchorPage.getPrevKey();
    if (prevKey != null) {
      return prevKey + 1;
    }

    Integer nextKey = anchorPage.getNextKey();
    if (nextKey != null) {
      return nextKey - 1;
    }

    return null;
  }
}

일반적인 PagingSource 구현은 생성자에서 제공된 매개변수를 load() 메서드에 전달하여 쿼리에 적절한 데이터를 로드합니다. 위의 예에서 이러한 매개변수는 다음과 같습니다.

  • backend: 데이터를 제공하는 백엔드 서비스의 인스턴스입니다.
  • query: backend로 표시된 서비스에 전송할 검색어입니다.

LoadParams 객체에는 실행할 로드 작업에 관한 정보가 포함됩니다. 여기에는 로드할 키와 로드할 항목 수가 포함됩니다.

LoadResult 객체에는 로드 작업의 결과가 포함됩니다. LoadResultload() 호출이 성공했는지 여부에 따라 두 가지 형식 중 하나를 취하는 봉인 클래스입니다.

  • 로드에 성공하면 LoadResult.Page 객체를 반환합니다.
  • 로드에 실패하면 LoadResult.Error 객체를 반환합니다.

다음 그림은 이 예의 load() 함수가 각 로드의 키를 수신하고 후속 로드용 키를 제공하는 방법을 보여줍니다.

각 load() 호출에서 ExamplePagingSource는 현재 키를 가져와서 로드할 다음 키를 반환합니다.
그림 1. load()의 키 사용 및 업데이트 방법을 보여 주는 다이어그램

PagingSource 구현은 PagingState 객체를 매개변수로 사용하는 getRefreshKey() 메서드도 반드시 구현해야 합니다. 이 메서드는 데이터가 첫 로드 후 새로고침되거나 무효화되었을 때 키를 반환하여 load() 메서드로 전달합니다. Paging 라이브러리는 다음에 데이터를 새로고침할 때 자동으로 이 메서드를 호출합니다.

오류 처리

데이터 로드 요청은 특히 네트워크를 통해 로드하는 경우 여러 가지 이유로 실패할 수 있습니다. 로드하는 중에 load() 메서드에서 LoadResult.Error 객체를 반환하여 발생한 오류를 보고합니다.

예를 들어 load() 메서드에 다음을 추가하여 이전 예시의 ExamplePagingSource에서 로드 오류를 포착하여 보고할 수 있습니다.

Kotlin

catch (e: IOException) {
  // IOException for network failures.
  return LoadResult.Error(e)
} catch (e: HttpException) {
  // HttpException for any non-2xx HTTP status codes.
  return LoadResult.Error(e)
}

Java

return backend.searchUsers(searchTerm, nextPageNumber)
  .subscribeOn(Schedulers.io())
  .map(this::toLoadResult)
  .onErrorReturn(LoadResult.Error::new);

Java

ListenableFuture<LoadResult<Integer, User>> pageFuture = Futures.transform(
  backend.searchUsers(query, nextPageNumber), this::toLoadResult,
  bgExecutor);

ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture = Futures.catching(
  pageFuture, HttpException.class, LoadResult.Error::new,
  bgExecutor);

return Futures.catching(partialLoadResultFuture,
  IOException.class, LoadResult.Error::new, bgExecutor);

Retrofit 오류 처리에 관한 자세한 내용은 PagingSource API 참조의 샘플을 참고하세요.

PagingSource는 개발자가 조치를 취할 수 있도록 LoadResult.Error 객체를 수집하여 UI에 제공합니다. UI에서 로드 상태를 노출하는 방법에 관한 자세한 내용은 로드 상태 관리 및 표시를 참고하세요.

PagingData 스트림 설정

그런 다음 PagingSource 구현에서 페이징된 데이터의 스트림이 필요합니다. ViewModel에서 데이터 스트림을 설정하세요. Pager 클래스는 PagingSource에서 PagingData 객체의 반응형 스트림을 노출하는 메서드를 제공합니다. Paging 라이브러리는 Flow, LiveData, RxJava의 Flowable 유형과 Observable 유형을 비롯한 여러 스트림 유형을 사용할 수 있도록 지원합니다.

Pager 인스턴스를 만들어 반응형 스트림을 설정할 때는 PagingConfig 구성 객체와 PagingSource 구현 인스턴스를 가져오는 방법을 Pager에 지시하는 함수를 인스턴스에 제공해야 합니다.

Kotlin

val flow = Pager(
  // Configure how data is loaded by passing additional properties to
  // PagingConfig, such as prefetchDistance.
  PagingConfig(pageSize = 20)
) {
  ExamplePagingSource(backend, query)
}.flow
  .cachedIn(viewModelScope)

Java

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
  new PagingConfig(/* pageSize = */ 20),
  () -> ExamplePagingSource(backend, query));

Flowable<PagingData<User>> flowable = PagingRx.getFlowable(pager);
PagingRx.cachedIn(flowable, viewModelScope);

Java

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
  new PagingConfig(/* pageSize = */ 20),
  () -> ExamplePagingSource(backend, query));

PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), viewModelScope);

cachedIn() 연산자는 데이터 스트림을 공유 가능하게 하며 제공된 CoroutineScope을 사용하여 로드된 데이터를 캐시합니다. 이 예에서는 수명 주기 lifecycle-viewmodel-ktx 아티팩트가 제공하는 viewModelScope을 사용합니다.

Pager 객체는 PagingSource 객체에서 load() 메서드를 호출하여 LoadParams 객체를 제공하고 반환되는 LoadResult 객체를 수신합니다.

RecyclerView 어댑터 정의

데이터를 RecyclerView 목록에 수신하는 어댑터도 설정해야 합니다. Paging 라이브러리는 이러한 용도로 PagingDataAdapter 클래스를 제공합니다.

PagingDataAdapter를 확장하는 클래스를 정의합니다. 아래 예에서 UserAdapterPagingDataAdapter를 확장하고 유형 User의 목록 항목에 관해 UserViewHolder뷰 홀더로 사용하여 RecyclerView 어댑터를 제공합니다.

Kotlin

class UserAdapter(diffCallback: DiffUtil.ItemCallback<User>) :
  PagingDataAdapter<User, UserViewHolder>(diffCallback) {
  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ): UserViewHolder {
    return UserViewHolder(parent)
  }

  override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
    val item = getItem(position)
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item)
  }
}

Java

class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
  UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
    super(diffCallback);
  }

  @NonNull
  @Override
  public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    return new UserViewHolder(parent);
  }

  @Override
  public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
    User item = getItem(position);
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item);
  }
}

Java

class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
  UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
    super(diffCallback);
  }

  @NonNull
  @Override
  public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    return new UserViewHolder(parent);
  }

  @Override
  public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
    User item = getItem(position);
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item);
  }
}

또한 어댑터는 onCreateViewHolder()onBindViewHolder() 메서드를 정의하고 DiffUtil.ItemCallback을 지정해야 합니다. RecyclerView 목록 어댑터를 정의할 때의 일반적인 방식으로 동일하게 작동합니다.

Kotlin

object UserComparator : DiffUtil.ItemCallback<User>() {
  override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
    // Id is unique.
    return oldItem.id == newItem.id
  }

  override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
    return oldItem == newItem
  }
}

Java

class UserComparator extends DiffUtil.ItemCallback<User> {
  @Override
  public boolean areItemsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    // Id is unique.
    return oldItem.id.equals(newItem.id);
  }

  @Override
  public boolean areContentsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    return oldItem.equals(newItem);
  }
}

Java

class UserComparator extends DiffUtil.ItemCallback<User> {
  @Override
  public boolean areItemsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    // Id is unique.
    return oldItem.id.equals(newItem.id);
  }

  @Override
  public boolean areContentsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    return oldItem.equals(newItem);
  }
}

UI에 페이징된 데이터 표시

지금까지 PagingSource를 정의하고 앱에서 PagingData 스트림을 생성하는 방법을 만들었으며 PagingDataAdapter를 정의했습니다. 이제 이러한 요소를 함께 연결하여 페이징된 데이터를 활동에 표시할 준비가 되었습니다.

활동의 onCreate 또는 프래그먼트의 onViewCreated 메서드에서 다음 단계를 진행하세요.

  1. PagingDataAdapter 클래스의 인스턴스를 만듭니다.
  2. 페이징된 데이터를 표시할 RecyclerView 목록에 PagingDataAdapter 인스턴스를 전달합니다.
  3. PagingData 스트림을 확인하고 생성된 각 값을 어댑터의 submitData() 메서드에 전달합니다.

Kotlin

val viewModel by viewModels<ExampleViewModel>()

val pagingAdapter = UserAdapter(UserComparator)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter

// Activities can use lifecycleScope directly; fragments use
// viewLifecycleOwner.lifecycleScope.
lifecycleScope.launch {
  viewModel.flow.collectLatest { pagingData ->
    pagingAdapter.submitData(pagingData)
  }
}

Java

ExampleViewModel viewModel = new ViewModelProvider(this)
  .get(ExampleViewModel.class);

UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
  R.id.recycler_view);
recyclerView.adapter = pagingAdapter

viewModel.flowable
  // Using AutoDispose to handle subscription lifecycle.
  // See: https://github.com/uber/AutoDispose.
  .to(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
  .subscribe(pagingData -> pagingAdapter.submitData(lifecycle, pagingData));

Java

ExampleViewModel viewModel = new ViewModelProvider(this)
  .get(ExampleViewModel.class);

UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
  R.id.recycler_view);
recyclerView.adapter = pagingAdapter

// Activities can use getLifecycle() directly; fragments use
// getViewLifecycleOwner().getLifecycle().
viewModel.liveData.observe(this, pagingData ->
  pagingAdapter.submitData(getLifecycle(), pagingData));

이제 RecyclerView 목록에 데이터 소스에서 페이징된 데이터가 표시되고 필요한 경우 자동으로 다른 페이지가 로드됩니다.

추가 리소스

Paging 라이브러리에 관한 자세한 내용은 다음과 같은 추가 리소스를 참고하세요.

Codelab