Page du réseau et de la base de données

Améliorez l'expérience utilisateur en vous assurant que votre application peut être utilisée si les connexions réseau ne sont pas fiables ou que l'utilisateur est hors connexion. Pour ce faire, vous pouvez par exemple procéder en même temps à un appel à partir du réseau et d'une base de données locale. Votre application gère ainsi l'interface utilisateur à partir d'un cache de base de données local et n'envoie des requêtes au réseau que lorsque la base de données ne contient plus de données.

Ce guide suppose que vous connaissez déjà la bibliothèque de persistance Room et l'utilisation de base de la bibliothèque Paging.

Coordonner les chargements de données

La bibliothèque Paging fournit le composant RemoteMediator pour ce cas d'utilisation. RemoteMediator agit comme un signal de la bibliothèque Paging lorsque l'application est à court de données mises en cache. Vous pouvez utiliser ce signal pour charger des données supplémentaires à partir du réseau et les stocker dans la base de données locale, où un élément PagingSource peut les charger et les fournir à l'interface utilisateur à afficher.

Lorsque des données supplémentaires sont nécessaires, la bibliothèque Paging appelle la méthode load() à partir de l'intégration RemoteMediator. Il s'agit d'une fonction de suspension, afin que des tâches de longue durée puissent être exécutées sans risque. Cette fonction extrait généralement les nouvelles données à partir d'une source réseau et les enregistre dans un espace de stockage local.

Ce processus fonctionne avec les nouvelles données, mais au fil du temps, les données stockées dans la base de données nécessitent une invalidation, par exemple lorsque l'utilisateur déclenche manuellement une actualisation. Il est représenté par la propriété LoadType transmise à la méthode load(). LoadType indique à RemoteMediator s'il doit actualiser les données existantes ou extraire des données supplémentaires à ajouter en début/fin de la liste existante.

RemoteMediator s'assure ainsi que votre application charge les données que les utilisateurs souhaitent voir dans l'ordre approprié.

Cycle de vie de Paging

Figure 1 : Schéma du cycle de vie de Paging avec PagingSource et PagingData.

Lorsque la pagination s'effectue directement à partir du réseau, PagingSource charge les données et renvoie un objet LoadResult. L'intégration de PagingSource est transmise à Pager via le paramètre pagingSourceFactory.

Comme de nouvelles données sont requises par l'interface utilisateur, Pager appelle la méthode load() à partir de PagingSource et renvoie un flux de PagingData qui encapsulent les nouvelles données. Chaque objet PagingData est généralement mis en cache dans ViewModel avant d'être envoyé à l'interface utilisateur pour être affiché.

Figure 2 : Schéma du cycle de vie de Paging avec PagingSource et RemoteMediator.

RemoteMediator modifie ce flux de données. Un élément PagingSource charge toujours les données, mais lorsque les données paginées sont épuisées, la bibliothèque Paging déclenche l'action RemoteMediator pour charger de nouvelles données à partir de la source réseau. RemoteMediator stocke les nouvelles données dans la base de données locale. Un cache en mémoire dans ViewModel n'est donc pas nécessaire. Enfin, PagingSource s'annule automatiquement et Pager crée une instance pour charger les nouvelles données de la base de données.

Utilisation de base

Supposons que vous souhaitiez que votre application charge des pages d'éléments User provenant d'une source de données réseau associée à un élément dans un cache local stocké dans une base de données Room.

RemoteMediator charge les données du réseau dans la base de données, tandis que PagingSource les charge à partir de la base de données. Pager utilise à la fois RemoteMediator et PagingSource pour charger des données paginées.
Figure 3 : Schéma d'une intégration Paging utilisant une source de données en couches.

Une intégration RemoteMediator permet de charger des données paginées du réseau dans la base de données, mais pas directement dans l'interface utilisateur. L'application utilise plutôt la base de données comme source de fiabilité. En d'autres termes, l'application n'affiche que les données mises en cache dans la base de données. Une intégration PagingSource (générée par exemple par Room) gère le chargement des données mises en cache à partir de la base de données dans l'interface utilisateur.

Créer des entités Room

