Страница из сети и базы данных

Обеспечьте улучшенный пользовательский опыт, обеспечив возможность использования вашего приложения, когда сетевые подключения ненадежны или когда пользователь находится в автономном режиме. Один из способов сделать это — одновременно выполнить пейджинг из сети и из локальной базы данных. Таким образом, ваше приложение управляет пользовательским интерфейсом из кэша локальной базы данных и отправляет запросы к сети только тогда, когда в базе данных больше нет данных.

В этом руководстве предполагается, что вы знакомы с библиотекой Room Persistent и с базовым использованием библиотеки Paging .

Координация загрузки данных

Библиотека подкачки предоставляет компонент RemoteMediator для этого варианта использования. RemoteMediator действует как сигнал библиотеки подкачки, когда в приложении закончились кэшированные данные. Вы можете использовать этот сигнал для загрузки дополнительных данных из сети и сохранения их в локальной базе данных, откуда PagingSource может загрузить их и предоставить пользовательскому интерфейсу для отображения.

Когда необходимы дополнительные данные, библиотека Paging вызывает метод load() из реализации RemoteMediator . Это функция приостановки, поэтому можно безопасно выполнять длительную работу. Эта функция обычно извлекает новые данные из сетевого источника и сохраняет их в локальном хранилище.

Этот процесс работает с новыми данными, но со временем данные, хранящиеся в базе данных, требуют признания недействительными, например, когда пользователь вручную запускает обновление. Это представлено свойством LoadType передаваемым методу load() . LoadType сообщает RemoteMediator , нужно ли ему обновить существующие данные или получить дополнительные данные, которые необходимо добавить или добавить в начало существующего списка.

Таким образом, RemoteMediator гарантирует, что ваше приложение загрузит данные, которые пользователи хотят видеть, в соответствующем порядке.

Жизненный цикл подкачки

Рисунок 1. Схема жизненного цикла Paging с PagingSource и PagingData.

При подкачке данных непосредственно из сети PagingSource загружает данные и возвращает объект LoadResult . Реализация PagingSource передается Pager через параметр pagingSourceFactory .

Поскольку пользовательскому интерфейсу требуются новые данные, Pager вызывает метод load() из PagingSource и возвращает поток объектов PagingData , которые инкапсулируют новые данные. Каждый объект PagingData обычно кэшируется в ViewModel перед отправкой в ​​пользовательский интерфейс для отображения.

Рисунок 2. Схема жизненного цикла Paging с PagingSource и RemoteMediator.

RemoteMediator изменяет этот поток данных. PagingSource по-прежнему загружает данные; но когда выгружаемые данные исчерпаны, библиотека подкачки запускает RemoteMediator для загрузки новых данных из сетевого источника. RemoteMediator сохраняет новые данные в локальной базе данных, поэтому кэш в памяти ViewModel не требуется. Наконец, PagingSource объявляет себя недействительным, и Pager создает новый экземпляр для загрузки свежих данных из базы данных.

Основное использование

Предположим, вы хотите, чтобы ваше приложение загружало страницы элементов User из источника сетевых данных с ключами элементов в локальный кэш, хранящийся в базе данных Room.

RemoteMediator загружает данные из сети в базу данных, а PagingSource загружает данные из базы данных. Пейджер использует как RemoteMediator, так и PagingSource для загрузки выгружаемых данных.
Рисунок 3. Схема реализации пейджинга, использующей многоуровневый источник данных.

Реализация RemoteMediator помогает загружать постраничные данные из сети в базу данных, но не загружает данные непосредственно в пользовательский интерфейс. Вместо этого приложение использует базу данных в качестве источника истины . Другими словами, приложение отображает только данные, кэшированные в базе данных. Реализация PagingSource (например, созданная Room) обрабатывает загрузку кэшированных данных из базы данных в пользовательский интерфейс.

Создание объектов комнаты

Первым шагом является использование библиотеки постоянства комнаты для определения базы данных, в которой хранится локальный кэш выгружаемых данных из источника сетевых данных. Начните с реализации RoomDatabase , как описано в разделе Сохранение данных в локальной базе данных с помощью Room .

Затем определите сущность «Комната», которая будет представлять таблицу элементов списка, как описано в разделе «Определение данных с использованием сущностей комнаты» . Дайте ему поле id в качестве первичного ключа, а также поля для любой другой информации, которую содержат элементы вашего списка.

Котлин

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

Ява

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

Ява

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

Вы также должны определить объект доступа к данным (DAO) для этой сущности Room, как описано в разделе Доступ к данным с помощью Room DAO . DAO для объекта элемента списка должен включать следующие методы:

  • Метод insertAll() , который вставляет список элементов в таблицу.
  • Метод, который принимает строку запроса в качестве параметра и возвращает объект PagingSource для списка результатов. Таким образом, объект Pager может использовать эту таблицу в качестве источника постраничных данных.
  • Метод clearAll() , который удаляет все данные таблицы.

Котлин

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

Ява

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

Ява

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

Реализация RemoteMediator

