Pagina di rete e di database

Migliora l'esperienza utente assicurandoti che la tua app possa essere utilizzata quando le connessioni di rete non sono affidabili o quando l'utente è offline. Un modo per farlo è cercare pagine dalla rete e da un database locale contemporaneamente. In questo modo, l'app utilizza l'interfaccia utente dalla cache di un database locale e invia richieste alla rete solo quando il database non contiene altri dati.

Questa guida presuppone la conoscenza della libreria di persistenza della stanza e dell'utilizzo di base della libreria di paging.

Caricamenti dati di Coordinate

La libreria di paging fornisce il componente RemoteMediator per questo caso d'uso. RemoteMediator funge da indicatore dalla libreria di paging quando l'app ha esaurito i dati memorizzati nella cache. Puoi utilizzare questo indicatore per caricare dati aggiuntivi dalla rete e archiviarli nel database locale, dove un elemento PagingSource può caricarli e fornirli alla UI da visualizzare.

Se sono necessari dati aggiuntivi, la libreria Paging chiama il metodo load() dall'implementazione RemoteMediator. Questa è una funzione di sospensione, quindi è sicuro eseguire lavori di lunga durata. Questa funzione in genere recupera i nuovi dati da un'origine di rete e li salva nello spazio di archiviazione locale.

Questo processo funziona con i nuovi dati, ma nel tempo i dati archiviati nel database richiedono l'annullamento della convalida, ad esempio quando l'utente attiva manualmente un aggiornamento. Ciò è rappresentato dalla proprietà LoadType passata al metodo load(). LoadType indica all'RemoteMediator se è necessario aggiornare i dati esistenti o recuperare dati aggiuntivi da aggiungere o anteporre all'elenco esistente.

In questo modo, RemoteMediator assicura che la tua app carichi nell'ordine appropriato i dati che gli utenti vogliono vedere.

Ciclo di vita delle pagine

Figura 1. Diagramma del ciclo di vita di Paging con PagingSource e PagingData.

Quando si naviga direttamente dalla rete, PagingSource carica i dati e restituisce un oggetto LoadResult. L'implementazione PagingSource viene passata al parametro Pager tramite il parametro pagingSourceFactory.

Poiché l'interfaccia utente richiede nuovi dati, Pager chiama il metodo load() da PagingSource e restituisce un flusso di oggetti PagingData che incapsulano i nuovi dati. In genere, ogni oggetto PagingData viene memorizzato nella cache all'interno di ViewModel prima di essere inviato all'interfaccia utente per la visualizzazione.

Figura 2. Diagramma del ciclo di vita del paging con PagingSource e RemoteMediator.

RemoteMediator modifica questo flusso di dati. Un PagingSource carica comunque i dati; ma quando i dati impaginati sono esauriti, la libreria di paging attiva il RemoteMediator per caricare nuovi dati dall'origine della rete. RemoteMediator archivia i nuovi dati nel database locale, quindi non è necessaria una cache in memoria in ViewModel. Infine, l'elemento PagingSource viene invalidato e l'elemento Pager crea una nuova istanza per caricare i dati aggiornati dal database.

Utilizzo di base

Supponi di volere che la tua app carichi pagine di elementi User da un'origine dati di rete con chiave elemento in una cache locale archiviata in un database Room.

RemoteMediator carica i dati dalla rete nel database e PagingSource carica i dati dal database. Un cercapersone utilizza sia RemoteMediator sia PagingSource per caricare i dati impaginati.
Figura 3. Diagramma di un'implementazione di paging che utilizza un'origine dati a più livelli.

Un'implementazione RemoteMediator consente di caricare nel database i dati impaginati della rete, ma non di caricare i dati direttamente nell'interfaccia utente. L'app utilizza invece il database come fonte attendibile. In altre parole, l'app mostra solo i dati che sono stati memorizzati nella cache del database. Un'implementazione di PagingSource (ad esempio quella generata da una stanza virtuale) gestisce il caricamento dei dati memorizzati nella cache dal database nell'interfaccia utente.

