Strona z sieci i bazy danych

Zadbaj o wygodę użytkowników, upewniając się, że można jej używać, gdy połączenia sieciowe są zawodne lub gdy użytkownik jest offline. Można to robić na przykład z sieci i z lokalnej bazy danych. Dzięki temu aplikacja kieruje interfejs użytkownika z lokalnej pamięci podręcznej bazy danych i wysyła żądania do sieci tylko wtedy, gdy w bazie danych nie ma już więcej danych.

W tym przewodniku zakładamy, że znasz bibliotekę trwałości sal i znasz podstawowe korzystanie z biblioteki stronicowania.

Koordynuj wczytywanie danych

Biblioteka stronicowania udostępnia komponent RemoteMediator na potrzeby tego przypadku użycia. RemoteMediator działa jako sygnał z biblioteki stronicowania, gdy w aplikacji wyczerpią się dane w pamięci podręcznej. Możesz użyć tego sygnału, aby wczytać dodatkowe dane z sieci i zapisać je w lokalnej bazie danych, gdzie PagingSource może je wczytać i przekazać do interfejsu w celu ich wyświetlenia.

Gdy potrzebne są dodatkowe dane, biblioteka stronicowania wywołuje metodę load() z implementacji RemoteMediator. To funkcja zawieszania, która pozwala bezpiecznie wykonywać długotrwałe zadania. Ta funkcja zwykle pobiera nowe dane ze źródła sieci i zapisuje je w pamięci lokalnej.

Ten proces sprawdza się w przypadku nowych danych, ale z czasem przechowywane w bazie danych wymagają unieważnienia, na przykład gdy użytkownik ręcznie aktywuje odświeżenie. Reprezentuje ją właściwość LoadType przekazywana do metody load(). Parametr LoadType informuje RemoteMediator, czy musi odświeżyć istniejące dane czy pobrać dodatkowe dane, które trzeba dołączyć do dotychczasowej listy.

Dzięki temu RemoteMediator będzie mieć pewność, że aplikacja wczytuje dane, które chcą zobaczyć użytkownicy, w odpowiedniej kolejności.

Cykl życia stron

Rysunek 1. Diagram cyklu życia obsługi stronicowania z użyciem PagingSource i PgingData.

W przypadku stronicowania bezpośrednio z sieci PagingSource wczytuje dane i zwraca obiekt LoadResult. Implementacja PagingSource jest przekazywana do Pager za pomocą parametru pagingSourceFactory.

Interfejs użytkownika wymaga nowych danych, więc Pager wywołuje metodę load() z PagingSource i zwraca strumień obiektów PagingData, które zawierają nowe dane. Każdy obiekt PagingData jest zwykle zapisywany w pamięci podręcznej w ViewModel, zanim zostanie wysłany do interfejsu w celu wyświetlenia.

Rysunek 2. Schemat cyklu życia obsługi stronicowania z użyciem PagingSource i RemoteMediator.

RemoteMediator zmienia ten przepływ danych. Element PagingSource nadal wczytuje dane, ale po wyczerpaniu danych stronicowanych biblioteka stronicowania aktywuje polecenie RemoteMediator, aby wczytać nowe dane ze źródła sieciowego. RemoteMediator przechowuje nowe dane w lokalnej bazie danych, więc pamięć podręczna w pamięci podręcznej nie jest potrzebna w ViewModel. Na koniec PagingSource unieważnia się, a Pager tworzy nową instancję, aby wczytać nowe dane z bazy danych.

Podstawowe użycie

Załóżmy, że chcesz, aby Twoja aplikacja wczytywała strony elementów User ze źródła danych sieciowych z elementem jako kluczem do lokalnej pamięci podręcznej przechowywanej w bazie danych Room.

RemoteMediator wczytuje dane z sieci do bazy danych, a PagingSource wczytuje dane z tej bazy. Do wczytywania danych z stroną stronicowanie korzysta zarówno z funkcji RemoteMediator, jak i PgingSource.
Rysunek 3. Schemat implementacji stronicowania, która korzysta z warstwowego źródła danych.

