Улучшите пользовательский опыт, обеспечив возможность использования вашего приложения при нестабильном сетевом соединении или в автономном режиме. Один из способов сделать это — одновременно использовать сетевое соединение и локальную базу данных. Таким образом, ваше приложение будет управлять пользовательским интерфейсом из локального кэша базы данных и отправлять запросы в сеть только тогда, когда в базе данных больше нет данных.
В этом руководстве предполагается, что вы знакомы с библиотекой 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 перед отправкой в пользовательский интерфейс для отображения.

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() должен выполнить следующие шаги:
- Определяйте, какую страницу загрузить из сети в зависимости от типа загрузки и уже загруженных данных.
- Инициировать сетевой запрос.
- Выполняйте действия в зависимости от результата операции погрузки:
- Если загрузка прошла успешно и полученный список элементов не пуст, то сохраните элементы списка в базе данных и верните
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 показан процесс загрузки данных сначала из локальной базы данных, а затем из сети, когда в базе данных заканчиваются данные.
Дополнительные ресурсы
Чтобы узнать больше о библиотеке пейджинга, ознакомьтесь со следующими дополнительными ресурсами:
Просмотры контента
{% verbatim %}Рекомендуем вам
- Примечание: текст ссылки отображается, когда JavaScript отключен.
- Загрузка и отображение постраничных данных
- Протестируйте свою реализацию постраничной навигации.
- Перейти к пейджингу 3