Основная роль RemoteMediator — загружать больше данных из сети, когда в Pager заканчиваются данные или существующие данные становятся недействительными. Он включает метод load() , который необходимо переопределить, чтобы определить поведение загрузки.

Типичная реализация RemoteMediator включает следующие параметры:

  • query : строка запроса, определяющая, какие данные следует получить из серверной службы.
  • database : база данных комнаты, которая служит локальным кешем.
  • networkService : экземпляр API для серверной службы.

Создайте реализацию RemoteMediator<Key, Value> . Тип Key и тип Value должны быть такими же, как если бы вы определяли PagingSource для того же источника сетевых данных. Дополнительные сведения о выборе параметров типа см. в разделе Выбор типов ключа и значения .

Котлин

@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 {
    // ...
  }
}

Ява

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

Ява

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

Метод load() отвечает за обновление резервного набора данных и аннулирование PagingSource . Некоторые библиотеки, поддерживающие разбиение на страницы (например, Room), автоматически обрабатывают объекты PagingSource , которые они реализуют, как недействительные.

Метод load() принимает два параметра:

  • PagingState , который содержит информацию о страницах, загруженных на данный момент, индексе, к которому последний раз обращались, и объекте PagingConfig , который вы использовали для инициализации потока подкачки.
  • LoadType , указывающий тип загрузки: REFRESH , APPEND или PREPEND .

Возвращаемое значение метода load() — это объект MediatorResult . MediatorResult может быть либо MediatorResult.Error (который включает описание ошибки), либо MediatorResult.Success (который включает сигнал, указывающий, есть ли еще данные для загрузки).

Метод load() должен выполнить следующие шаги:

  1. Определите, какую страницу загрузить из сети, в зависимости от типа загрузки и данных, которые были загружены на данный момент.
  2. Запустите сетевой запрос.
  3. Выполните действия в зависимости от результата операции загрузки:
    • Если загрузка прошла успешно и полученный список элементов не пуст, сохраните элементы списка в базе данных и верните MediatorResult.Success(endOfPaginationReached = false) . После сохранения данных сделайте источник данных недействительным, чтобы уведомить библиотеку подкачки о новых данных.
    • Если загрузка прошла успешно и либо полученный список элементов пуст, либо это индекс последней страницы, верните MediatorResult.Success(endOfPaginationReached = true) . После сохранения данных сделайте источник данных недействительным, чтобы уведомить библиотеку подкачки о новых данных.
    • Если запрос вызывает ошибку, верните MediatorResult.Error .

Котлин

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

Ява

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

Ява

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

Определите метод инициализации

Реализации RemoteMediator также могут переопределить метод initialize() , чтобы проверить, устарели ли кэшированные данные, и решить, запускать ли удаленное обновление. Этот метод запускается до выполнения какой-либо загрузки, поэтому вы можете манипулировать базой данных (например, очищать старые данные) перед запуском каких-либо локальных или удаленных загрузок.

Поскольку initialize() — асинхронная функция, вы можете загружать данные, чтобы определить релевантность существующих данных в базе данных. Наиболее распространенным случаем является то, что кэшированные данные действительны только в течение определенного периода времени. RemoteMediator может проверить, прошел ли срок действия, и в этом случае библиотеке подкачки необходимо полностью обновить данные. Реализации initialize() должны возвращать InitializeAction следующим образом:

  • В случаях, когда локальные данные необходимо полностью обновить, initialize() должен вернуть InitializeAction.LAUNCH_INITIAL_REFRESH . Это заставляет RemoteMediator выполнить удаленное обновление для полной перезагрузки данных. Любые удаленные загрузки APPEND или PREPEND ждут успешной загрузки REFRESH , прежде чем продолжить.
  • В тех случаях, когда локальные данные не нуждаются в обновлении, initialize() должен вернуть InitializeAction.SKIP_INITIAL_REFRESH . Это приводит к тому, что RemoteMediator пропускает удаленное обновление и загружает кэшированные данные.

Котлин

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

Ява

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

Ява

@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 для настройки потока выгружаемых данных. Это похоже на создание Pager из простого сетевого источника данных, но есть две вещи, которые вы должны сделать по-другому:

  • Вместо прямой передачи конструктора PagingSource необходимо предоставить метод запроса, который возвращает объект PagingSource из DAO.
  • Вы должны предоставить экземпляр своей реализации RemoteMediator в качестве параметра remoteMediator .

Котлин

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

Ява

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

Ява

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

Обработка условий гонки

Одна из ситуаций, которую вашему приложению необходимо обрабатывать при загрузке данных из нескольких источников, — это случай, когда локальные кэшированные данные не синхронизируются с удаленным источником данных.

Когда метод initialize() из вашей реализации RemoteMediator возвращает LAUNCH_INITIAL_REFRESH , данные устарели и должны быть заменены свежими данными. Любые запросы загрузки PREPEND или APPEND вынуждены ожидать успешной удаленной загрузки REFRESH . Поскольку запросы PREPEND или APPEND были поставлены в очередь перед запросом REFRESH , возможно, что PagingState , переданное этим вызовам загрузки, будет устаревшим к моменту их запуска.