La première étape consiste à définir une base de données contenant un cache local de données paginées à partir de la source de données réseau à l'aide de la bibliothèque de persistance Room. Commencez par intégrer RoomDatabase comme décrit dans la section Enregistrer des données dans une base de données locale à l'aide de Room.

Ensuite, définissez une entité Room pour représenter un tableau d'éléments de liste, comme décrit dans Définir des données à l'aide d'entités Room. Attribuez-lui un champ id comme clé primaire, ainsi que tous les champs liés à d'autres informations contenues dans les éléments de votre liste.

Kotlin

@Entity(tableName = "users")
data class User(val id: String, val label: String)

Java

@Entity(tableName = "users")
public class User {
  public String id;
  public String label;
}

Java

@Entity(tableName = "users")
public class User {
  public String id;
  public String label;
}

Vous devez également définir un objet d'accès aux données (DAO) pour cette entité Room, comme décrit dans Accéder aux données à l'aide des DAO de Room. Le DAO de l'entité de l'élément de liste doit inclure les méthodes suivantes :

  • Une méthode insertAll(), qui insère une liste d'éléments dans le tableau.
  • Une méthode qui prend la chaîne de requête comme paramètre et renvoie un objet PagingSource pour la liste des résultats. Un objet Pager peut ainsi utiliser ce tableau comme source de données paginées.
  • Une méthode clearAll(), qui supprime toutes les données du tableau.

Kotlin

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertAll(users: List<User>)

  @Query("SELECT * FROM users WHERE label LIKE :query")
  fun pagingSource(query: String): PagingSource<Int, User>

  @Query("DELETE FROM users")
  suspend fun clearAll()
}

Java

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertAll(List<User> users);

  @Query("SELECT * FROM users WHERE mLabel LIKE :query")
  PagingSource<Integer, User> pagingSource(String query);

  @Query("DELETE FROM users")
  int clearAll();
}

Java

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertAll(List<User> users);

  @Query("SELECT * FROM users WHERE mLabel LIKE :query")
  PagingSource<Integer, User> pagingSource(String query);

  @Query("DELETE FROM users")
  int clearAll();
}

Intégrer un RemoteMediator

Le rôle principal de RemoteMediator consiste à charger plus de données à partir du réseau si Pager est à court de données ou que les données existantes sont invalidées. Il inclut une méthode load() que vous devez ignorer pour définir le comportement de chargement.

Une intégration RemoteMediator type inclut les paramètres suivants :

  • query : chaîne de requête définissant les données à récupérer depuis le service de backend.
  • database : base de données Room servant de cache local.
  • networkService : instance d'API pour le service de backend.

Créez une intégration RemoteMediator<Key, Value>. Les types Key et Value doivent être identiques à ceux que vous définissez si vous indiquez un élément PagingSource sur la même source de données réseau. Pour en savoir plus sur la sélection des paramètres de type, consultez Sélectionner des types de clés et de valeurs.

Kotlin

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    // ...
  }
}

Java

@UseExperimental(markerClass = ExperimentalPagingApi.class)
class ExampleRemoteMediator extends RxRemoteMediator<Integer, User> {
  private String query;
  private ExampleBackendService networkService;
  private RoomDb database;
  private UserDao userDao;

  ExampleRemoteMediator(
    String query,
    ExampleBackendService networkService, RoomDb database
  ) {
    query = query;
    networkService = networkService;
    database = database;
    userDao = database.userDao();
  }

  @NotNull
  @Override
  public Single<MediatorResult> loadSingle(
    @NotNull LoadType loadType,
    @NotNull PagingState<Integer, User> state
  ) {
    ...
  }
}

Java

class ExampleRemoteMediator extends ListenableFutureRemoteMediator<Integer, User> {
  private String query;
  private ExampleBackendService networkService;
  private RoomDb database;
  private UserDao userDao;
  private Executor bgExecutor;

  ExampleRemoteMediator(
    String query,
    ExampleBackendService networkService,
    RoomDb database,
    Executor bgExecutor
  ) {
    this.query = query;
    this.networkService = networkService;
    this.database = database;
    this.userDao = database.userDao();
    this.bgExecutor = bgExecutor;
  }

  @NotNull
  @Override
  public ListenableFuture<MediatorResult> loadFuture(
    @NotNull LoadType loadType,
    @NotNull PagingState<Integer, User> state
  ) {
    ...
  }
}