Implementacja RemoteMediator ułatwia wczytywanie z sieci danych z podziałem na strony do bazy danych, ale nie wczytuje ich bezpośrednio do interfejsu. Zamiast tego aplikacja korzysta z bazy danych jako źródła danych. Innymi słowy, aplikacja wyświetla tylko dane, które zostały zapisane w pamięci podręcznej. Implementacja PagingSource (np. wygenerowana przez Room) obsługuje wczytywanie do interfejsu danych z pamięci podręcznej z bazy danych.

Tworzenie elementów pokoju

Pierwszym krokiem jest zdefiniowanie bazy danych z lokalną pamięcią podręczną stronicowanych na podstawie biblioteki trwałości sal z sieciowego źródła danych. Zacznij od wdrożenia RoomDatabase zgodnie z opisem w sekcji Zapisywanie danych w lokalnej bazie danych przy użyciu pokoju.

Następnie zdefiniuj element Room, który będzie reprezentował tabelę z elementami listy zgodnie z opisem w sekcji Definiowanie danych za pomocą elementów dotyczących sal. Nadaj mu pole id jako klucz podstawowy oraz pola na dowolne inne informacje zawarte w elementach listy.

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

Musisz też zdefiniować obiekt dostępu do danych dla tej encji sala zgodnie z opisem w sekcji Uzyskiwanie dostępu do danych za pomocą DAO dla sal. DAO elementu listy musi obejmować te metody:

  • Metoda insertAll(), która wstawia listę elementów do tabeli.
  • Metoda, która pobiera ciąg zapytania jako parametr i zwraca obiekt PagingSource na liście wyników. Dzięki temu obiekt Pager może użyć tej tabeli jako źródła danych stronicowanych.
  • Metoda clearAll(), która usuwa wszystkie dane z tabeli.

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

Implementacja zdalnego mediatora

Główną rolą funkcji RemoteMediator jest wczytywanie z sieci większej ilości danych, gdy w interfejsie Pager zabraknie danych lub dotychczasowe dane zostaną unieważnione. Zawiera metodę load(), którą musisz zastąpić, by zdefiniować sposób ładowania.

Typowa implementacja RemoteMediator obejmuje te parametry:

  • query: ciąg zapytania określający dane do pobrania z usługi backendu.
  • database: baza danych sal, która pełni rolę lokalnej pamięci podręcznej.
  • networkService: instancja interfejsu API dla usługi backendu.

Utwórz implementację RemoteMediator<Key, Value>. Typ Key i typ Value powinny być takie same jak w przypadku zdefiniowania obiektu PagingSource dla tego samego źródła danych sieci. Więcej informacji o wybieraniu parametrów typów znajdziesz w artykule Wybieranie typu klucza i wartości.

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

Metoda load() odpowiada za aktualizowanie zbioru danych kopii zapasowej i unieważnianie PagingSource. Niektóre biblioteki obsługujące stronicowanie (np. Room) automatycznie obsługują unieważnione implementowane obiekty PagingSource.

Metoda load() przyjmuje 2 parametry:

Wartość zwracana metody load() jest obiektem MediatorResult. MediatorResult może mieć wartość MediatorResult.Error (z opisem błędu) lub MediatorResult.Success (zawierający sygnał informujący, czy jest więcej danych do wczytania).

Metoda load() musi wykonywać te czynności:

  1. Określ, którą stronę wczytać z sieci, w zależności od typu obciążenia i dotychczasowych danych.
  2. Wywołaj żądanie sieciowe.
  3. Wykonaj działania w zależności od wyniku operacji wczytywania:
    • Jeśli wczytanie się uda, a odebrana lista elementów nie jest pusta, zapisz elementy listy w bazie danych i zwróć wartość MediatorResult.Success(endOfPaginationReached = false). Po zapisaniu danych unieważnij źródło danych, aby powiadomić bibliotekę stron o nowych danych.
    • Jeśli wczytywanie się powiodło, a otrzymana lista elementów jest pusta lub jest to ostatni indeks strony, zwróć wartość MediatorResult.Success(endOfPaginationReached = true). Po zapisaniu danych unieważnij źródło danych, aby powiadomić bibliotekę stron o nowych danych.
    • Jeśli żądanie powoduje błąd, zwróć 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
  );
}

Zdefiniuj metodę inicjowania

