Wczytywanie i wyświetlanie danych podzielonych na strony (widoki)

Koncepcje i implementacja w Jetpack Compose

Biblioteka Paging zapewnia zaawansowane funkcje wczytywania i wyświetlania danych podzielonych na strony z większego zbioru danych. Z tego przewodnika dowiesz się, jak używać biblioteki Paging do konfigurowania strumienia danych podzielonych na strony ze źródła danych w sieci i wyświetlania ich w RecyclerView.

Określanie źródła danych

Pierwszym krokiem jest zdefiniowanie implementacji PagingSource, aby zidentyfikować źródło danych. Klasa API PagingSource zawiera metodę load, którą zastępujesz, aby wskazać, jak pobierać dane podzielone na strony z odpowiedniego źródła danych.

Aby używać korutyn Kotlin do asynchronicznego wczytywania, użyj bezpośrednio klasy PagingSource. Biblioteka Paging udostępnia też klasy obsługujące inne platformy asynchroniczne:

Wybieranie typów kluczy i wartości

PagingSource<Key, Value> ma 2 parametry typu: Key i Value. Klucz określa identyfikator używany do wczytywania danych, a wartość to typ samych danych. Jeśli na przykład wczytujesz strony obiektów User z sieci przekazując do Retrofit numery stron Int, wybierz Int jako typ Key i User jako typ Value.

Określanie PagingSource

W tym przykładzie zaimplementowano PagingSource, który wczytuje strony elementów według numeru strony. Typ Key to Int, a typ Value to User.

Java (RxJava)

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 (Guava/LiveData)

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;
  }
}

Typowa implementacja PagingSource przekazuje parametry podane w konstruktorze do metody load, aby wczytać odpowiednie dane dla zapytania. W powyższym przykładzie te parametry to:

  • backend: instancja usługi backendu, która udostępnia dane.
  • query: zapytanie wyszukiwania, które ma zostać wysłane do usługi wskazanej przez backend.

Obiekt LoadParams zawiera informacje o operacji wczytywania, która ma zostać wykonana. Obejmuje to klucz do wczytania i liczbę elementów do wczytania.

Obiekt LoadResult zawiera wynik operacji wczytywania. LoadResult to klasa zapieczętowana, która przyjmuje jedną z 2 postaci w zależności od tego, czy wywołanie load zakończyło się powodzeniem:

  • Jeśli wczytywanie się powiodło, zwróć obiekt LoadResult.Page.
  • Jeśli wczytywanie się nie powiodło, zwróć obiekt LoadResult.Error.

Ilustracja poniżej pokazuje, jak funkcja load w tym przykładzie otrzymuje klucz dla każdego wczytywania i udostępnia klucz do kolejnego wczytywania.

Przy każdym wywołaniu funkcji load klasa ExamplePagingSource pobiera bieżący klucz i zwraca następny klucz do wczytania.
Rysunek 1. Diagram pokazujący, jak load używa i aktualizuje klucz.

Implementacja PagingSource musi też implementować metodę getRefreshKey , która przyjmuje jako parametr obiekt PagingState. Zwraca ona klucz, który ma zostać przekazany do metody load, gdy dane zostaną odświeżone lub unieważnione po początkowym wczytaniu. Biblioteka Paging automatycznie wywołuje tę metodę podczas kolejnych odświeżeń danych.

Obsługiwanie błędów

Żądania wczytania danych mogą się nie powieść z wielu powodów, zwłaszcza podczas wczytywania przez sieć. Zgłaszaj błędy napotkane podczas wczytywania, zwracając obiekt LoadResult.Error z metody load.

Możesz na przykład przechwytywać i zgłaszać błędy wczytywania w ExamplePagingSource z poprzedniego przykładu, dodając do metody load te elementy:

Java (RxJava)

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

Java (Guava/LiveData)

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

Więcej informacji o obsłudze błędów Retrofit znajdziesz w przykładach w dokumentacji API PagingSource.

PagingSource zbiera i dostarcza obiekty LoadResult.Error do interfejsu, aby można było na nie reagować. Więcej informacji o udostępnianiu stanu wczytywania w interfejsie znajdziesz w artykule Zarządzanie stanami wczytywania i ich prezentowanie.

Konfigurowanie strumienia PagingData

Następnie potrzebujesz strumienia danych podzielonych na strony z implementacji PagingSource. Skonfiguruj strumień danych w ViewModel. Klasa Pager udostępnia metody, które udostępniają reaktywny strumień obiektów PagingData z PagingSource. Biblioteka Paging obsługuje używanie kilku typów strumieni, w tym Flow, LiveData, oraz typów Flowable i Observable z RxJava.

Gdy tworzysz instancję Pager, aby skonfigurować strumień reaktywny, musisz podać instancji obiekt konfiguracji PagingConfig i funkcję, która informuje Pager, jak uzyskać instancję implementacji PagingSource:

Java (RxJava)

// 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 (Guava/LiveData)

// 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);

Operator cachedIn sprawia, że strumień danych jest udostępniany, i buforuje wczytane dane w podanym CoroutineScope. Ten przykład używa viewModelScope udostępnianego przez artefakt cyklu życia lifecycle-viewmodel-ktx.

Obiekt Pager wywołuje metodę load z obiektu PagingSource, przekazując do niej obiekt LoadParams i otrzymując w zamian LoadResult obiekt.

Określanie adaptera RecyclerView

Musisz też skonfigurować adapter, aby odbierać dane na liście RecyclerView. Biblioteka Paging udostępnia do tego celu klasę PagingDataAdapter.

Zdefiniuj klasę, która rozszerza PagingDataAdapter. W tym przykładzie UserAdapter rozszerza PagingDataAdapter, aby udostępnić adapter RecyclerView dla elementów listy typu User i używać UserViewHolder jako uchwytu widoku:

Kotlin (korutyny)

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

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 (Guava/LiveData)

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);
  }
}

Adapter musi też definiować metody onCreateViewHolder i onBindViewHolder oraz określać DiffUtil.ItemCallback. Działa to tak samo jak zwykle podczas definiowania adapterów list RecyclerView:

Kotlin (korutyny)

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

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 (Guava/LiveData)

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);
  }
}

Wyświetlanie danych podzielonych na strony w interfejsie

Teraz, gdy masz już zdefiniowany PagingSource, utworzony sposób generowania przez aplikację strumienia PagingData i zdefiniowany PagingDataAdapter, możesz połączyć te elementy i wyświetlać dane podzielone na strony w aktywności.

Wykonaj te czynności w metodzie onCreate aktywności lub metodzie onViewCreated fragmentu:

  1. Utwórz instancję klasy PagingDataAdapter.
  2. Przekaż instancję PagingDataAdapter do listy RecyclerView, na której chcesz wyświetlać dane podzielone na strony.
  3. Obserwuj strumień PagingData i przekazuj każdą wygenerowaną wartość do metody submitData() adaptera.

Kotlin (korutyny)

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

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 (Guava/LiveData)

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

Lista RecyclerView wyświetla teraz dane podzielone na strony ze źródła danych i automatycznie wczytuje kolejną stronę, gdy jest to konieczne.