Creare entità stanza

Il primo passaggio consiste nell'utilizzare la libreria di persistenza della stanza per definire un database in cui è memorizzata una cache locale dei dati di impaginazione provenienti dall'origine dati della rete. Inizia con un'implementazione di RoomDatabase come descritto in Salvare i dati in un database locale utilizzando la stanza virtuale.

Successivamente, definisci un'entità Room per rappresentare una tabella di elementi elenco, come descritto in Definire i dati utilizzando le entità Room. Assegna al campo id come chiave primaria e campi per qualsiasi altra informazione contenuta negli elementi dell'elenco.

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

Devi inoltre definire un oggetto di accesso ai dati (DAO) per questa entità Room come descritto in Accesso ai dati utilizzando i DAO delle stanze. Il DAO dell'entità dell'elemento elenco deve includere i seguenti metodi:

  • Un metodo insertAll() che inserisce un elenco di elementi nella tabella.
  • Un metodo che prende la stringa di query come parametro e restituisce un oggetto PagingSource per l'elenco dei risultati. In questo modo, un oggetto Pager può utilizzare questa tabella come origine di dati impaginati.
  • Un metodo clearAll() che elimina tutti i dati della tabella.

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

Implementare un RemoteMediator

Il ruolo principale di RemoteMediator è caricare più dati dalla rete quando Pager esaurisce i dati o i dati esistenti vengono invalidati. Include un metodo load() di cui devi eseguire l'override per definire il comportamento di caricamento.

Una tipica implementazione RemoteMediator include i seguenti parametri:

  • query: una stringa di query che definisce i dati da recuperare dal servizio di backend.
  • database: il database della stanza che funge da cache locale.
  • networkService: un'istanza API per il servizio di backend.

Crea un'implementazione RemoteMediator<Key, Value>. Il tipo Key e il tipo Value devono essere uguali a come lo sarebbero se definissi un PagingSource in base alla stessa origine dati di rete. Per ulteriori informazioni sulla selezione dei parametri di tipo, consulta Selezionare tipi di chiave e valore.

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
  ) {
    ...
  }
}

Il metodo load() è responsabile dell'aggiornamento del set di dati di supporto e dell'annullamento di PagingSource. Alcune librerie che supportano il paging (come Room) gestiscono automaticamente gli oggetti PagingSource non validi implementati.

Il metodo load() accetta due parametri:

  • PagingState, che contiene informazioni sulle pagine caricate finora, sull'indice a cui si è eseguito l'ultimo accesso e sull'oggetto PagingConfig che hai utilizzato per inizializzare il flusso di paging.
  • LoadType, che indica il tipo di carico: REFRESH, APPEND o PREPEND.

Il valore restituito del metodo load() è un oggetto MediatorResult. MediatorResult può essere MediatorResult.Error (che include la descrizione dell'errore) o MediatorResult.Success (che include un indicatore che indica se ci sono o meno dati da caricare).

Il metodo load() deve eseguire i seguenti passaggi:

  1. Stabilisci quale pagina caricare dalla rete in base al tipo di caricamento e ai dati caricati finora.
  2. Attiva la richiesta di rete.
  3. Esegui azioni in base al risultato dell'operazione di caricamento:
    • Se il caricamento ha esito positivo e l'elenco di elementi ricevuto non è vuoto, archivia gli elementi dell'elenco nel database e restituisci MediatorResult.Success(endOfPaginationReached = false). Dopo l'archiviazione dei dati, invalida l'origine dati per notificare i nuovi dati alla libreria Paging.
    • Se il caricamento ha esito positivo e l'elenco di elementi ricevuto è vuoto o è l'ultimo indice di pagina, restituisce MediatorResult.Success(endOfPaginationReached = true). Dopo aver archiviato i dati, annulla la validità dell'origine dati per notificare i nuovi dati alla libreria Paging.
    • Se la richiesta genera un errore, restituisci 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
  );
}