La méthode load() est chargée de mettre à jour l'ensemble de données de sauvegarde et d'invalider PagingSource. Certaines bibliothèques compatibles avec la pagination (comme Room) gèrent automatiquement l'invalidation des objets PagingSource qu'ils intègrent.

La méthode load() comporte deux paramètres :

  • PagingState, qui contient des informations sur les pages chargées jusque-là, l'index le plus récemment consulté et l'objet PagingConfig que vous avez utilisé pour initialiser le flux de pagination.
  • LoadType, qui indique le type de charge : REFRESH, APPEND ou PREPEND.

La valeur renvoyée par la méthode load() est un objet MediatorResult. MediatorResult peut être MediatorResult.Error (qui inclut la description de l'erreur) ou MediatorResult.Success (qui inclut un signal indiquant s'il y a d'autres données à charger).

La méthode load() doit effectuer les étapes suivantes :

  1. Déterminer la page à charger à partir du réseau selon le type de chargement et des données déjà chargées.
  2. Déclencher la requête réseau.
  3. Effectuer les actions suivantes en fonction du résultat du chargement :
    • Si le chargement réussit et que la liste d'éléments reçue n'est pas vide, stockez les éléments de la liste dans la base de données et renvoyez MediatorResult.Success(endOfPaginationReached = false). Une fois les données stockées, invalidez la source de données pour avertir la bibliothèque Paging des nouvelles données.
    • Si le chargement réussit et que la liste d'éléments reçue est vide ou s'il s'agit du dernier index de page, renvoyez MediatorResult.Success(endOfPaginationReached = true). Une fois les données stockées, invalidez la source de données pour avertir la bibliothèque Paging des nouvelles données.
    • Si la requête génère une erreur, renvoyez MediatorResult.Error.

Kotlin

override suspend fun load(
  loadType: LoadType,
  state: PagingState<Int, User>
): MediatorResult {
  return try {
    // The network load method takes an optional after=<user.id>
    // parameter. For every page after the first, pass the last user
    // ID to let it continue from where it left off. For REFRESH,
    // pass null to load the first page.
    val loadKey = when (loadType) {
      LoadType.REFRESH -> null
      // In this example, you never need to prepend, since REFRESH
      // will always load the first page in the list. Immediately
      // return, reporting end of pagination.
      LoadType.PREPEND ->
        return MediatorResult.Success(endOfPaginationReached = true)
      LoadType.APPEND -> {
        val lastItem = state.lastItemOrNull()

        // You must explicitly check if the last item is null when
        // appending, since passing null to networkService is only
        // valid for initial load. If lastItem is null it means no
        // items were loaded after the initial REFRESH and there are
        // no more items to load.
        if (lastItem == null) {
          return MediatorResult.Success(
            endOfPaginationReached = true
          )
        }

        lastItem.id
      }
    }

    // Suspending network load via Retrofit. This doesn't need to be
    // wrapped in a withContext(Dispatcher.IO) { ... } block since
    // Retrofit's Coroutine CallAdapter dispatches on a worker
    // thread.
    val response = networkService.searchUsers(
      query = query, after = loadKey
    )

    database.withTransaction {
      if (loadType == LoadType.REFRESH) {
        userDao.deleteByQuery(query)
      }

      // Insert new users into database, which invalidates the
      // current PagingData, allowing Paging to present the updates
      // in the DB.
      userDao.insertAll(response.users)
    }

    MediatorResult.Success(
      endOfPaginationReached = response.nextKey == null
    )
  } catch (e: IOException) {
    MediatorResult.Error(e)
  } catch (e: HttpException) {
    MediatorResult.Error(e)
  }
}

Java

@NotNull
@Override
public Single<MediatorResult> loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter. For
  // every page after the first, pass the last user ID to let it continue from
  // where it left off. For REFRESH, pass null to load the first page.
  String loadKey = null;
  switch (loadType) {
    case REFRESH:
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Single.just(new MediatorResult.Success(true));
      }

      loadKey = lastItem.getId();
      break;
  }

  return networkService.searchUsers(query, loadKey)
    .subscribeOn(Schedulers.io())
    .map((Function<SearchUserResponse, MediatorResult>) response -> {
      database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
        }

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter. For
  // every page after the first, pass the last user ID to let it continue from
  // where it left off. For REFRESH, pass null to load the first page.
  String loadKey = null;
  switch (loadType) {
    case REFRESH:
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Futures.immediateFuture(new MediatorResult.Success(true));
      }

      loadKey = lastItem.getId();
      break;
  }

  ListenableFuture<MediatorResult> networkResult = Futures.transform(
    networkService.searchUsers(query, loadKey),
    response -> {
      database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
        }

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    }, bgExecutor);

  ListenableFuture<MediatorResult> ioCatchingNetworkResult =
    Futures.catching(
      networkResult,
      IOException.class,
      MediatorResult.Error::new,
      bgExecutor
    );

  return Futures.catching(
    ioCatchingNetworkResult,
    HttpException.class,
    MediatorResult.Error::new,
    bgExecutor
  );
}

