Seite aus Netzwerk und Datenbank

Verbessern Sie die Nutzerfreundlichkeit, indem Sie dafür sorgen, dass Ihre App auch dann verwendet werden kann, wenn die Netzwerkverbindungen unzuverlässig sind oder der Nutzer offline ist. Eine Möglichkeit besteht darin, gleichzeitig aus dem Netzwerk und aus einer lokalen Datenbank auszusortieren. Auf diese Weise führt Ihre Anwendung die UI aus einem lokalen Datenbankcache aus und stellt nur dann Anfragen an das Netzwerk, wenn sich keine Daten mehr in der Datenbank befinden.

In diesem Leitfaden wird davon ausgegangen, dass Sie mit der Raumpersistenzbibliothek und der grundlegenden Verwendung der Paging-Bibliothek vertraut sind.

Laden von Koordinatendaten

Die Paginierungsbibliothek stellt die Komponente RemoteMediator für diesen Anwendungsfall bereit. RemoteMediator fungiert als Signal von der Paging-Bibliothek, wenn die Anwendung keine im Cache gespeicherten Daten mehr hat. Mit diesem Signal können Sie zusätzliche Daten aus dem Netzwerk laden und in der lokalen Datenbank speichern, wo sie von einem PagingSource geladen und der UI zur Anzeige bereitgestellt werden können.

Wenn zusätzliche Daten benötigt werden, ruft die Paginierungsbibliothek die Methode load() aus der RemoteMediator-Implementierung auf. Dies ist eine Anhaltende Funktion, sodass lang andauernde Arbeiten sicher ausgeführt werden können. Diese Funktion ruft die neuen Daten normalerweise von einer Netzwerkquelle ab und speichert sie im lokalen Speicher.

Dieser Prozess funktioniert mit neuen Daten, aber mit der Zeit müssen die in der Datenbank gespeicherten Daten entwertet werden, z. B. wenn der Nutzer manuell eine Aktualisierung auslöst. Dies wird durch das Attribut LoadType dargestellt, das an die Methode load() übergeben wird. Das LoadType informiert die RemoteMediator darüber, ob sie die vorhandenen Daten aktualisieren oder zusätzliche Daten abrufen muss, die der vorhandenen Liste angehängt oder vorangestellt werden müssen.

Auf diese Weise sorgt RemoteMediator dafür, dass Ihre Anwendung die Daten lädt, die die Nutzer sehen möchten, in der richtigen Reihenfolge.

Paginglebenszyklus

Abbildung 1. Diagramm des Lebenszyklus des Pagings mit PagingSource und PagingData.

Beim Paging direkt über das Netzwerk lädt PagingSource die Daten und gibt ein LoadResult-Objekt zurück. Die Implementierung PagingSource wird über den Parameter pagingSourceFactory an Pager übergeben.

Wenn von der UI neue Daten benötigt werden, ruft der Pager die Methode load() aus PagingSource auf und gibt einen Stream von PagingData-Objekten zurück, in denen die neuen Daten zusammengefasst werden. Jedes PagingData-Objekt wird normalerweise im ViewModel zwischengespeichert, bevor es zur Anzeige an die UI gesendet wird.

Abbildung 2. Diagramm des Lebenszyklus von Paging mit PagingSource und RemoteMediator.

RemoteMediator ändert diesen Datenfluss. Ein PagingSource lädt die Daten weiterhin. Wenn die ausgelagerten Daten jedoch erschöpft sind, veranlasst die Paging-Bibliothek das RemoteMediator, neue Daten aus der Netzwerkquelle zu laden. Der RemoteMediator speichert die neuen Daten in der lokalen Datenbank, sodass kein speicherinterner Cache im ViewModel erforderlich ist. Schließlich wird das PagingSource automatisch ungültig und der Pager erstellt eine neue Instanz, um die neuen Daten aus der Datenbank zu laden.

Grundlegende Verwendung

Angenommen, Sie möchten, dass Ihre Anwendung Seiten mit User Elementen aus einer mit Artikeln gebundenen Netzwerkdatenquelle in einen lokalen Cache lädt, der in einer Raumdatenbank gespeichert ist.

Der RemoteMediator lädt Daten aus dem Netzwerk in die Datenbank und PagingSource Daten aus der Datenbank. Ein Pager verwendet zum Laden von Daten mit Seitenauslagerungen sowohl den RemoteMediator als auch die PagingSource.
Abbildung 3. Diagramm einer Paging-Implementierung mit einer mehrschichtigen Datenquelle.

