Charger et afficher des données paginées

La bibliothèque Paging permet de charger et d'afficher des données paginées à partir d'un ensemble de données plus volumineux. Ce guide explique comment utiliser la bibliothèque Paging pour configurer un flux de données paginées à partir d'une source de données réseau et l'afficher dans un RecyclerView.

Définir une source de données

La première étape consiste à définir une implémentation PagingSource afin d'identifier la source de données. La classe d'API PagingSource inclut la méthode load(), que vous devez ignorer pour indiquer comment extraire les données paginées de la source de données correspondante.

Utilisez directement la classe PagingSource pour utiliser les coroutines Kotlin pour le chargement asynchrone. La bibliothèque Paging fournit également des classes compatibles avec d'autres frameworks asynchrones :

Sélectionner des types de clés et de valeurs

PagingSource<Key, Value> comporte deux paramètres de type : Key et Value. La clé définit l'identifiant utilisé pour charger les données, tandis que la valeur correspond au type des données. Par exemple, si vous chargez des pages d'objets User à partir du réseau en transmettant des numéros de page Int à Retrofit, sélectionnez Int comme type Key et User comme type Value.

Définir la PagingSource

L'exemple suivant implémente une PagingSource qui charge les pages d'éléments par numéro de page. Le type Key est Int et le type Value est 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;
  }
}

Une implémentation PagingSource type transmet les paramètres fournis dans son constructeur à la méthode load() afin de charger les données adéquates pour une requête. Dans l'exemple ci-dessus, ces paramètres sont les suivants :

  • backend : instance du service de backend qui fournit les données.
  • query : requête de recherche à envoyer au service indiqué par backend.

L'objet LoadParams contient des informations sur le chargement à effectuer. Celles-ci incluent la clé et le nombre d'éléments à charger.

L'objet LoadResult contient le résultat du chargement. LoadResult est une classe scellée qui prend l'une des deux formes suivantes, selon que l'appel load() a réussi :

  • Si le chargement aboutit, renvoyez un objet LoadResult.Page.
  • Si le chargement échoue, renvoyez un objet LoadResult.Error.

La figure suivante montre comment la fonction load() de cet exemple reçoit la clé pour chaque chargement et fournit la clé pour le chargement suivant.

À chaque fois que load() est appelé, ExamplePagingSource utilise la clé actuelle et renvoie la clé suivante à charger.
Figure 1. Schéma montrant comment load() utilise et met à jour la clé.

L'implémentation de PagingSource doit également implémenter une méthode getRefreshKey() qui accepte un objet PagingState comme paramètre. Elle renvoie la clé à transmettre à la méthode load() lorsque les données sont actualisées ou invalidées après le chargement initial. La bibliothèque Paging appelle automatiquement cette méthode lors des actualisations ultérieures des données.

Gérer les erreurs

Les requêtes de chargement de données peuvent échouer pour plusieurs raisons, en particulier en cas de chargement sur un réseau. Signalez les erreurs rencontrées lors du chargement en renvoyant un objet LoadResult.Error à partir de la méthode load().

Par exemple, vous pouvez détecter et signaler les erreurs de chargement dans la ExamplePagingSource de l'exemple précédent en ajoutant le code suivant à la méthode load() :

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

Pour en savoir plus sur la gestion des erreurs Retrofit, consultez les exemples fournis dans la documentation de référence de l'API PagingSource.

PagingSource collecte et transmet les objets LoadResult.Error à l'interface utilisateur pour que vous puissiez les exploiter. Pour savoir comment exposer l'état de chargement dans l'interface utilisateur, consultez Gérer et présenter les états de chargement.

Configurer un flux de PagingData

Vous avez ensuite besoin d'un flux de données paginées généré par l'implémentation de PagingSource. Configurez le flux de données dans votre ViewModel. La classe Pager fournit des méthodes qui exposent un flux réactif d'objets PagingData à partir d'une PagingSource. La bibliothèque Paging accepte l'utilisation de plusieurs types de flux, y compris Flow, LiveData et les types Flowable et Observable de RxJava.

Lorsque vous créez une instance Pager pour configurer votre flux réactif, indiquez-lui un objet de configuration PagingConfig et une fonction qui indique au Pager comment obtenir une instance de votre implémentation de 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);

L'opérateur cachedIn() rend le flux de données partageable et met en cache les données chargées avec l'élément CoroutineScope fourni. Cet exemple utilise le viewModelScope fourni par l'artefact lifecycle-viewmodel-ktx de cycle de vie.

L'objet Pager appelle la méthode load() à partir de l'objet PagingSource en lui fournissant l'objet LoadParams et en recevant l'objet LoadResult en retour.

Définir un adaptateur RecyclerView

Vous devez également configurer un adaptateur pour recevoir les données dans votre liste RecyclerView. La bibliothèque Paging fournit la classe PagingDataAdapter à cette fin.

Définissez une classe qui étend PagingDataAdapter. Dans l'exemple, UserAdapter étend PagingDataAdapter afin de fournir un adaptateur RecyclerView pour les éléments de liste de type User utilisant UserViewHolder comme conteneur de vue :

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

Votre adaptateur doit également définir les méthodes onCreateViewHolder() et onBindViewHolder() et préciser un DiffUtil.ItemCallback. Le fonctionnement est le même que lorsque vous définissez des adaptateurs de liste 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);
  }
}

Afficher les données paginées dans votre interface utilisateur

Maintenant que vous avez défini une PagingSource, créé un moyen pour votre application de générer un flux de PagingData et défini un PagingDataAdapter, vous êtes prêt à connecter ces éléments et à afficher des données paginées dans votre activité.

Procédez comme suit dans la méthode onCreate de votre activité ou onViewCreated du fragment :

  1. Créez une instance de votre classe PagingDataAdapter.
  2. Transmettez l'instance PagingDataAdapter à la liste RecyclerView dans laquelle vous souhaitez afficher vos données paginées.
  3. Observez le flux PagingData, puis transmettez chaque valeur générée à la méthode submitData() de votre adaptateur.

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

La liste RecyclerView affiche maintenant les données paginées de la source de données et charge automatiquement une autre page si nécessaire.

Ressources supplémentaires

Pour en savoir plus sur la bibliothèque Paging, consultez ces ressources supplémentaires :

Ateliers de programmation