Définir la méthode d'initialisation

Les intégrations RemoteMediator peuvent également ignorer la méthode initialize() pour vérifier si les données mises en cache sont obsolètes et décider de déclencher une actualisation à distance. Cette méthode s'exécute avant tout chargement. Vous pouvez ainsi effectuer des manipulations dans la base de données (par exemple, pour effacer d'anciennes données) avant de déclencher des chargements locaux ou à distance.

Comme initialize() est une fonction asynchrone, vous pouvez charger des données pour déterminer la pertinence des données existantes dans la base de données. Le cas le plus courant est que les données mises en cache ne sont valides que pendant une certaine période. L'élément RemoteMediator peut vérifier si ce délai d'expiration est passé, auquel cas la bibliothèque Paging doit actualiser complètement les données. Les intégrations de l'élément initialize() doivent renvoyer un élément InitializeAction comme suit :

  • Si les données locales doivent être entièrement actualisées, initialize() doit renvoyer InitializeAction.LAUNCH_INITIAL_REFRESH. Cela entraîne l'actualisation à distance des données par RemoteMediator. Tous les chargements à distance de APPEND ou PREPEND attendent que le chargement REFRESH aboutisse avant de continuer.
  • Si les données locales ne doivent pas être actualisées, initialize() doit renvoyer InitializeAction.SKIP_INITIAL_REFRESH. Cela permet à RemoteMediator d'ignorer l'actualisation à distance et de charger les données mises en cache.

Kotlin

override suspend fun initialize(): InitializeAction {
  val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
  return if (System.currentTimeMillis() - db.lastUpdated() <= cacheTimeout)
  {
    // Cached data is up-to-date, so there is no need to re-fetch
    // from the network.
    InitializeAction.SKIP_INITIAL_REFRESH
  } else {
    // Need to refresh cached data from network; returning
    // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's
    // APPEND and PREPEND from running until REFRESH succeeds.
    InitializeAction.LAUNCH_INITIAL_REFRESH
  }
}

Java

@NotNull
@Override
public Single<InitializeAction> initializeSingle() {
  long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS);
  return mUserDao.lastUpdatedSingle()
    .map(lastUpdatedMillis -> {
      if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) {
        // Cached data is up-to-date, so there is no need to re-fetch
        // from the network.
        return InitializeAction.SKIP_INITIAL_REFRESH;
      } else {
        // Need to refresh cached data from network; returning
        // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's
        // APPEND and PREPEND from running until REFRESH succeeds.
        return InitializeAction.LAUNCH_INITIAL_REFRESH;
      }
    });
}

Java

@NotNull
@Override
public ListenableFuture<InitializeAction> initializeFuture() {
  long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS);
  return Futures.transform(
    mUserDao.lastUpdated(),
    lastUpdatedMillis -> {
      if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) {
        // Cached data is up-to-date, so there is no need to re-fetch
        // from the network.
        return InitializeAction.SKIP_INITIAL_REFRESH;
      } else {
        // Need to refresh cached data from network; returning
        // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's
        // APPEND and PREPEND from running until REFRESH succeeds.
        return InitializeAction.LAUNCH_INITIAL_REFRESH;
      }
    },
    mBgExecutor);
}

Créer un Pager

