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

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

В этом руководстве предполагается, что вы знакомы с библиотекой 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. Схема жизненного цикла пейджинга с использованием PagingSource и RemoteMediator.

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

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

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

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

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

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

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

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

Также необходимо определить объект доступа к данным (DAO) для этой сущности «Комната», как описано в разделе «Доступ к данным с помощью 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()
}

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

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

В реализациях 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)
    }
  }
}

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

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

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

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

@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 не нужно определять поведение предварительной загрузки.

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

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

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

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

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

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

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}