Página da rede e do banco de dados

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

Figura 1. Diagrama do ciclo de vida da Paging com PagingSource e PagingData.

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.

Figura 2. Diagrama do ciclo de vida da Paging com PagingSource e RemoteMediator.

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.

O RemoteMediator carrega dados da rede no banco de dados, e a
    PagingSource carrega dados do banco de dados. Um Pager usa o
    RemoteMediator e a PagingSource para carregar dados paginados.
Figura 3. Diagrama de uma implementação da Paging que usa uma fonte de dados em camadas.

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 objeto Pager 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 objeto PagingConfig usado para inicializar o fluxo de paginação.
  • LoadType, que indica o tipo de carga: REFRESH, APPEND ou PREPEND.

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:

  1. Determinar qual página carregar da rede, dependendo do tipo de carga e dos dados que foram carregados até o momento.
  2. Acionar a solicitação de rede.
  3. Realizar 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.

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 o RemoteMediator execute uma atualização remota para recarregar os dados totalmente. Qualquer carregamento remoto de APPEND ou PREPEND aguarda a conclusão do carregamento de REFRESH 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 o RemoteMediator 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 objeto PagingSource do DAO.
  • É preciso fornecer uma instância da implementação de RemoteMediator como o parâmetro remoteMediator.

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.

A PagingSource carrega do banco de dados para a IU até que os bancos de dados
    fiquem vazios. Em seguida, o RemoteMediator é carregado da rede para o
    banco de dados e, depois disso, a PagingSource continua carregando.
Figura 4. Diagrama mostrando como a PagingSource e o RemoteMediator trabalham juntos para carregar dados.

Outros recursos

Para saber mais sobre a biblioteca Paging, consulte os seguintes recursos extras:

Codelabs

Exemplos