Enfin, vous devez créer une instance Pager pour configurer le flux de données paginées. Cette opération s'apparente à la création d'un Pager à partir d'une source de données réseau simple, mais deux opérations doivent être effectuées différemment :

  • Au lieu de transmettre directement un constructeur PagingSource, vous devez fournir la méthode de requête qui renvoie un objet PagingSource à partir du DAO.
  • Vous devez fournir une instance de votre intégration de RemoteMediator en tant que paramètre remoteMediator.

Kotlin

val userDao = database.userDao()
val pager = Pager(
  config = PagingConfig(pageSize = 50)
  remoteMediator = ExampleRemoteMediator(query, database, networkService)
) {
  userDao.pagingSource(query)
}

Java

UserDao userDao = database.userDao();
Pager<Integer, User> pager = Pager(
  new PagingConfig(/* pageSize = */ 20),
  null, // initialKey,
  new ExampleRemoteMediator(query, database, networkService)
  () -> userDao.pagingSource(query));

Java

UserDao userDao = database.userDao();
Pager<Integer, User> pager = Pager(
  new PagingConfig(/* pageSize = */ 20),
  null, // initialKey
  new ExampleRemoteMediator(query, database, networkService, bgExecutor),
  () -> userDao.pagingSource(query));

Gérer les conditions de concurrence

Au moment de charger des données à partir de plusieurs sources, votre application doit gérer le problème de la désynchronisation des données mises en cache localement avec la source de données à distance.

Lorsque la méthode initialize() de votre intégration RemoteMediator renvoie LAUNCH_INITIAL_REFRESH, les données deviennent obsolètes et doivent être remplacées par de nouvelles. Toutes les requêtes de chargement PREPEND ou APPEND sont forcées d'attendre la fin du chargement à distance de REFRESH. Étant donné que les requêtes PREPEND ou APPEND ont été mises en file d'attente avant la requête REFRESH, il se peut que l'élément PagingState transmis à ces appels de chargement devienne obsolète au moment de l'exécution.

Selon la manière dont les données sont stockées localement, votre application peut ignorer les requêtes redondantes si des modifications aux données mises en cache entraînent l'invalidation et l'extraction de nouvelles données. Par exemple, Room invalide les requêtes pour toute insertion de données. Cela signifie que de nouveaux objets PagingSource avec les données actualisées sont fournis aux requêtes de chargement en attente lorsque de nouvelles données sont insérées dans la base de données.

Il est essentiel de résoudre ce problème de synchronisation des données pour que les utilisateurs voient les données les plus pertinentes et à jour. La solution idéale dépend surtout de la manière dont la source de données réseau interroge les données. Dans tous les cas, les clés à distance vous permettent d'enregistrer des informations sur la page la plus récente demandée par le serveur. Votre application peut utiliser ces informations pour identifier et demander la page de données correcte à charger ensuite.

Gérer les clés à distance

Les clés à distance sont des clés utilisées par une intégration RemoteMediator pour indiquer au service de backend les données à charger ensuite. Dans le scénario le plus simple, chaque élément de données paginée comprend une clé à distance que vous pouvez facilement référencer. Toutefois, si les clés à distance ne correspondent pas à des éléments individuels, vous devez les stocker séparément et les gérer dans votre méthode load().

Cette section explique comment collecter, stocker et mettre à jour les clés à distance qui ne sont pas stockées dans des éléments individuels.

Clés d'élément

Cette section explique comment utiliser les clés à distance correspondant à des éléments individuels. En général, lorsqu'une API est dissociée d'éléments individuels, l'identifiant de l'élément est transmis en tant que paramètre de requête. Le nom du paramètre indique si le serveur doit répondre avec des éléments avant ou après l'identifiant fourni. Dans l'exemple de la classe de modèle User, le champ id du serveur est utilisé comme clé à distance lors de la requête de données supplémentaires.

Si votre méthode load() doit gérer des clés à distance spécifiques à un élément, ces clés sont généralement les identifiants des données extraites du serveur. Les opérations d'actualisation n'ont pas besoin de clé de chargement, car elles ne récupèrent que les données les plus récentes. De même, les opérations Prepend n'ont pas besoin d'extraire de données supplémentaires, car l'actualisation récupère toujours les données les plus récentes du serveur.