Definisci il metodo di inizializzazione

Le implementazioni RemoteMediator possono anche sostituire il metodo initialize() per verificare se i dati memorizzati nella cache sono obsoleti e decidere se attivare un aggiornamento remoto. Questo metodo viene eseguito prima dell'esecuzione di qualsiasi caricamento, in modo da poter manipolare il database (ad esempio per cancellare i dati precedenti) prima di attivare qualsiasi caricamento locale o remoto.

Poiché initialize() è una funzione asincrona, puoi caricare dati per determinare la pertinenza dei dati esistenti nel database. Il caso più comune è che i dati memorizzati nella cache sono validi solo per un determinato periodo di tempo. RemoteMediator può controllare se questa scadenza è trascorsa, nel qual caso la libreria Paging deve aggiornare completamente i dati. Le implementazioni di initialize() dovrebbero restituire un InitializeAction come segue:

  • Nei casi in cui i dati locali debbano essere completamente aggiornati, initialize() dovrebbe restituire InitializeAction.LAUNCH_INITIAL_REFRESH. Questo fa sì che RemoteMediator esegua un aggiornamento remoto per ricaricare completamente i dati. Prima di procedere, tutti i caricamenti remoti APPEND o PREPEND attendono il completamento del caricamento REFRESH.
  • Nei casi in cui i dati locali non debbano essere aggiornati, initialize() deve restituire InitializeAction.SKIP_INITIAL_REFRESH. In questo modo RemoteMediator salterà l'aggiornamento remoto e caricherà i dati memorizzati nella 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);
}

Crea un cercapersone

Infine, devi creare un'istanza Pager per configurare lo stream di dati impaginati. È simile alla creazione di un Pager da una semplice origine dati di rete, ma ci sono due cose che devi fare in modo diverso:

  • Anziché passare direttamente un costruttore PagingSource, devi fornire il metodo di query che restituisce un oggetto PagingSource dal DAO.
  • Devi fornire un'istanza dell'implementazione RemoteMediator come parametro 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));

Gestire le gare di gara

Una situazione che l'app deve gestire quando carica dati da più origini è quella in cui i dati memorizzati nella cache locale non siano sincronizzati con l'origine dati remota.

Quando il metodo initialize() dell'implementazione RemoteMediator restituisce LAUNCH_INITIAL_REFRESH, i dati sono obsoleti e devono essere sostituiti con dati aggiornati. Eventuali richieste di caricamento PREPEND o APPEND devono attendere il completamento del caricamento remoto REFRESH. Poiché le richieste PREPEND o APPEND sono state in coda prima della richiesta REFRESH, è possibile che il valore PagingState passato a queste chiamate al caricamento non sia aggiornato al momento dell'esecuzione.

A seconda di come i dati sono archiviati localmente, la tua app può ignorare le richieste ridondanti se le modifiche ai dati memorizzati nella cache causano l'annullamento della validità e il recupero di nuovi dati. Ad esempio, Room non rende valide le query relative all'inserimento di dati. Ciò significa che i nuovi oggetti PagingSource con i dati aggiornati vengono forniti alle richieste di caricamento in attesa quando vengono inseriti nuovi dati nel database.

La risoluzione di questo problema di sincronizzazione dei dati è essenziale per garantire che gli utenti vedano i dati più pertinenti e aggiornati. La soluzione migliore dipende principalmente dal modo in cui l'origine dati di rete pagina i dati. In ogni caso, le chiavi remote ti consentono di salvare le informazioni relative alla pagina più recente richiesta dal server. Puoi usare queste informazioni per identificare e richiedere la pagina di dati corretta da caricare in seguito.

Gestisci chiavi remoti

