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

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

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

Загрузка данных о координатах

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

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

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

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

Жизненный цикл разбиения на страницы

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

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

Рисунок 1. Диаграмма жизненного цикла Paging с PagingSource и RemoteMediator.

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

Как пользоваться отчетом

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

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

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

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

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

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

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

  • Метод 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()
}

Реализация RemoteMediator

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

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

  • query – строка запроса, определяющая, какие данные нужно получить от бэкенд-службы.
  • database: база данных Room, которая служит локальным кешем.
  • 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 {
    // ...
  }
}

Метод 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). После сохранения данных аннулируйте источник данных, чтобы уведомить библиотеку Paging о новых данных.
    • Если загрузка прошла успешно и полученный список элементов пуст или это индекс последней страницы, возвращается MediatorResult.Success(endOfPaginationReached = true). После сохранения данных сделайте источник данных недействительным, чтобы уведомить библиотеку Paging о новых данных.
    • Если запрос вызывает ошибку, возвращается 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)
  }
}

Задайте метод initialize.

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

Поскольку initialize() – это асинхронная функция, вы можете загрузить данные, чтобы определить релевантность существующих данных в базе данных. Чаще всего кешированные данные действительны только в течение определенного периода времени. RemoteMediator может проверить, истек ли этот срок действия, и в этом случае библиотеке Paging необходимо полностью обновить данные. Реализации 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
  }
}

Создать пейджер

Наконец, вы должны создать экземпляр 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)
}

Как обрабатывать состояния гонки

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

Если метод 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)
    }
  }
}

Ключи страниц

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

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

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

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

Также необходимо определить 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)
}

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

Если метод 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)
    }
  }
}

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

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

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

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

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

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

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

Просмотр контента