Toutefois, les opérations Append requièrent un identifiant. Chargez le dernier élément de la base de données et utilisez son identifiant pour charger la page de données suivante. Si la base de données ne contient aucun élément, endOfPaginationReached est alors défini sur "vrai", ce qui indique que les données doivent être actualisées.

Kotlin

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      // The network load method takes an optional String
      // parameter. For every page after the first, pass the String
      // token returned from the previous page to let it continue
      // from where it left off. For REFRESH, pass null to load the
      // first page.
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        // In this example, you never need to prepend, since REFRESH
        // will always load the first page in the list. Immediately
        // return, reporting end of pagination.
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        // Get the last User object id for the next RemoteKey.
        LoadType.APPEND -> {
          val lastItem = state.lastItemOrNull()

          // You must explicitly check if the last item is null when
          // appending, since passing null to networkService is only
          // valid for initial load. If lastItem is null it means no
          // items were loaded after the initial REFRESH and there are
          // no more items to load.
          if (lastItem == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          lastItem.id
        }
      }

      // Suspending network load via Retrofit. This doesn't need to
      // be wrapped in a withContext(Dispatcher.IO) { ... } block
      // since Retrofit's Coroutine CallAdapter dispatches on a
      // worker thread.
      val response = networkService.searchUsers(query, loadKey)

      // Store loaded data, and next key in transaction, so that
      // they're always consistent.
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query)
        }

        // Insert new users into database, which invalidates the
        // current PagingData, allowing Paging to present the updates
        // in the DB.
        userDao.insertAll(response.users)
      }

      // End of pagination has been reached if no users are returned from the
      // service
      MediatorResult.Success(
        endOfPaginationReached = response.users.isEmpty()
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

Java

@NotNull
@Override
public Single>MediatorResult< loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState>Integer, User< state
) {
  // The network load method takes an optional String parameter. For every page
  // after the first, pass the String token returned from the previous page to
  // let it continue from where it left off. For REFRESH, pass null to load the
  // first page.
  Single>String< remoteKeySingle = null;
  switch (loadType) {
    case REFRESH:
      // Initial load should use null as the page key, so you can return null
      // directly.
      remoteKeySingle = Single.just(null);
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when
      // appending, since passing null to networkService is only
      // valid for initial load. If lastItem is null it means no
      // items were loaded after the initial REFRESH and there are
      // no more items to load.
      if (lastItem == null) {
        return Single.just(new MediatorResult.Success(true));
      }
      remoteKeySingle = Single.just(lastItem.getId());
      break;
  }

  return remoteKeySingle
    .subscribeOn(Schedulers.io())
    .flatMap((Function<String, Single<MediatorResult>>) remoteKey -> {
      return networkService.searchUsers(query, remoteKey)
        .map(response -> {
          database.runInTransaction(() -> {
            if (loadType == LoadType.REFRESH) {
              userDao.deleteByQuery(query);
            }
            // Insert new users into database, which invalidates the current
            // PagingData, allowing Paging to present the updates in the DB.
            userDao.insertAll(response.getUsers());
          });

          return new MediatorResult.Success(response.getUsers().isEmpty());
        });
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter.
  // For every page after the first, pass the last user ID to let it continue
  // from where it left off. For REFRESH, pass null to load the first page.
  ResolvableFuture<String> remoteKeyFuture = ResolvableFuture.create();
  switch (loadType) {
    case REFRESH:
      remoteKeyFuture.set(null);
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Futures.immediateFuture(new MediatorResult.Success(true));
      }

      remoteKeyFuture.set(lastItem.getId());
      break;
  }

  return Futures.transformAsync(remoteKeyFuture, remoteKey -> {

    ListenableFuture<MediatorResult> networkResult = Futures.transform(
      networkService.searchUsers(query, remoteKey),
      response -> {
        database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
        }

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getUsers().isEmpty());
    }, bgExecutor);

    ListenableFuture<MediatorResult> ioCatchingNetworkResult =
      Futures.catching(
        networkResult,
        IOException.class,
        MediatorResult.Error::new,
        bgExecutor
      );

    return Futures.catching(
      ioCatchingNetworkResult,
      HttpException.class,
      MediatorResult.Error::new,
      bgExecutor
    );
  }, bgExecutor);
}

Clés de page