Mit einer RemoteMediator-Implementierung werden Daten aus Seiten aus dem Netzwerk in die Datenbank geladen. Die Daten werden jedoch nicht direkt in die UI geladen. Stattdessen verwendet die Anwendung die Datenbank als Datenquelle. Mit anderen Worten: Die Anwendung zeigt nur Daten an, die in der Datenbank zwischengespeichert wurden. Eine PagingSource-Implementierung (z. B. eine von Room generierte) übernimmt das Laden von im Cache gespeicherten Daten aus der Datenbank in die UI.

Raumentitäten erstellen

Im ersten Schritt verwenden Sie die Room Persistence Library, um eine Datenbank zu definieren, die einen lokalen Cache mit ausgelagerten Daten aus der Netzwerkdatenquelle enthält. Beginnen Sie mit einer Implementierung von RoomDatabase, wie unter Daten in einer lokalen Datenbank mit Raum speichern beschrieben.

Definieren Sie als Nächstes eine Zimmerentität, um eine Tabelle mit Listenelementen darzustellen, wie unter Daten mithilfe von Zimmerentitäten definieren beschrieben. Weisen Sie ihr ein id-Feld als Primärschlüssel sowie Felder für alle anderen Informationen in Ihren Listenelementen zu.

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

Außerdem müssen Sie ein Datenzugriffsobjekt (Data Access Object, DAO) für diese Zimmerentität definieren, wie unter Mit Raum-DAOs auf Daten zugreifen beschrieben. Die DAO für die Listenelemententität muss die folgenden Methoden enthalten:

  • Eine insertAll()-Methode, mit der eine Liste von Elementen in die Tabelle eingefügt wird.
  • Eine Methode, die den Abfragestring als Parameter verwendet und ein PagingSource-Objekt für die Liste der Ergebnisse zurückgibt. So kann ein Pager-Objekt diese Tabelle als Quelle für ausgelagerte Daten verwenden.
  • Eine clearAll()-Methode, die alle Daten der Tabelle löscht.

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

Remote-Mediator implementieren

Die Hauptaufgabe von RemoteMediator besteht darin, mehr Daten aus dem Netzwerk zu laden, wenn entweder keine Daten mehr im Pager vorhanden sind oder die vorhandenen Daten ungültig werden. Sie enthält die Methode load(), die Sie überschreiben müssen, um das Ladeverhalten zu definieren.

Eine typische RemoteMediator-Implementierung umfasst die folgenden Parameter:

  • query: Ein Abfragestring, der definiert, welche Daten aus dem Back-End-Dienst abgerufen werden sollen.
  • database: Die Raumdatenbank, die als lokaler Cache dient.
  • networkService: Eine API-Instanz für den Back-End-Dienst.

Erstellen Sie eine RemoteMediator<Key, Value>-Implementierung. Die Typen Key und Value sollten mit denen identisch sein, wenn Sie eine PagingSource für dieselbe Netzwerkdatenquelle definieren würden. Weitere Informationen zum Auswählen von Typparametern finden Sie unter Schlüssel- und Werttypen auswählen.

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

Die Methode load() ist für die Aktualisierung des Back-End-Datasets und die Entwertung des PagingSource zuständig. Einige Bibliotheken, die Paging unterstützen (z. B. Room), verarbeiten ungültige PagingSource-Objekte, die sie implementieren, automatisch.

Die Methode load() verwendet zwei Parameter:

Der Rückgabewert der Methode load() ist ein MediatorResult-Objekt. MediatorResult kann entweder MediatorResult.Error (mit Fehlerbeschreibung) oder MediatorResult.Success (mit einem Signal, das angibt, ob weitere Daten zum Laden vorhanden sind) sein.

Die Methode load() muss die folgenden Schritte ausführen:

  1. Bestimmen Sie je nach Ladetyp und den bisher geladenen Daten, welche Seite aus dem Netzwerk geladen werden soll.
  2. Lösen Sie die Netzwerkanfrage aus.
  3. Führen Sie die Aktionen je nach Ergebnis des Ladevorgangs aus:
    • Wenn der Ladevorgang erfolgreich ist und die empfangene Elementliste nicht leer ist, speichern Sie die Listenelemente in der Datenbank und geben Sie MediatorResult.Success(endOfPaginationReached = false) zurück. Nach dem Speichern der Daten müssen Sie die Datenquelle entwerten, um die Paging-Bibliothek über die neuen Daten zu informieren.
    • Wenn das Laden erfolgreich ist und entweder die empfangene Elementliste leer ist oder der letzte Seitenindex vorliegt, geben Sie MediatorResult.Success(endOfPaginationReached = true) zurück. Nach dem Speichern der Daten müssen Sie die Datenquelle entwerten, um die Paging-Bibliothek über die neuen Daten zu informieren.
    • Wenn die Anfrage einen Fehler verursacht, wird MediatorResult.Error zurückgegeben.

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

Initialisierungsmethode definieren