Implementacje RemoteMediator mogą też zastąpić metodę initialize(), aby sprawdzić, czy dane w pamięci podręcznej są nieaktualne, i zdecydować, czy uruchomić odświeżanie zdalne. Ta metoda jest uruchamiana przed wczytaniem, dzięki czemu możesz wykonywać operacje na bazie danych (np. w celu usunięcia starych danych) przed uruchomieniem jakichkolwiek obciążeń lokalnych lub zdalnych.

initialize() to funkcja asynchroniczna, możesz więc wczytywać dane, aby określić trafność tych informacji znajdujących się w bazie danych. Najczęstszym przypadkiem jest to, że dane z pamięci podręcznej są ważne tylko przez określony czas. RemoteMediator może sprawdzić, czy upłynął czas wygaśnięcia. W takim przypadku biblioteka stronicowania musi w pełni odświeżyć dane. Implementacje funkcji initialize() powinny zwracać błąd InitializeAction w taki sposób:

  • W przypadkach, gdy dane lokalne wymagają pełnego odświeżenia, funkcja initialize() powinna zwrócić wartość InitializeAction.LAUNCH_INITIAL_REFRESH. Powoduje to zdalne odświeżanie RemoteMediator w celu pełnego ponownego załadowania danych. Przed kontynuacją wszystkie operacje zdalnego wczytywania APPEND lub PREPEND muszą poczekać na pomyślne wczytanie elementu REFRESH.
  • Jeśli dane lokalne nie wymagają odświeżania, initialize() powinien zwrócić wartość InitializeAction.SKIP_INITIAL_REFRESH. Powoduje to, że RemoteMediator pomija zdalne odświeżanie i wczytuje dane z pamięci podręcznej.

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

Utwórz pager

Na koniec musisz utworzyć instancję Pager, aby skonfigurować strumień danych stronicowanych. Przypomina to tworzenie obiektu Pager z prostego źródła danych sieciowych, ale musisz zrobić 2 rzeczy w inny sposób:

  • Zamiast przekazywać bezpośrednio konstruktor PagingSource, musisz podać metodę zapytania, która zwraca obiekt PagingSource z DAO.
  • Musisz podać wystąpienie implementacji RemoteMediator jako parametr 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));

Warunki wyścigu

Jedną z sytuacji, z którą aplikacja musi sobie poradzić podczas wczytywania danych z wielu źródeł, jest sytuacja, w której lokalne dane z pamięci podręcznej nie są zsynchronizowane ze zdalnym źródłem danych.

Gdy metoda initialize() z Twojej implementacji RemoteMediator zwraca LAUNCH_INITIAL_REFRESH, dane są nieaktualne i trzeba je zastąpić nowymi danymi. Wszystkie żądania wczytywania PREPEND lub APPEND muszą czekać na prawidłowe wczytanie REFRESH. Żądania PREPEND lub APPEND zostały umieszczone w kolejce przed żądaniem REFRESH, dlatego może się zdarzyć, że żądanie PagingState przekazane do tych wywołań wczytywania będzie nieaktualne w momencie ich uruchomienia.

W zależności od sposobu przechowywania danych lokalnie aplikacja może zignorować zbędne żądania, jeśli zmiany danych w pamięci podręcznej spowodują ich unieważnienie i ponowne pobranie. Na przykład sala unieważnia zapytania dotyczące wstawiania danych. Oznacza to, że nowe obiekty PagingSource z odświeżonymi danymi są dostarczane do oczekujących żądań obciążenia po wstawieniu do bazy danych nowych danych.

Rozwiązanie tego problemu z synchronizacją danych jest kluczowe, aby użytkownicy widzieli najbardziej odpowiednie i aktualne dane. Najlepsze rozwiązanie zależy głównie od sposobu, w jaki źródło danych udostępnia dane. W każdym przypadku klucze zdalne umożliwiają zapisywanie informacji o ostatniej stronie żądanej przez serwer. Dzięki tym informacjom można zidentyfikować właściwą stronę danych do wczytania i poprosić o nią.

Zarządzaj kluczykami zdalnymi

Klucze zdalne to klucze, których implementacja RemoteMediator używa do informowania usługi backendu, które dane mają zostać wczytane w następnej kolejności. W najprostszym przypadku każdy element danych na stronie zawiera klucz zdalny, do którego można łatwo się odnieść. Jeśli jednak klucze zdalne nie odpowiadają poszczególnym elementom, musisz przechowywać je oddzielnie i zarządzać nimi za pomocą metody load().