Cette section explique comment utiliser les clés à distance qui ne correspondent pas à des éléments individuels.

Ajouter un tableau de clés à distance

Si les clés à distance ne sont pas directement associées à des éléments de liste, il est préférable de les stocker dans un tableau distinct de la base de données locale. Définissez une entité Room qui représente un tableau de clés à distance :

Kotlin

@Entity(tableName = "remote_keys")
data class RemoteKey(val label: String, val nextKey: String?)

Java

@Entity(tableName = "remote_keys")
public class RemoteKey {
  public String label;
  public String nextKey;
}

Java

@Entity(tableName = "remote_keys")
public class RemoteKey {
  public String label;
  public String nextKey;
}

Définissez également un DAO pour l'entité RemoteKey :

Kotlin

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertOrReplace(remoteKey: RemoteKey)

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  suspend fun remoteKeyByQuery(query: String): RemoteKey

  @Query("DELETE FROM remote_keys WHERE label = :query")
  suspend fun deleteByQuery(query: String)
}

Java

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertOrReplace(RemoteKey remoteKey);

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  Single<RemoteKey> remoteKeyByQuerySingle(String query);

  @Query("DELETE FROM remote_keys WHERE label = :query")
  void deleteByQuery(String query);
}

Java

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertOrReplace(RemoteKey remoteKey);

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  ListenableFuture<RemoteKey> remoteKeyByQueryFuture(String query);

  @Query("DELETE FROM remote_keys WHERE label = :query")
  void deleteByQuery(String query);
}

Charger avec des clés à distance

Si votre méthode load() doit gérer des clés de page à distance, vous devez la définir différemment de l'utilisation de base de RemoteMediator :

  • Incluez une propriété supplémentaire contenant une référence au DAO pour votre tableau de clés à distance.
  • Déterminez la clé à charger ensuite en interrogeant le tableau de clés à distance au lieu d'utiliser PagingState.
  • Insérez ou stockez la clé à distance renvoyée à partir de la source de données du réseau, en plus des données paginées.