RemoteMediator-Implementierungen können auch die Methode initialize() überschreiben, um zu prüfen, ob im Cache gespeicherte Daten veraltet sind, und um zu entscheiden, ob eine Remote-Aktualisierung ausgelöst werden soll. Diese Methode wird vor dem Laden ausgeführt. So können Sie die Datenbank bearbeiten (z. B. um alte Daten zu löschen), bevor lokale oder Remote-Ladevorgänge ausgelöst werden.

Da es sich bei initialize() um eine asynchrone Funktion handelt, können Sie Daten laden, um die Relevanz der vorhandenen Daten in der Datenbank zu ermitteln. Meistens sind die im Cache gespeicherten Daten nur für einen bestimmten Zeitraum gültig. Der RemoteMediator kann prüfen, ob diese Ablaufzeit abgelaufen ist. In diesem Fall muss die Paginierungsbibliothek die Daten vollständig aktualisieren. Bei Implementierungen von initialize() sollte wie folgt ein InitializeAction zurückgegeben werden:

  • Falls die lokalen Daten vollständig aktualisiert werden müssen, sollte initialize() InitializeAction.LAUNCH_INITIAL_REFRESH zurückgeben. Dadurch führt RemoteMediator eine Remote-Aktualisierung durch, um die Daten vollständig neu zu laden. Alle Remote-Ladevorgänge APPEND oder PREPEND warten, bis der REFRESH-Ladevorgang abgeschlossen ist, bevor er fortgesetzt wird.
  • Falls die lokalen Daten nicht aktualisiert werden müssen, sollte initialize() InitializeAction.SKIP_INITIAL_REFRESH zurückgeben. Dadurch überspringt RemoteMediator die Remote-Aktualisierung und lädt die im Cache gespeicherten Daten.

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

Pager erstellen

Schließlich müssen Sie eine Pager-Instanz erstellen, um den Stream von ausgelagerten Daten einzurichten. Dies ähnelt dem Erstellen einer Pager aus einer einfachen Netzwerkdatenquelle. Dabei müssen Sie jedoch zwei Schritte unterschiedlich ausführen:

  • Anstatt einen PagingSource-Konstruktor direkt zu übergeben, müssen Sie die Abfragemethode angeben, die ein PagingSource-Objekt aus dem DAO zurückgibt.
  • Sie müssen eine Instanz Ihrer RemoteMediator-Implementierung als remoteMediator-Parameter angeben.

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

Race-Bedingungen verarbeiten

Eine Situation, mit der Ihre Anwendung beim Laden von Daten aus mehreren Quellen umgehen muss, tritt auf, wenn lokale im Cache gespeicherte Daten mit der Remote-Datenquelle nicht mehr synchron sind.

Wenn die Methode initialize() Ihrer RemoteMediator-Implementierung LAUNCH_INITIAL_REFRESH zurückgibt, sind die Daten veraltet und müssen durch neue Daten ersetzt werden. Alle PREPEND- oder APPEND-Ladeanfragen müssen warten, bis der Remote-Ladevorgang REFRESH abgeschlossen ist. Da die PREPEND- oder APPEND-Anfragen vor der REFRESH-Anfrage in die Warteschlange gestellt wurden, kann es sein, dass die PagingState-Anfragen, die an diese Ladeaufrufe übergeben wurden, zum Zeitpunkt ihrer Ausführung veraltet sind.

Je nachdem, wie die Daten lokal gespeichert werden, kann Ihre Anwendung redundante Anfragen ignorieren, wenn Änderungen an den im Cache gespeicherten Daten zu einer Entwertung und neuen Datenabrufen führen. Beispiel: „Room“ macht Abfragen für Dateneinfügungen ungültig. Das bedeutet, dass neue PagingSource-Objekte mit den aktualisierten Daten für ausstehende Ladeanfragen bereitgestellt werden, wenn neue Daten in die Datenbank eingefügt werden.

Die Lösung dieses Problems bei der Datensynchronisierung ist wichtig, damit Nutzer die relevantesten und aktuellsten Daten sehen. Die beste Lösung hängt vor allem davon ab, wie die Netzwerkdatenquelle die Daten seitent. Mit Remote-Schlüsseln können Sie in jedem Fall Informationen über die zuletzt vom Server angeforderte Seite speichern. Anhand dieser Informationen kann die Anwendung die richtige Seite mit Daten, die als Nächstes geladen werden soll, ermitteln und anfordern.

Remote-Schlüssel verwalten

Remote-Schlüssel sind Schlüssel, mit denen eine RemoteMediator-Implementierung dem Back-End-Dienst mitteilt, welche Daten als Nächstes geladen werden sollen. Im einfachsten Fall enthält jedes Element mit ausgelagerten Daten einen Remoteschlüssel, auf den Sie einfach verweisen können. Wenn die Remoteschlüssel jedoch nicht einzelnen Elementen entsprechen, müssen Sie sie separat speichern und in der Methode load() verwalten.