В зависимости от того, как данные хранятся локально, ваше приложение может игнорировать избыточные запросы, если изменения в кэшированных данных приводят к аннулированию и выборке новых данных. Например, Room делает запросы недействительными при любой вставке данных. Это означает, что новые объекты PagingSource с обновленными данными предоставляются ожидающим запросам на загрузку, когда новые данные вставляются в базу данных.

Решение этой проблемы синхронизации данных необходимо для обеспечения того, чтобы пользователи видели наиболее актуальные и актуальные данные. Лучшее решение в основном зависит от того, как сетевой источник данных размещает данные. В любом случае удаленные ключи позволяют сохранять информацию о самой последней запрошенной с сервера странице. Ваше приложение может использовать эту информацию, чтобы идентифицировать и запросить правильную страницу данных для загрузки следующей.

Управление удаленными ключами

Удаленные ключи — это ключи, которые реализация RemoteMediator использует, чтобы сообщить внутренней службе, какие данные загружать следующими. В простейшем случае каждый элемент выгружаемых данных включает в себя удаленный ключ, на который вы можете легко ссылаться. Однако если удаленные ключи не соответствуют отдельным элементам, вам придется хранить их отдельно и управлять ими в методе load() .

В этом разделе описывается, как собирать, хранить и обновлять удаленные ключи, которые не хранятся в отдельных элементах.

Ключи предметов

В этом разделе описывается, как работать с дистанционными ключами, соответствующими отдельным элементам. Обычно, когда API отключает отдельные элементы, идентификатор элемента передается в качестве параметра запроса. Имя параметра указывает, должен ли сервер отвечать элементами до или после предоставленного идентификатора. В примере класса модели User поле id с сервера используется как удаленный ключ при запросе дополнительных данных.

Когда вашему методу load() необходимо управлять удаленными ключами для конкретных элементов, эти ключи обычно представляют собой идентификаторы данных, полученных с сервера. Операциям обновления не нужен ключ загрузки, поскольку они просто извлекают самые последние данные. Аналогичным образом, операциям добавления не требуется извлекать какие-либо дополнительные данные, поскольку обновление всегда извлекает самые новые данные с сервера.

Однако для операций добавления требуется идентификатор. Для этого вам необходимо загрузить последний элемент из базы данных и использовать его идентификатор для загрузки следующей страницы данных. Если в базе данных нет элементов, то endOfPaginationReached имеет значение true, что указывает на необходимость обновления данных.

Котлин

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

Ява

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

Ява

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

Клавиши страниц

В этом разделе описывается, как работать с дистанционными ключами, не соответствующими отдельным элементам.

Добавить таблицу удаленных ключей

Если удаленные ключи не связаны напрямую с элементами списка, лучше всего хранить их в отдельной таблице в локальной базе данных. Определите сущность «Комната», которая представляет таблицу удаленных ключей:

Котлин

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

Ява

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

Ява

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

Вы также должны определить DAO для объекта RemoteKey :

Котлин

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

Ява

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

Ява

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

Загрузка с помощью дистанционных ключей

Если вашему методу load() необходимо управлять ключами удаленной страницы, вы должны определить его иначе, чем при базовом использовании RemoteMediator :

  • Добавьте дополнительное свойство, содержащее ссылку на DAO для вашей таблицы удаленных ключей.
  • Определите, какой ключ загрузить следующим, запросив таблицу удаленных ключей вместо использования PagingState .
  • Вставьте или сохраните возвращенный удаленный ключ из источника сетевых данных в дополнение к самим выгруженным данным.

Котлин

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

Ява

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

Ява

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

Обновить на месте

Если вашему приложению необходимо поддерживать обновление сети только из верхней части списка, как в предыдущих примерах, тогда вашему RemoteMediator не нужно определять поведение предварительной загрузки.

Однако если вашему приложению необходимо поддерживать постепенную загрузку из сети в локальную базу данных, вы должны обеспечить поддержку возобновления нумерации страниц, начиная с привязки, позиции прокрутки пользователя. Реализация PagingSource в Room сделает это за вас, но если вы не используете Room, вы можете сделать это, переопределив PagingSource.getRefreshKey() . Пример реализации getRefreshKey() см. в разделе Определение PagingSource .

На рисунке 4 показан процесс загрузки данных сначала из локальной базы данных, а затем из сети, когда в базе данных закончатся данные.

PagingSource загружается из базы данных в пользовательский интерфейс до тех пор, пока в базе данных не закончатся данные. Затем RemoteMediator загружается из сети в базу данных, а затем PagingSource продолжает загрузку.
Рис. 4. Диаграмма, показывающая, как PagingSource и RemoteMediator работают вместе при загрузке данных.

Дополнительные ресурсы

Чтобы узнать больше о библиотеке подкачки, см. следующие дополнительные ресурсы:

Кодлабы

Образцы

{% дословно %} {% дословно %} {% дословно %} {% дословно %}