Wczytywanie i wyświetlanie danych z podziałem na strony

Biblioteka stronicowania daje zaawansowane możliwości wczytywania i wyświetlania danych z podziałem na strony z większego zbioru danych. W tym przewodniku pokazujemy, jak za pomocą biblioteki stronicowania skonfigurować strumień danych z podziałem na strony ze źródła danych sieciowych i wyświetlać je w elemencie RecyclerView.

Zdefiniuj źródło danych

Pierwszym krokiem jest zdefiniowanie implementacji PagingSource umożliwiającej identyfikację źródła danych. Klasa interfejsu API PagingSource zawiera metodę load(), którą zastępujesz, aby wskazać sposób pobierania danych z podziałem na strony z odpowiedniego źródła danych.

Aby używać współprogramów Kotlin do ładowania asynchronicznego, użyj bezpośrednio klasy PagingSource. Biblioteka stronicowania udostępnia również klasy obsługujące inne platformy asynchroniczne:

Wybierz typy klucza i wartości

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

Zdefiniuj stronę PagingSource

W poniższym przykładzie zaimplementowano komponent PagingSource, który wczytuje strony elementów według numeru strony. Typ Key to Int, a typ Value to 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;
  }
}

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

  • backend: instancja usługi backendu, która dostarcza dane.
  • query: zapytanie, 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 zabezpieczona klasa, która ma jedną z 2 form w zależności od tego, czy wywołanie load() zakończyło się powodzeniem:

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

Na rysunku poniżej widać, jak funkcja load() w tym przykładzie otrzymuje klucz przy każdym obciążeniu i dostarcza klucz do kolejnego wczytywania.

Przy każdym wywołaniu load() element 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 jako parametr przyjmuje obiekt PagingState. Zwraca klucz, aby przekazać do metody load(), gdy dane zostaną odświeżone lub unieważnione po wstępnym wczytaniu. Biblioteka stronicowania wywołuje tę metodę automatycznie przy kolejnych odświeżeniach danych.

Obsługa błędów

Żądania wczytania danych mogą z różnych powodów kończyć się niepowodzeniem, zwłaszcza podczas wczytywania danych przez sieć. Raportuj błędy napotkane podczas wczytywania przez zwrócenie obiektu LoadResult.Error z metody load().

Możesz na przykład wychwycić i zgłosić błędy wczytywania w tabeli ExamplePagingSource z poprzedniego przykładu, dodając do metody load() ten kod:

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

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

PagingSource zbiera i dostarcza obiekty LoadResult.Error do interfejsu, aby umożliwić Ci podjęcie działań na ich podstawie. Więcej informacji o przedstawianiu stanu wczytywania w interfejsie znajdziesz w artykule Zarządzanie stanami wczytywania i ich prezentowanie.

Skonfiguruj strumień danych PagingData

Następnie potrzebujesz strumienia danych z podziałem na strony z implementacji PagingSource. Skonfiguruj strumień danych w urządzeniu ViewModel. Klasa Pager udostępnia metody, które udostępniają reaktywny strumień obiektów PagingData z PagingSource. Biblioteka stronicowania obsługuje kilka typów strumieni, w tym Flow, LiveData oraz Flowable i Observable z RxJava.

Gdy tworzysz instancję Pager, aby skonfigurować strumień reaktywny, musisz udostępnić w niej obiekt konfiguracji PagingConfig oraz funkcję, która poinformuje Pager, jak pobrać instancję swojej implementacji PagingSource:

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

Operator cachedIn() umożliwia udostępnianie strumienia danych i zapisuje wczytane dane w pamięci podręcznej z podanym parametrem CoroutineScope. W tym przykładzie korzystamy z viewModelScope dostarczonego przez artefakt lifecycle-viewmodel-ktx cyklu życia.

Obiekt Pager wywołuje metodę load() z obiektu PagingSource, dostarczając go z obiektem LoadParams, a następnie odbierając obiekt LoadResult.

Definiowanie adaptera RecyclerView

Musisz też skonfigurować adapter, aby przekazywać dane na listę RecyclerView. Do tego celu służy biblioteka stronicowania, która udostępnia klasę PagingDataAdapter.

Zdefiniuj klasę rozszerzającą zakres PagingDataAdapter. W tym przykładzie UserAdapter rozszerza zakres PagingDataAdapter, aby udostępnić adapter RecyclerView dla elementów listy typu User i używać UserViewHolder jako właściciela widoku:

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

Adapter musi też definiować metody onCreateViewHolder() i onBindViewHolder() oraz określać właściwość DiffUtil.ItemCallback. Działa to tak samo jak w przypadku definiowania adapterów list 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);
  }
}

Wyświetlanie danych z podziałem na strony w interfejsie

Po zdefiniowaniu obiektu PagingSource, utworzeniu dla aplikacji sposobu generowania strumienia PagingData i zdefiniowaniu PagingDataAdapter możesz teraz połączyć te elementy i wyświetlać w swojej aktywności dane z podziałem na strony.

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

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

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

Lista RecyclerView wyświetla teraz dane stronowane ze źródła danych i w razie potrzeby automatycznie wczytuje kolejną stronę.

Dodatkowe materiały

Więcej informacji o bibliotece stronicowania znajdziesz w tych dodatkowych materiałach:

Ćwiczenia z programowania

Próbki