Kotlin

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()
  val remoteKeyDao = database.remoteKeyDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      // The network load method takes an optional String
      // parameter. For every page after the first, pass the String
      // token returned from the previous page to let it continue
      // from where it left off. For REFRESH, pass null to load the
      // first page.
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        // In this example, you never need to prepend, since REFRESH
        // will always load the first page in the list. Immediately
        // return, reporting end of pagination.
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        // Query remoteKeyDao for the next RemoteKey.
        LoadType.APPEND -> {
          val remoteKey = database.withTransaction {
            remoteKeyDao.remoteKeyByQuery(query)
          }

          // You must explicitly check if the page key is null when
          // appending, since null is only valid for initial load.
          // If you receive null for APPEND, that means you have
          // reached the end of pagination and there are no more
          // items to load.
          if (remoteKey.nextKey == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          remoteKey.nextKey
        }
      }

      // Suspending network load via Retrofit. This doesn't need to
      // be wrapped in a withContext(Dispatcher.IO) { ... } block
      // since Retrofit's Coroutine CallAdapter dispatches on a
      // worker thread.
      val response = networkService.searchUsers(query, loadKey)

      // Store loaded data, and next key in transaction, so that
      // they're always consistent.
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          remoteKeyDao.deleteByQuery(query)
          userDao.deleteByQuery(query)
        }

        // Update RemoteKey for this query.
        remoteKeyDao.insertOrReplace(
          RemoteKey(query, response.nextKey)
        )

        // Insert new users into database, which invalidates the
        // current PagingData, allowing Paging to present the updates
        // in the DB.
        userDao.insertAll(response.users)
      }

      MediatorResult.Success(
        endOfPaginationReached = response.nextKey == null
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

Java

@NotNull
@Override
public Single<MediatorResult> loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional String parameter. For every page
  // after the first, pass the String token returned from the previous page to
  // let it continue from where it left off. For REFRESH, pass null to load the
  // first page.
  Single<RemoteKey> remoteKeySingle = null;
  switch (loadType) {
    case REFRESH:
      // Initial load should use null as the page key, so you can return null
      // directly.
      remoteKeySingle = Single.just(new RemoteKey(mQuery, null));
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      // Query remoteKeyDao for the next RemoteKey.
      remoteKeySingle = mRemoteKeyDao.remoteKeyByQuerySingle(mQuery);
      break;
  }

  return remoteKeySingle
    .subscribeOn(Schedulers.io())
    .flatMap((Function<RemoteKey, Single<MediatorResult>>) remoteKey -> {
      // You must explicitly check if the page key is null when appending,
      // since null is only valid for initial load. If you receive null
      // for APPEND, that means you have reached the end of pagination and
      // there are no more items to load.
      if (loadType != REFRESH && remoteKey.getNextKey() == null) {
        return Single.just(new MediatorResult.Success(true));
      }

      return networkService.searchUsers(query, remoteKey.getNextKey())
        .map(response -> {
          database.runInTransaction(() -> {
            if (loadType == LoadType.REFRESH) {
              userDao.deleteByQuery(query);
              remoteKeyDao.deleteByQuery(query);
            }

            // Update RemoteKey for this query.
            remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey()));

            // Insert new users into database, which invalidates the current
            // PagingData, allowing Paging to present the updates in the DB.
            userDao.insertAll(response.getUsers());
          });

          return new MediatorResult.Success(response.getNextKey() == null);
        });
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter. For
  // every page after the first, pass the last user ID to let it continue from
  // where it left off. For REFRESH, pass null to load the first page.
  ResolvableFuture<RemoteKey> remoteKeyFuture = ResolvableFuture.create();
  switch (loadType) {
    case REFRESH:
      remoteKeyFuture.set(new RemoteKey(query, null));
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Futures.immediateFuture(new MediatorResult.Success(true));
      }

      // Query remoteKeyDao for the next RemoteKey.
      remoteKeyFuture.setFuture(
        remoteKeyDao.remoteKeyByQueryFuture(query));
      break;
  }

  return Futures.transformAsync(remoteKeyFuture, remoteKey -> {
    // You must explicitly check if the page key is null when appending,
    // since null is only valid for initial load. If you receive null
    // for APPEND, that means you have reached the end of pagination and
    // there are no more items to load.
    if (loadType != LoadType.REFRESH && remoteKey.getNextKey() == null) {
      return Futures.immediateFuture(new MediatorResult.Success(true));
    }

    ListenableFuture<MediatorResult> networkResult = Futures.transform(
      networkService.searchUsers(query, remoteKey.getNextKey()),
      response -> {
        database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
          remoteKeyDao.deleteByQuery(query);
        }

        // Update RemoteKey for this query.
        remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey()));

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    }, bgExecutor);

    ListenableFuture<MediatorResult> ioCatchingNetworkResult =
      Futures.catching(
        networkResult,
        IOException.class,
        MediatorResult.Error::new,
        bgExecutor
      );

    return Futures.catching(
      ioCatchingNetworkResult,
      HttpException.class,
      MediatorResult.Error::new,
      bgExecutor
    );
  }, bgExecutor);
}

Actualiser sur place

Si votre application a uniquement besoin de prendre en charge les actualisations de réseau à partir du haut de la liste, comme dans les exemples précédents, votre élément RemoteMediator n'a pas besoin de définir le comportement de chargement Prepend.

Toutefois, si votre application doit prendre en charge le chargement incrémentiel du réseau dans la base de données locale, vous devez permettre la reprise de la pagination à partir du point d'ancrage, la position de défilement de l'utilisateur. L'intégration de PagingSource de Room gère cela pour vous, mais si vous n'utilisez pas Room, vous pouvez le faire en ignorant PagingSource.getRefreshKey(). Pour obtenir un exemple d'intégration de getRefreshKey(), consultez Définir la PagingSource.

La figure 4 illustre le processus de chargement des données d'abord à partir de la base de données locale, puis à partir du réseau lorsque la base de données est à court de données.

La PagingSource est chargée à partir de la base de données dans l&#39;interface utilisateur jusqu&#39;à ce que la base de données soit à court de données. RemoteMediator charge alors depuis le réseau dans la base de données, puis PagingSource poursuit le chargement.
Figure 4 : Schéma illustrant la coopération entre PagingSource et RemoteMediator pour charger des données.

Ressources supplémentaires

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

Ateliers de programmation

Exemples