Melhore a experiência do usuário garantindo que seu app possa ser usado quando as conexões de rede não forem confiáveis ou quando o usuário estiver off-line. Uma maneira de fazer isso é paginar pela rede e por um banco de dados local ao mesmo tempo. Dessa forma, o app orienta a IU usando um cache de banco de dados local e faz solicitações à rede apenas quando não há mais dados no banco de dados.
Neste guia, você precisa estar familiarizado com a biblioteca de persistência Room e o uso básico da biblioteca Paging.
Coordenar carregamentos de dados
A biblioteca Paging fornece o componente
RemoteMediator
para esse caso de uso. O RemoteMediator
atua como um sinal da biblioteca Paging
quando o app fica sem dados em cache. Você pode usar esse sinal para carregar
mais dados da rede e armazená-los no banco de dados local, em que um
PagingSource
pode carregá-los e
fornecê-los para serem exibidos pela IU.
Quando outros dados são necessários, a biblioteca Paging chama o método
load()
da
implementação RemoteMediator
. Essa é uma função de suspensão para
realizar trabalhos de longa duração de maneira segura. Essa função normalmente busca os novos dados de
uma origem de rede e os salva no armazenamento local.
Esse processo funciona com novos dados, mas, com o tempo, os dados armazenados no banco de dados
exigem a invalidação, como quando o usuário aciona manualmente uma atualização. Isso
é representado pela propriedade LoadType
transmitida ao método load()
. O LoadType
informa ao
RemoteMediator
se é necessário atualizar os dados existentes ou buscar
outros dados que precisam ser anexados à lista atual.
Dessa forma, o RemoteMediator
garante que o app carregue os dados que
os usuários querem ver na ordem apropriada.
Ciclo de vida da Paging
Ao paginar diretamente da rede, a PagingSource
carrega os dados e
retorna um
objeto
LoadResult
. A implementação da PagingSource
é transmitida ao
Pager
usando o parâmetro
pagingSourceFactory
.
Conforme novos dados são exigidos pela IU, o Pager
chama o método
load()
da
PagingSource
e retorna um fluxo de objetos
PagingData
que
encapsulam os novos dados. Normalmente, cada objeto PagingData
é armazenado em cache no
ViewModel
antes de ser enviado para a IU para exibição.
O RemoteMediator
modifica este fluxo de dados. Um PagingSource
ainda carrega os dados,
mas quando os dados paginados são esgotados, a biblioteca Paging aciona o
RemoteMediator
para carregar novos dados da origem da rede. O RemoteMediator
armazena os novos dados no banco de dados local, de modo que um cache na memória no
ViewModel
não é necessário. Por fim, a PagingSource
se invalida e o
Pager
cria uma nova instância para carregar os dados atualizados do banco de dados.
Uso básico
Suponha que você queira que o app carregue páginas de itens User
de uma fonte de dados de rede
com chave de item para um cache local armazenado em um banco de dados do Room.
Uma implementação de RemoteMediator
ajuda a carregar dados paginados da rede no
banco de dados, mas não carrega dados diretamente na IU. Em vez disso, o app usa
o banco de dados como a fonte da
verdade. Em outras palavras, o app só
mostra dados que foram armazenados em cache no banco de dados. Uma implementação de
PagingSource
(por exemplo, uma gerada pelo Room) processa o carregamento de dados armazenados em cache
do banco de dados na IU.
Criar entidades do Room
A primeira etapa é usar a biblioteca de persistência
Room para definir um banco de dados que armazene um
cache local de dados paginados da fonte de dados de rede. Comece com uma
implementação de RoomDatabase
,
conforme descrito em Salvar dados em um banco de dados local usando
Room.
Em seguida, defina uma entidade do Room para representar uma tabela de itens de lista, conforme descrito em
Como definir dados usando entidades do Room.
Forneça um campo id
como chave primária e outros campos para outras
informações que seus itens de lista contenham.
Kotlin
@Entity(tableName = "users") data class User(val id: String, val label: String)
Java
@Entity(tableName = "users") public class User { public String id; public String label; }
Java
@Entity(tableName = "users") public class User { public String id; public String label; }
Também é necessário definir um objeto de acesso a dados (DAO, na sigla em inglês) para essa entidade do Room, conforme descrito em Como acessar dados usando DAOs do Room. O DAO da entidade de item de lista precisa incluir os seguintes métodos:
- Um método
insertAll()
que insere uma lista de itens na tabela. - Um método que usa a string de consulta como parâmetro e retorna um objeto
PagingSource
para a lista de resultados. Dessa forma, um objetoPager
pode usar essa tabela como uma fonte de dados paginados. - Um método
clearAll()
que exclui todos os dados da tabela.
Kotlin
@Dao interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(users: List<User>) @Query("SELECT * FROM users WHERE label LIKE :query") fun pagingSource(query: String): PagingSource<Int, User> @Query("DELETE FROM users") suspend fun clearAll() }
Java
@Dao interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insertAll(List<User> users); @Query("SELECT * FROM users WHERE mLabel LIKE :query") PagingSource<Integer, User> pagingSource(String query); @Query("DELETE FROM users") int clearAll(); }
Java
@Dao interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insertAll(List<User> users); @Query("SELECT * FROM users WHERE mLabel LIKE :query") PagingSource<Integer, User> pagingSource(String query); @Query("DELETE FROM users") int clearAll(); }
Implementar um RemoteMediator
A função principal do RemoteMediator
é carregar mais dados da rede quando
o Pager
ficar sem dados ou os dados existentes forem invalidados. Ele
inclui um método load()
que você precisa substituir para definir o comportamento de
carregamento.
Uma implementação típica de RemoteMediator
inclui os seguintes parâmetros:
query
: uma string de consulta que define quais dados recuperar do serviço de back-end.database
: o banco de dados do Room que atua como um cache local.networkService
: uma instância de API para o serviço de back-end.
Crie uma implementação de RemoteMediator<Key, Value>
. Os tipos Key
e
Value
precisam ser os mesmos que seriam se você estivesse definindo uma
PagingSource
na mesma fonte de dados da rede. Para mais informações sobre
como selecionar parâmetros de tipo, consulte Selecionar os
tipos de chave e valor.
Kotlin
@OptIn(ExperimentalPagingApi::class) class ExampleRemoteMediator( private val query: String, private val database: RoomDb, private val networkService: ExampleBackendService ) : RemoteMediator<Int, User>() { val userDao = database.userDao() override suspend fun load( loadType: LoadType, state: PagingState<Int, User> ): MediatorResult { // ... } }
Java
@UseExperimental(markerClass = ExperimentalPagingApi.class) class ExampleRemoteMediator extends RxRemoteMediator<Integer, User> { private String query; private ExampleBackendService networkService; private RoomDb database; private UserDao userDao; ExampleRemoteMediator( String query, ExampleBackendService networkService, RoomDb database ) { query = query; networkService = networkService; database = database; userDao = database.userDao(); } @NotNull @Override public Single<MediatorResult> loadSingle( @NotNull LoadType loadType, @NotNull PagingState<Integer, User> state ) { ... } }
Java
class ExampleRemoteMediator extends ListenableFutureRemoteMediator<Integer, User> { private String query; private ExampleBackendService networkService; private RoomDb database; private UserDao userDao; private Executor bgExecutor; ExampleRemoteMediator( String query, ExampleBackendService networkService, RoomDb database, Executor bgExecutor ) { this.query = query; this.networkService = networkService; this.database = database; this.userDao = database.userDao(); this.bgExecutor = bgExecutor; } @NotNull @Override public ListenableFuture<MediatorResult> loadFuture( @NotNull LoadType loadType, @NotNull PagingState<Integer, User> state ) { ... } }
O método load()
é responsável por atualizar o conjunto de dados de apoio e
invalidar a PagingSource
. Algumas bibliotecas que aceitam paginação (como o Room)
processam automaticamente a invalidação de
objetos PagingSource
implementados.
O método load()
tem dois parâmetros:
PagingState
, que contém informações sobre as páginas carregadas até o momento, o índice acessado mais recentemente e o objetoPagingConfig
usado para inicializar o fluxo de paginação.LoadType
, que indica o tipo de carga:REFRESH
,APPEND
ouPREPEND
.
O valor de retorno do método load()
é um
objeto
MediatorResult
. MediatorResult
pode ser
MediatorResult.Error
(que inclui a descrição do erro) ou
MediatorResult.Success
(que inclui um sinal que indica se há ou não mais dados para carregar).
O método load()
precisa executar as seguintes etapas:
- Determinar qual página carregar da rede, dependendo do tipo de carga e dos dados que foram carregados até o momento.
- Acionar a solicitação de rede.
- Executar ações dependendo do resultado da operação de carregamento:
- Se o carregamento for bem-sucedido e a lista de itens recebida não estiver vazia,
armazene esses itens no banco de dados e retorne
MediatorResult.Success(endOfPaginationReached = false)
. Depois que os dados forem armazenados, invalide a fonte de dados para notificar a biblioteca Paging dos novos dados. - Se o carregamento for bem-sucedido e a lista de itens recebida estiver vazia
ou for o último índice da página, retorne
MediatorResult.Success(endOfPaginationReached = true)
. Depois que os dados forem armazenados, invalide a fonte para notificar a biblioteca Paging sobre os novos dados. - Se a solicitação causar um erro, retorne
MediatorResult.Error
.
- Se o carregamento for bem-sucedido e a lista de itens recebida não estiver vazia,
armazene esses itens no banco de dados e retorne
Kotlin
override suspend fun load( loadType: LoadType, state: PagingState<Int, User> ): MediatorResult { return try { // The network load method takes an optional after=<user.id> // parameter. For every page after the first, pass the last user // ID to let it continue from where it left off. For REFRESH, // pass null to load the first page. val loadKey = when (loadType) { LoadType.REFRESH -> null // In this example, you never need to prepend, since REFRESH // will always load the first page in the list. Immediately // return, reporting end of pagination. LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) LoadType.APPEND -> { val lastItem = state.lastItemOrNull() // You must explicitly check if the last item is null when // appending, since passing null to networkService is only // valid for initial load. If lastItem is null it means no // items were loaded after the initial REFRESH and there are // no more items to load. if (lastItem == null) { return MediatorResult.Success( endOfPaginationReached = true ) } lastItem.id } } // Suspending network load via Retrofit. This doesn't need to be // wrapped in a withContext(Dispatcher.IO) { ... } block since // Retrofit's Coroutine CallAdapter dispatches on a worker // thread. val response = networkService.searchUsers( query = query, after = loadKey ) database.withTransaction { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query) } // Insert new users into database, which invalidates the // current PagingData, allowing Paging to present the updates // in the DB. userDao.insertAll(response.users) } MediatorResult.Success( endOfPaginationReached = response.nextKey == null ) } catch (e: IOException) { MediatorResult.Error(e) } catch (e: HttpException) { MediatorResult.Error(e) } }
Java
@NotNull @Override public Single<MediatorResult> loadSingle( @NotNull LoadType loadType, @NotNull PagingState<Integer, User> state ) { // The network load method takes an optional after=<user.id> parameter. For // every page after the first, pass the last user ID to let it continue from // where it left off. For REFRESH, pass null to load the first page. String loadKey = null; switch (loadType) { case REFRESH: break; case PREPEND: // In this example, you never need to prepend, since REFRESH will always // load the first page in the list. Immediately return, reporting end of // pagination. return Single.just(new MediatorResult.Success(true)); case APPEND: User lastItem = state.lastItemOrNull(); // You must explicitly check if the last item is null when appending, // since passing null to networkService is only valid for initial load. // If lastItem is null it means no items were loaded after the initial // REFRESH and there are no more items to load. if (lastItem == null) { return Single.just(new MediatorResult.Success(true)); } loadKey = lastItem.getId(); break; } return networkService.searchUsers(query, loadKey) .subscribeOn(Schedulers.io()) .map((Function<SearchUserResponse, MediatorResult>) response -> { database.runInTransaction(() -> { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query); } // Insert new users into database, which invalidates the current // PagingData, allowing Paging to present the updates in the DB. userDao.insertAll(response.getUsers()); }); return new MediatorResult.Success(response.getNextKey() == null); }) .onErrorResumeNext(e -> { if (e instanceof IOException || e instanceof HttpException) { return Single.just(new MediatorResult.Error(e)); } return Single.error(e); }); }
Java
@NotNull @Override public ListenableFuture<MediatorResult> loadFuture( @NotNull LoadType loadType, @NotNull PagingState<Integer, User> state ) { // The network load method takes an optional after=<user.id> parameter. For // every page after the first, pass the last user ID to let it continue from // where it left off. For REFRESH, pass null to load the first page. String loadKey = null; switch (loadType) { case REFRESH: break; case PREPEND: // In this example, you never need to prepend, since REFRESH will always // load the first page in the list. Immediately return, reporting end of // pagination. return Futures.immediateFuture(new MediatorResult.Success(true)); case APPEND: User lastItem = state.lastItemOrNull(); // You must explicitly check if the last item is null when appending, // since passing null to networkService is only valid for initial load. // If lastItem is null it means no items were loaded after the initial // REFRESH and there are no more items to load. if (lastItem == null) { return Futures.immediateFuture(new MediatorResult.Success(true)); } loadKey = lastItem.getId(); break; } ListenableFuture<MediatorResult> networkResult = Futures.transform( networkService.searchUsers(query, loadKey), response -> { database.runInTransaction(() -> { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query); } // Insert new users into database, which invalidates the current // PagingData, allowing Paging to present the updates in the DB. userDao.insertAll(response.getUsers()); }); return new MediatorResult.Success(response.getNextKey() == null); }, bgExecutor); ListenableFuture<MediatorResult> ioCatchingNetworkResult = Futures.catching( networkResult, IOException.class, MediatorResult.Error::new, bgExecutor ); return Futures.catching( ioCatchingNetworkResult, HttpException.class, MediatorResult.Error::new, bgExecutor ); }
Definir o método de inicialização
As implementações do RemoteMediator
também podem substituir o método
initialize()
para verificar se os dados em cache estão desatualizados e decidir se precisam acionar
uma atualização remota. Esse método é executado antes de qualquer carregamento ser realizado. Dessa forma, é possível
manipular o banco de dados (por exemplo, para limpar dados antigos) antes de acionar
carregamentos locais ou remotos.
Como initialize()
é uma função assíncrona, carregue dados para
determinar a relevância dos dados atuais no banco de dados. O caso mais comum
ocorre quando os dados armazenados em cache são válidos apenas por um determinado período. O
RemoteMediator
pode verificar se esse prazo de validade expirou, nesse caso
a biblioteca Paging precisa atualizar totalmente os dados. As implementações de
initialize()
precisam retornar uma InitializeAction
desta forma:
- Nos casos em que os dados locais precisam ser totalmente atualizados,
initialize()
retornaráInitializeAction.LAUNCH_INITIAL_REFRESH
. Isso faz com que oRemoteMediator
execute uma atualização remota para recarregar os dados totalmente. Qualquer carregamento remoto deAPPEND
ouPREPEND
aguarda a conclusão do carregamento deREFRESH
antes de continuar. - Nos casos em que os dados locais não precisam ser atualizados,
initialize()
retornaráInitializeAction.SKIP_INITIAL_REFRESH
. Isso faz com que oRemoteMediator
ignore a atualização remota e carregue os dados armazenados em cache.
Kotlin
override suspend fun initialize(): InitializeAction { val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS) return if (System.currentTimeMillis() - db.lastUpdated() <= cacheTimeout) { // Cached data is up-to-date, so there is no need to re-fetch // from the network. InitializeAction.SKIP_INITIAL_REFRESH } else { // Need to refresh cached data from network; returning // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's // APPEND and PREPEND from running until REFRESH succeeds. InitializeAction.LAUNCH_INITIAL_REFRESH } }
Java
@NotNull @Override public Single<InitializeAction> initializeSingle() { long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS); return mUserDao.lastUpdatedSingle() .map(lastUpdatedMillis -> { if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) { // Cached data is up-to-date, so there is no need to re-fetch // from the network. return InitializeAction.SKIP_INITIAL_REFRESH; } else { // Need to refresh cached data from network; returning // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's // APPEND and PREPEND from running until REFRESH succeeds. return InitializeAction.LAUNCH_INITIAL_REFRESH; } }); }
Java
@NotNull @Override public ListenableFuture<InitializeAction> initializeFuture() { long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS); return Futures.transform( mUserDao.lastUpdated(), lastUpdatedMillis -> { if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) { // Cached data is up-to-date, so there is no need to re-fetch // from the network. return InitializeAction.SKIP_INITIAL_REFRESH; } else { // Need to refresh cached data from network; returning // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's // APPEND and PREPEND from running until REFRESH succeeds. return InitializeAction.LAUNCH_INITIAL_REFRESH; } }, mBgExecutor); }
Criar um Pager
Por fim, você precisa criar uma instância de Pager
para configurar o fluxo de dados paginados.
Isso é semelhante a criar um Pager
por uma fonte de dados de rede simples, mas
há dois aspectos que precisam ser diferentes:
- Em vez de transmitir um construtor
PagingSource
diretamente, é necessário fornecer o método de consulta que retorna um objetoPagingSource
do DAO. - É preciso fornecer uma instância da implementação de
RemoteMediator
como o parâmetroremoteMediator
.
Kotlin
val userDao = database.userDao() val pager = Pager( config = PagingConfig(pageSize = 50) remoteMediator = ExampleRemoteMediator(query, database, networkService) ) { userDao.pagingSource(query) }
Java
UserDao userDao = database.userDao(); Pager<Integer, User> pager = Pager( new PagingConfig(/* pageSize = */ 20), null, // initialKey, new ExampleRemoteMediator(query, database, networkService) () -> userDao.pagingSource(query));
Java
UserDao userDao = database.userDao(); Pager<Integer, User> pager = Pager( new PagingConfig(/* pageSize = */ 20), null, // initialKey new ExampleRemoteMediator(query, database, networkService, bgExecutor), () -> userDao.pagingSource(query));
Lidar com disputas
Uma situação que o app precisa processar ao carregar dados de várias fontes ocorre quando os dados em cache locais ficam dessincronizados com a fonte de dados remota.
Quando o método initialize()
da sua implementação RemoteMediator
retorna
LAUNCH_INITIAL_REFRESH
, os dados estão desatualizados e precisam ser substituídos por dados
novos. Todas as solicitações de carregamento PREPEND
ou APPEND
são forçadas a esperar a conclusão do carregamento
REFRESH
remoto. Como as solicitações PREPEND
ou APPEND
foram
enfileiradas antes da solicitação REFRESH
, é possível que o PagingState
transmitido para essas chamadas de carregamento esteja desatualizado no momento que elas forem executadas.
Dependendo de como os dados são armazenados localmente, o app poderá ignorar solicitações
redundantes se as alterações nos dados em cache causarem a invalidação e novas buscas de dados.
Por exemplo, o Room invalida consultas em qualquer inserção de dados. Isso significa que novos objetos
PagingSource
com os dados atualizados são fornecidos a solicitações de carregamento
pendentes quando novos dados são inseridos no banco de dados.
Resolver esse problema de sincronização de dados é essencial para garantir que os dados mais relevantes e atualizados sejam exibidos aos usuários. A melhor solução depende principalmente da maneira como a fonte de dados da rede faz a paginação dos dados. Em qualquer caso, as chaves remotas permitem salvar informações sobre a página mais recente solicitada do servidor. Seu app pode usar essas informações para identificar e solicitar a página de dados correta para ser carregada em seguida.
Gerenciar chaves remotas
As chaves remotas são aquelas usadas por uma implementação RemoteMediator
para informar ao
serviço de back-end quais dados serão carregados em seguida. No caso mais simples, cada item de
dados paginados inclui uma chave remota que você pode consultar facilmente. No entanto, se as
chaves remotas não corresponderem a itens individuais, será necessário armazená-las
separadamente e gerenciá-las no método load()
.
Esta seção descreve como coletar, armazenar e atualizar chaves remotas que não estão armazenadas em itens individuais.
Chaves de itens
Esta seção descreve como trabalhar com chaves remotas que correspondem a
itens individuais. Normalmente, quando uma chave de API é exterior aos itens individuais, o ID
do item é transmitido como um parâmetro de consulta. O nome do parâmetro indica se o
servidor responderá com itens antes ou depois do ID fornecido. No exemplo da
classe de modelo User
, o campo id
do servidor é usado como uma chave
remota ao solicitar mais dados.
Quando o método load()
precisa gerenciar chaves remotas específicas de itens, essas chaves
geralmente são os IDs dos dados buscados no servidor. As operações de atualização
não precisam de uma chave de carregamento, porque elas apenas extraem os dados mais recentes.
Da mesma forma, as operações de prefixação não precisam buscar outros dados, porque
a atualização sempre extrai os dados mais recentes do servidor.
No entanto, as operações de anexação exigem um ID. Isso exige que você carregue o último
item do banco de dados e use o ID dele para carregar a próxima página de dados. Se não
houver itens no banco de dados, endOfPaginationReached
será definido como verdadeiro,
indicando que uma atualização de dados é necessária.
Kotlin
@OptIn(ExperimentalPagingApi::class) class ExampleRemoteMediator( private val query: String, private val database: RoomDb, private val networkService: ExampleBackendService ) : RemoteMediator<Int, User>() { val userDao = database.userDao() override suspend fun load( loadType: LoadType, state: PagingState<Int, User> ): MediatorResult { return try { // The network load method takes an optional String // parameter. For every page after the first, pass the String // token returned from the previous page to let it continue // from where it left off. For REFRESH, pass null to load the // first page. val loadKey = when (loadType) { LoadType.REFRESH -> null // In this example, you never need to prepend, since REFRESH // will always load the first page in the list. Immediately // return, reporting end of pagination. LoadType.PREPEND -> return MediatorResult.Success( endOfPaginationReached = true ) // Get the last User object id for the next RemoteKey. LoadType.APPEND -> { val lastItem = state.lastItemOrNull() // You must explicitly check if the last item is null when // appending, since passing null to networkService is only // valid for initial load. If lastItem is null it means no // items were loaded after the initial REFRESH and there are // no more items to load. if (lastItem == null) { return MediatorResult.Success( endOfPaginationReached = true ) } lastItem.id } } // Suspending network load via Retrofit. This doesn't need to // be wrapped in a withContext(Dispatcher.IO) { ... } block // since Retrofit's Coroutine CallAdapter dispatches on a // worker thread. val response = networkService.searchUsers(query, loadKey) // Store loaded data, and next key in transaction, so that // they're always consistent. database.withTransaction { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query) } // Insert new users into database, which invalidates the // current PagingData, allowing Paging to present the updates // in the DB. userDao.insertAll(response.users) } // End of pagination has been reached if no users are returned from the // service MediatorResult.Success( endOfPaginationReached = response.users.isEmpty() ) } catch (e: IOException) { MediatorResult.Error(e) } catch (e: HttpException) { MediatorResult.Error(e) } } }
Java
@NotNull @Override public Single>MediatorResult< loadSingle( @NotNull LoadType loadType, @NotNull PagingState>Integer, User< state ) { // The network load method takes an optional String parameter. For every page // after the first, pass the String token returned from the previous page to // let it continue from where it left off. For REFRESH, pass null to load the // first page. Single>String< remoteKeySingle = null; switch (loadType) { case REFRESH: // Initial load should use null as the page key, so you can return null // directly. remoteKeySingle = Single.just(null); break; case PREPEND: // In this example, you never need to prepend, since REFRESH will always // load the first page in the list. Immediately return, reporting end of // pagination. return Single.just(new MediatorResult.Success(true)); case APPEND: User lastItem = state.lastItemOrNull(); // You must explicitly check if the last item is null when // appending, since passing null to networkService is only // valid for initial load. If lastItem is null it means no // items were loaded after the initial REFRESH and there are // no more items to load. if (lastItem == null) { return Single.just(new MediatorResult.Success(true)); } remoteKeySingle = Single.just(lastItem.getId()); break; } return remoteKeySingle .subscribeOn(Schedulers.io()) .flatMap((Function<String, Single<MediatorResult>>) remoteKey -> { return networkService.searchUsers(query, remoteKey) .map(response -> { database.runInTransaction(() -> { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query); } // Insert new users into database, which invalidates the current // PagingData, allowing Paging to present the updates in the DB. userDao.insertAll(response.getUsers()); }); return new MediatorResult.Success(response.getUsers().isEmpty()); }); }) .onErrorResumeNext(e -> { if (e instanceof IOException || e instanceof HttpException) { return Single.just(new MediatorResult.Error(e)); } return Single.error(e); }); }
Java
@NotNull @Override public ListenableFuture<MediatorResult> loadFuture( @NotNull LoadType loadType, @NotNull PagingState<Integer, User> state ) { // The network load method takes an optional after=<user.id> parameter. // For every page after the first, pass the last user ID to let it continue // from where it left off. For REFRESH, pass null to load the first page. ResolvableFuture<String> remoteKeyFuture = ResolvableFuture.create(); switch (loadType) { case REFRESH: remoteKeyFuture.set(null); break; case PREPEND: // In this example, you never need to prepend, since REFRESH will always // load the first page in the list. Immediately return, reporting end of // pagination. return Futures.immediateFuture(new MediatorResult.Success(true)); case APPEND: User lastItem = state.lastItemOrNull(); // You must explicitly check if the last item is null when appending, // since passing null to networkService is only valid for initial load. // If lastItem is null it means no items were loaded after the initial // REFRESH and there are no more items to load. if (lastItem == null) { return Futures.immediateFuture(new MediatorResult.Success(true)); } remoteKeyFuture.set(lastItem.getId()); break; } return Futures.transformAsync(remoteKeyFuture, remoteKey -> { ListenableFuture<MediatorResult> networkResult = Futures.transform( networkService.searchUsers(query, remoteKey), response -> { database.runInTransaction(() -> { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query); } // Insert new users into database, which invalidates the current // PagingData, allowing Paging to present the updates in the DB. userDao.insertAll(response.getUsers()); }); return new MediatorResult.Success(response.getUsers().isEmpty()); }, bgExecutor); ListenableFuture<MediatorResult> ioCatchingNetworkResult = Futures.catching( networkResult, IOException.class, MediatorResult.Error::new, bgExecutor ); return Futures.catching( ioCatchingNetworkResult, HttpException.class, MediatorResult.Error::new, bgExecutor ); }, bgExecutor); }
Chaves de páginas
Esta seção descreve como trabalhar com chaves remotas que não correspondem a itens individuais.
Adicionar tabela de chave remota
Quando as chaves remotas não são associadas diretamente aos itens de lista, é melhor armazená-las em uma tabela separada no banco de dados local. Defina uma entidade do Room que represente uma tabela de chaves remotas:
Kotlin
@Entity(tableName = "remote_keys") data class RemoteKey(val label: String, val nextKey: String?)
Java
@Entity(tableName = "remote_keys") public class RemoteKey { public String label; public String nextKey; }
Java
@Entity(tableName = "remote_keys") public class RemoteKey { public String label; public String nextKey; }
Também é necessário definir um DAO para a entidade RemoteKey
:
Kotlin
@Dao interface RemoteKeyDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOrReplace(remoteKey: RemoteKey) @Query("SELECT * FROM remote_keys WHERE label = :query") suspend fun remoteKeyByQuery(query: String): RemoteKey @Query("DELETE FROM remote_keys WHERE label = :query") suspend fun deleteByQuery(query: String) }
Java
@Dao interface RemoteKeyDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insertOrReplace(RemoteKey remoteKey); @Query("SELECT * FROM remote_keys WHERE label = :query") Single<RemoteKey> remoteKeyByQuerySingle(String query); @Query("DELETE FROM remote_keys WHERE label = :query") void deleteByQuery(String query); }
Java
@Dao interface RemoteKeyDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insertOrReplace(RemoteKey remoteKey); @Query("SELECT * FROM remote_keys WHERE label = :query") ListenableFuture<RemoteKey> remoteKeyByQueryFuture(String query); @Query("DELETE FROM remote_keys WHERE label = :query") void deleteByQuery(String query); }
Carregar com chaves remotas
Quando o método load()
precisar gerenciar chaves remotas, será necessário defini-lo de
maneiras diferentes em comparação com o uso básico do
RemoteMediator
:
- Inclua uma propriedade extra que contenha uma referência ao DAO para sua tabela de chave remota.
- Consulte a tabela de chaves remotas em vez de
usar
PagingState
para determinar qual chave será carregada em seguida. - Insira ou armazene a chave remota retornada da fonte de dados de rede, além dos próprios dados paginados.
Kotlin
@OptIn(ExperimentalPagingApi::class) class ExampleRemoteMediator( private val query: String, private val database: RoomDb, private val networkService: ExampleBackendService ) : RemoteMediator<Int, User>() { val userDao = database.userDao() val remoteKeyDao = database.remoteKeyDao() override suspend fun load( loadType: LoadType, state: PagingState<Int, User> ): MediatorResult { return try { // The network load method takes an optional String // parameter. For every page after the first, pass the String // token returned from the previous page to let it continue // from where it left off. For REFRESH, pass null to load the // first page. val loadKey = when (loadType) { LoadType.REFRESH -> null // In this example, you never need to prepend, since REFRESH // will always load the first page in the list. Immediately // return, reporting end of pagination. LoadType.PREPEND -> return MediatorResult.Success( endOfPaginationReached = true ) // Query remoteKeyDao for the next RemoteKey. LoadType.APPEND -> { val remoteKey = database.withTransaction { remoteKeyDao.remoteKeyByQuery(query) } // You must explicitly check if the page key is null when // appending, since null is only valid for initial load. // If you receive null for APPEND, that means you have // reached the end of pagination and there are no more // items to load. if (remoteKey.nextKey == null) { return MediatorResult.Success( endOfPaginationReached = true ) } remoteKey.nextKey } } // Suspending network load via Retrofit. This doesn't need to // be wrapped in a withContext(Dispatcher.IO) { ... } block // since Retrofit's Coroutine CallAdapter dispatches on a // worker thread. val response = networkService.searchUsers(query, loadKey) // Store loaded data, and next key in transaction, so that // they're always consistent. database.withTransaction { if (loadType == LoadType.REFRESH) { remoteKeyDao.deleteByQuery(query) userDao.deleteByQuery(query) } // Update RemoteKey for this query. remoteKeyDao.insertOrReplace( RemoteKey(query, response.nextKey) ) // Insert new users into database, which invalidates the // current PagingData, allowing Paging to present the updates // in the DB. userDao.insertAll(response.users) } MediatorResult.Success( endOfPaginationReached = response.nextKey == null ) } catch (e: IOException) { MediatorResult.Error(e) } catch (e: HttpException) { MediatorResult.Error(e) } } }
Java
@NotNull @Override public Single<MediatorResult> loadSingle( @NotNull LoadType loadType, @NotNull PagingState<Integer, User> state ) { // The network load method takes an optional String parameter. For every page // after the first, pass the String token returned from the previous page to // let it continue from where it left off. For REFRESH, pass null to load the // first page. Single<RemoteKey> remoteKeySingle = null; switch (loadType) { case REFRESH: // Initial load should use null as the page key, so you can return null // directly. remoteKeySingle = Single.just(new RemoteKey(mQuery, null)); break; case PREPEND: // In this example, you never need to prepend, since REFRESH will always // load the first page in the list. Immediately return, reporting end of // pagination. return Single.just(new MediatorResult.Success(true)); case APPEND: // Query remoteKeyDao for the next RemoteKey. remoteKeySingle = mRemoteKeyDao.remoteKeyByQuerySingle(mQuery); break; } return remoteKeySingle .subscribeOn(Schedulers.io()) .flatMap((Function<RemoteKey, Single<MediatorResult>>) remoteKey -> { // You must explicitly check if the page key is null when appending, // since null is only valid for initial load. If you receive null // for APPEND, that means you have reached the end of pagination and // there are no more items to load. if (loadType != REFRESH && remoteKey.getNextKey() == null) { return Single.just(new MediatorResult.Success(true)); } return networkService.searchUsers(query, remoteKey.getNextKey()) .map(response -> { database.runInTransaction(() -> { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query); remoteKeyDao.deleteByQuery(query); } // Update RemoteKey for this query. remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey())); // Insert new users into database, which invalidates the current // PagingData, allowing Paging to present the updates in the DB. userDao.insertAll(response.getUsers()); }); return new MediatorResult.Success(response.getNextKey() == null); }); }) .onErrorResumeNext(e -> { if (e instanceof IOException || e instanceof HttpException) { return Single.just(new MediatorResult.Error(e)); } return Single.error(e); }); }
Java
@NotNull @Override public ListenableFuture<MediatorResult> loadFuture( @NotNull LoadType loadType, @NotNull PagingState<Integer, User> state ) { // The network load method takes an optional after=<user.id> parameter. For // every page after the first, pass the last user ID to let it continue from // where it left off. For REFRESH, pass null to load the first page. ResolvableFuture<RemoteKey> remoteKeyFuture = ResolvableFuture.create(); switch (loadType) { case REFRESH: remoteKeyFuture.set(new RemoteKey(query, null)); break; case PREPEND: // In this example, you never need to prepend, since REFRESH will always // load the first page in the list. Immediately return, reporting end of // pagination. return Futures.immediateFuture(new MediatorResult.Success(true)); case APPEND: User lastItem = state.lastItemOrNull(); // You must explicitly check if the last item is null when appending, // since passing null to networkService is only valid for initial load. // If lastItem is null it means no items were loaded after the initial // REFRESH and there are no more items to load. if (lastItem == null) { return Futures.immediateFuture(new MediatorResult.Success(true)); } // Query remoteKeyDao for the next RemoteKey. remoteKeyFuture.setFuture( remoteKeyDao.remoteKeyByQueryFuture(query)); break; } return Futures.transformAsync(remoteKeyFuture, remoteKey -> { // You must explicitly check if the page key is null when appending, // since null is only valid for initial load. If you receive null // for APPEND, that means you have reached the end of pagination and // there are no more items to load. if (loadType != LoadType.REFRESH && remoteKey.getNextKey() == null) { return Futures.immediateFuture(new MediatorResult.Success(true)); } ListenableFuture<MediatorResult> networkResult = Futures.transform( networkService.searchUsers(query, remoteKey.getNextKey()), response -> { database.runInTransaction(() -> { if (loadType == LoadType.REFRESH) { userDao.deleteByQuery(query); remoteKeyDao.deleteByQuery(query); } // Update RemoteKey for this query. remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey())); // Insert new users into database, which invalidates the current // PagingData, allowing Paging to present the updates in the DB. userDao.insertAll(response.getUsers()); }); return new MediatorResult.Success(response.getNextKey() == null); }, bgExecutor); ListenableFuture<MediatorResult> ioCatchingNetworkResult = Futures.catching( networkResult, IOException.class, MediatorResult.Error::new, bgExecutor ); return Futures.catching( ioCatchingNetworkResult, HttpException.class, MediatorResult.Error::new, bgExecutor ); }, bgExecutor); }
Atualizar no lugar
Se o app só precisar ser compatível com atualizações de rede do topo da lista,
como nos exemplos anteriores, o RemoteMediator
não precisará definir
um comportamento prefixado de carregamento.
No entanto, se o app precisar oferecer compatibilidade com o carregamento incremental da rede
para o banco de dados local, você precisará fornecer suporte para a retomada da paginação
a partir da âncora, a posição de rolagem do usuário. A implementação da PagingSource
do Room lida com isso, porém, se você não estiver usando o Room,
substitua
PagingSource.getRefreshKey()
.
Para um exemplo de implementação da getRefreshKey()
, consulte Como definir a
PagingSource.
A Figura 4 ilustra o processo de carregamento de dados primeiro pelo banco de dados local e, em seguida, pela rede quando os bancos de dados estão vazios.
Outros recursos
Para saber mais sobre a biblioteca Paging, consulte os seguintes recursos extras:
Codelabs
Exemplos
- Exemplo de paginação de Componentes da arquitetura do Android com o banco de dados e a rede (link em inglês)
Recomendados para você
- Observação: o texto do link aparece quando o JavaScript está desativado.
- Carregar e exibir dados paginados
- Testar a implementação da Paging
- Migrar para a Paging 3