W tej sekcji opisujemy, jak zbierać, przechowywać i aktualizować klucze zdalne, które nie są przechowywane w poszczególnych elementach.

Klucze elementów

W tej sekcji opisujemy, jak korzystać z kluczy zdalnych odpowiadających poszczególnym elementom. Zwykle, gdy klucze interfejsu API łączą się z poszczególnymi elementami, identyfikator elementu jest przekazywany jako parametr zapytania. Nazwa parametru wskazuje, czy serwer powinien odpowiadać z elementami przed podanym identyfikatorem czy po nim. W przykładowej klasie modelu User pole id z serwera jest używane jako klucz zdalny podczas wysyłania żądań dotyczących dodatkowych danych.

Gdy metoda load() musi zarządzać kluczami zdalnymi dotyczącymi konkretnego produktu, klucze te są zwykle identyfikatorami danych pobranych z serwera. Operacje odświeżania nie wymagają klucza wczytywania, ponieważ pobierają tylko najnowsze dane. Podobnie operacje dołączania na początku nie muszą pobierać żadnych dodatkowych danych, ponieważ odświeżanie zawsze pobiera najnowsze dane z serwera.

Operacje dołączania wymagają jednak identyfikatora. Wymaga to wczytania ostatniego elementu z bazy danych, a następnie użycia jego identyfikatora do wczytania następnej strony z danymi. Jeśli w bazie danych nie ma żadnych elementów, endOfPaginationReached ma wartość Prawda, co wskazuje, że wymagane jest odświeżenie danych.

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

Klucze strony

W tej sekcji opisujemy, jak korzystać z kluczy zdalnych, które nie odpowiadają poszczególnym elementom.

Dodaj tabelę kluczy zdalnych

Gdy klucze zdalne nie są bezpośrednio powiązane z elementami listy, najlepiej przechowywać je w osobnej tabeli w lokalnej bazie danych. Zdefiniuj pomieszczenie, które reprezentuje tabelę kluczy zdalnych:

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

Musisz też zdefiniować DAO dla encji 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);
}

Wczytywanie przy użyciu kluczy zdalnych

Gdy Twoja metoda load() musi zarządzać zdalnymi kluczami stron, musisz zdefiniować ją inaczej w porównaniu z podstawowym użyciem RemoteMediator:

  • Uwzględnij dodatkową właściwość, która odwołuje się do DAO w tabeli kluczy zdalnej.
  • Aby określić, który klucz ma zostać wczytany w następnej kolejności, wyślij zapytanie do tabeli kluczy zdalnych zamiast używać PagingState.
  • Wstaw lub zapisz zwrócony klucz zdalny ze źródła danych sieci w uzupełnieniu danych stronicowanych.

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

Odświeżanie

Jeśli Twoja aplikacja musi obsługiwać tylko odświeżanie sieci z góry listy, tak jak w poprzednich przykładach, RemoteMediator nie musi definiować zachowania wczytywania na początku.

Jeśli jednak Twoja aplikacja musi obsługiwać stopniowe wczytywanie z sieci do lokalnej bazy danych, musisz umożliwić wznawianie podziału na strony od zakotwiczenia, czyli pozycji przewijania użytkownika. Implementacja PagingSource pokoju zrobi to za Ciebie, ale jeśli nie korzystasz z sali, możesz to zrobić, zastępując wartość PagingSource.getRefreshKey(). Przykład implementacji getRefreshKey() znajdziesz w sekcji Definiowanie elementu PagingSource.

Rysunek 4 przedstawia proces wczytywania danych najpierw z lokalnej bazy danych, a następnie z sieci po jej wyczerpaniu.

Strona PagingSource wczytuje się z bazy danych do interfejsu użytkownika, dopóki w bazie danych nie wyczerpią się dane. Następnie interfejs RemoteMediator wczytuje się z sieci do bazy danych, a następnie kontynuuje wczytywanie strony PagingSource.
Rysunek 4. Diagram pokazujący, jak strony PagingSource i RemoteMediator współpracują przy wczytywaniu danych.

Dodatkowe materiały

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

Ćwiczenia z programowania

Próbki