In diesem Abschnitt wird beschrieben, wie Sie Remote-Schlüssel erfassen, speichern und aktualisieren, die nicht in einzelnen Elementen gespeichert sind.

Elementschlüssel

In diesem Abschnitt wird beschrieben, wie Sie mit Remoteschlüsseln arbeiten, die einzelnen Elementen entsprechen. Wenn ein API-Schlüssel für einzelne Elemente verwendet wird, wird normalerweise die Element-ID als Abfrageparameter übergeben. Der Parametername gibt an, ob der Server mit Elementen vor oder nach der angegebenen ID antworten soll. Im Beispiel der Modellklasse User wird das Feld id des Servers als Remoteschlüssel verwendet, wenn zusätzliche Daten angefordert werden.

Wenn mit der Methode load() artikelspezifische Remote-Schlüssel verwaltet werden müssen, sind diese Schlüssel in der Regel die IDs der vom Server abgerufenen Daten. Aktualisierungsvorgänge benötigen keinen Ladeschlüssel, da sie nur die neuesten Daten abrufen. Ebenso müssen bei vorangehenden Vorgängen keine zusätzlichen Daten abgerufen werden, da bei der Aktualisierung immer die neuesten Daten vom Server abgerufen werden.

Für Anfügevorgänge ist jedoch eine ID erforderlich. Dazu müssen Sie das letzte Element aus der Datenbank laden und seine ID verwenden, um die nächste Seite mit Daten zu laden. Wenn die Datenbank keine Elemente enthält, wird endOfPaginationReached auf „true“ gesetzt und weist darauf hin, dass eine Datenaktualisierung erforderlich ist.

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

Seitenschlüssel

In diesem Abschnitt wird beschrieben, wie Sie mit Remoteschlüsseln arbeiten, die nicht einzelnen Elementen entsprechen.

Tabelle mit Remote-Schlüsseln hinzufügen

Wenn Remote-Schlüssel nicht direkt mit Listenelementen verknüpft sind, empfiehlt es sich, sie in einer separaten Tabelle in der lokalen Datenbank zu speichern. Definieren Sie eine „Room“-Entität, die eine Tabelle mit Remote-Schlüsseln darstellt:

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

Außerdem müssen Sie einen DAO für die RemoteKey-Entität definieren:

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

Mit Remote-Tasten laden

Wenn die Methode load() Remote-Seitenschlüssel verwalten muss, müssen Sie sie im Vergleich zur grundlegenden Verwendung von RemoteMediator auf folgende Weise anders definieren:

  • Fügen Sie ein zusätzliches Attribut hinzu, das einen Verweis auf die DAO für Ihre Remote-Schlüsseltabelle enthält.
  • Bestimmen Sie, welcher Schlüssel als Nächstes geladen werden soll. Fragen Sie dazu die Remote-Schlüsseltabelle ab, anstatt PagingState zu verwenden.
  • Fügen oder speichern Sie den zurückgegebenen Remote-Schlüssel aus der Netzwerkdatenquelle zusätzlich zu den ausgelagerten Daten selbst.

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

Vor Ort aktualisieren

Wenn Ihre Anwendung wie in den vorherigen Beispielen nur Netzwerkaktualisierungen von ganz oben in der Liste unterstützen muss, muss RemoteMediator kein vorangestelltes Ladeverhalten definieren.

Wenn Ihre Anwendung jedoch das inkrementelle Laden aus dem Netzwerk in die lokale Datenbank unterstützen muss, müssen Sie die Fortsetzung der Paginierung ab dem Anker, der Scrollposition des Nutzers, unterstützen. Die PagingSource-Implementierung von Room übernimmt dies für Sie. Wenn Sie Room jedoch nicht verwenden, können Sie PagingSource.getRefreshKey() überschreiben. Eine Beispielimplementierung von getRefreshKey() finden Sie unter PagingSource definieren.

Abbildung 4 zeigt den Vorgang, bei dem Daten zuerst aus der lokalen Datenbank und dann aus dem Netzwerk geladen werden, sobald die Datenbank keine Daten mehr hat.

Die PagingSource wird aus der Datenbank in die UI geladen, bis die Datenbank keine Daten mehr hat. Anschließend lädt der RemoteMediator aus dem Netzwerk die Datenbank und anschließend die PagingSource weiter.
Abbildung 4: Diagramm, das zeigt, wie PagingSource und RemoteMediator zusammenarbeiten, um Daten zu laden.

Weitere Informationen

Weitere Informationen zur Paging-Bibliothek finden Sie in den folgenden Ressourcen:

Codelabs

Produktproben