Le chiavi remote sono chiavi utilizzate da un'implementazione RemoteMediator per indicare al servizio di backend i dati da caricare successivamente. Nel caso più semplice, ogni elemento di dati impaginati include una chiave remota a cui puoi facilmente fare riferimento. Tuttavia, se le chiavi remote non corrispondono a singoli elementi, devi archiviarle separatamente e gestirle nel tuo metodo load().

Questa sezione descrive come raccogliere, archiviare e aggiornare le chiavi remote che non sono archiviate in singoli elementi.

Chiavi elemento

Questa sezione descrive come utilizzare i tasti del telecomando che corrispondono a singoli elementi. In genere, quando un'API si basa su singoli elementi, l'ID elemento viene trasmesso come parametro di ricerca. Il nome del parametro indica se il server deve rispondere con elementi prima o dopo l'ID fornito. Nell'esempio della classe del modello User, il campo id del server viene utilizzato come chiave remota quando vengono richiesti dati aggiuntivi.

Quando il metodo load() deve gestire chiavi remote specifiche degli elementi, queste chiavi sono in genere gli ID dei dati recuperati dal server. Le operazioni di aggiornamento non richiedono una chiave di caricamento, perché recuperano solo i dati più recenti. Allo stesso modo, le operazioni di anteposta non richiedono il recupero di dati aggiuntivi, poiché refresh estrae sempre i dati più recenti dal server.

Tuttavia, le operazioni di accodamento richiedono un ID. Ciò richiede di caricare l'ultimo elemento del database e di utilizzarne l'ID per caricare la pagina successiva di dati. Se non ci sono elementi nel database, endOfPaginationReached è impostato su true, a indicare che è necessario un aggiornamento dei dati.

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

Chiavi pagina

Questa sezione descrive come utilizzare i tasti del telecomando che non corrispondono a singoli elementi.

Aggiungi tabella chiavi remota

Quando le chiavi remote non sono associate direttamente agli elementi dell'elenco, è preferibile archiviarle in una tabella separata nel database locale. Definisci un'entità Room che rappresenta una tabella di chiavi remote:

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

Devi anche definire un DAO per 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);
}

Carica con tasti remoti

Quando il metodo load() deve gestire le chiavi delle pagine remote, devi definirlo in modo diverso nei seguenti modi rispetto all'utilizzo di base di RemoteMediator:

  • Includi un'ulteriore proprietà che contiene un riferimento al DAO per la tabella delle chiavi remote.
  • Determina quale chiave caricare successivamente eseguendo una query sulla tabella delle chiavi remote anziché utilizzare PagingState.
  • Inserisci o memorizza la chiave remota restituita dall'origine dati di rete oltre ai dati impaginati stessi.

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

Aggiornamento attivo

Se la tua app deve supportare solo gli aggiornamenti di rete dall'inizio dell'elenco come negli esempi precedenti, non è necessario che l'elemento RemoteMediator definisca il comportamento di caricamento anticipato.

Tuttavia, se la tua app deve supportare il caricamento incrementale dalla rete al database locale, devi fornire supporto per riprendere l'impaginazione a partire dall'ancoraggio, la posizione di scorrimento dell'utente. L'implementazione PagingSource di Room si occupa di questo aspetto, ma se non utilizzi Room, puoi eseguire l'override di PagingSource.getRefreshKey(). Per un'implementazione di esempio di getRefreshKey(), consulta Definire l'origine pagina.

La Figura 4 illustra il processo di caricamento dei dati prima dal database locale e poi dalla rete una volta che i dati del database sono esauriti.

PagingSource viene caricato dal database nell&#39;interfaccia utente fino a quando i dati del database non sono esauriti. Quindi, RemoteMediator esegue il caricamento dalla rete nel database e, in seguito, viene caricato da PagingSource.
Figura 4. Diagramma che mostra l'interazione tra PagingSource e RemoteMediator per caricare i dati.

Risorse aggiuntive

Per saperne di più sulla libreria Paging, consulta le seguenti risorse aggiuntive:

Codelab

Samples