Página de la red y la base de datos

Para brindar una experiencia del usuario mejorada, asegúrate de que tu app pueda usarse cuando no haya conexiones de red confiables o cuando el usuario no tenga conexión. Una forma de hacerlo es realizar la paginación desde la red y desde una base de datos local al mismo tiempo. De esta manera, tu app controla la IU desde una caché de base de datos local y solo realiza solicitudes a la red cuando no hay más datos en la base de datos.

En esta guía, se asume que conoces la biblioteca de persistencias Room y el uso básico de la biblioteca de Paging.

Coordina las cargas de datos

La biblioteca de Paging proporciona el componente RemoteMediator para este caso de uso. RemoteMediator actúa como un indicador de la biblioteca de Paging cuando la app se quedó sin datos almacenados en caché. Puedes usar este indicador para cargar datos adicionales de la red y almacenarlos en la base de datos local, donde una PagingSource puede cargarlos y proporcionarlos a la IU que se mostrará.

Cuando se necesitan datos adicionales, la biblioteca de Paging llama al método load() desde la implementación de RemoteMediator. Esta es una función de suspensión, por lo que realizar un trabajo de larga duración resulta seguro. En general, esta función recupera los datos nuevos de una fuente de red y los guarda en el almacenamiento local.

Este proceso funciona con datos nuevos, pero, con el tiempo, los datos almacenados en la base de datos requieren una invalidación, como cuando el usuario activa una actualización de forma manual. Esto se representa mediante la propiedad LoadType que se pasa al método load(). El LoadType informa al RemoteMediator si necesita actualizar los datos existentes o recuperar datos adicionales que deben posponerse o anteponerse a la lista existente.

De esta manera, el RemoteMediator garantiza que tu app cargue los datos que los usuarios desean ver en el orden adecuado.

Ciclo de vida de Paging

Figura 1: Diagrama del ciclo de vida de Paging con PagingSource y PagingData

Cuando realizas la paginación directamente desde la red, la PagingSource carga los datos y muestra un objeto LoadResult. La implementación de PagingSource se pasa al Pager mediante el parámetro pagingSourceFactory.

Dado que la IU requiere datos nuevos, el Pager llama al método load() desde la PagingSource y muestra un flujo de objetos PagingData que encapsulan los datos nuevos. Por lo general, cada objeto PagingData se almacena en caché en ViewModel antes de enviarse a la IU que se mostrará.

Figura 2: Diagrama del ciclo de vida de Paging con PagingSource y RemoteMediator

RemoteMediator cambia este flujo de datos. Una PagingSource seguirá cargando los datos; pero cuando se agoten los datos paginados, la biblioteca de Paging activará el RemoteMediator para cargar datos nuevos desde la fuente de red. El RemoteMediator almacenará los datos nuevos en la base de datos local, por lo que no será necesario crear una memoria caché en el objeto ViewModel. Por último, la PagingSource se invalida a sí misma, y el Pager crea una instancia nueva a los efectos de cargar los datos nuevos desde la base de datos.

Uso básico

Supongamos que deseas que la app cargue páginas de elementos de User desde una fuente de datos de red con elementos protegidos por clave en una caché local almacenada en una base de datos de Room.

RemoteMediator carga los datos de la red a la base de datos, y PagingSource carga los datos de la base de datos. Pager ahora usa tanto RemoteMediator como PagingSource para cargar datos paginados.
Figura 3: Diagrama de una implementación de Paging que usa una fuente de datos en capas

Una implementación de RemoteMediator ayuda a cargar datos paginados de la red en la base de datos, pero no los carga directamente en la IU. En cambio, la app usa la base de datos como fuente de confianza. En otras palabras, la app solo muestra los datos almacenados en la caché de la base de datos. Una implementación de PagingSource (por ejemplo, una generada por Room) controla la carga de datos almacenados en caché de la base de datos a la IU.

Crea entidades de Room

El primer paso es usar la biblioteca de persistencias Room para definir una base de datos que tenga una caché local de datos paginados desde la fuente de datos de red. Comienza con una implementación de RoomDatabase, como se describe en Cómo guardar contenido en una base de datos local con Room.

A continuación, define una entidad de Room para representar una tabla de elementos de lista, como se describe en Cómo definir datos con entidades de Room. Proporciona un campo id como clave primaria, así como campos para cualquier otra información que contengan los elementos de lista.

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;
}

También debes definir un objeto de acceso a datos (DAO) para esta entidad de Room, como se describe en Cómo acceder a los datos con DAO de Room. El DAO para la entidad del elemento de lista debe incluir los siguientes métodos:

  • Un método insertAll() que inserta una lista de elementos en la tabla
  • Un método que toma la cadena de consulta como parámetro y muestra un objeto PagingSource para la lista de resultados. De esta manera, un objeto Pager puede usar esta tabla como una fuente de datos paginados
  • Un método clearAll() que borra todos los datos de la tabla

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();
}

Implementa un RemoteMediator

La función principal de RemoteMediator es cargar más datos de la red cuando Pager se queda sin datos o se invalidan los existentes. Incluye un método load() que debes anular para definir el comportamiento de carga.

Una implementación de RemoteMediator típica incluye los siguientes parámetros:

  • query: una cadena de consulta que define qué datos se recuperan del servicio de backend
  • database: la base de datos de Room que funciona como una caché local
  • networkService: una instancia de API para el servicio de backend

Crea una implementación de RemoteMediator<Key, Value>. Los tipos Key y Value deben ser los mismos que si estuvieras definiendo un PagingSource en la misma fuente de datos de red. Si quieres obtener más información para seleccionar parámetros de tipo, consulta Selecciona tipos de clave y de 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
  ) {
    ...
  }
}

El método load() es responsable de actualizar el conjunto de datos de copia de seguridad y de invalidar la PagingSource. Algunas bibliotecas que admiten paginación (como Room) controlarán automáticamente la invalidación de los objetos PagingSource que implementen.

El método load() adopta dos parámetros:

  • PagingState, que contiene información sobre las páginas que se cargaron hasta el momento, el índice al que se accedió de forma más reciente y el objeto PagingConfig que usaste para inicializar el flujo de paginación
  • LoadType, que indica el tipo de carga: REFRESH, APPEND o PREPEND

El valor que se muestra del método load() es un objeto MediatorResult. MediatorResult puede ser MediatorResult.Error (que incluye la descripción del error) o MediatorResult.Success (que incluye una señal que indica si hay más datos para cargar o no).

El método load() debe realizar los siguientes pasos:

  1. Determinar qué página cargar de la red según el tipo de carga y los datos que se cargaron hasta el momento
  2. Activar la solicitud de red
  3. Realiza acciones según el resultado de la operación de carga:
    • Si la carga se realiza correctamente y la lista recibida de elementos no está vacía, almacenará los elementos de lista en la base de datos y mostrará MediatorResult.Success(endOfPaginationReached = false). Una vez que se almacenen los datos, invalida la fuente de datos para notificar a la biblioteca de Paging acerca de los datos nuevos.
    • Si la carga se realiza correctamente y la lista recibida de elementos está vacía o bien contiene el índice de la última página, muestra MediatorResult.Success(endOfPaginationReached = true). Una vez que se almacenen los datos, invalida la fuente de datos para notificar a la biblioteca de Paging acerca de los datos nuevos.
    • Si la solicitud genera un error, mostrará 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
  );
}

Cómo definir el método de inicialización

Las implementaciones de RemoteMediator también pueden anular el método initialize() para comprobar si los datos almacenados en caché están desactualizados y decidir si activar una actualización remota. Este método se ejecuta antes de que se realice cualquier carga, por lo que puedes manipular la base de datos (por ejemplo, a los efectos de borrar datos antiguos) antes de activar cualquier carga local o remota.

Dado que initialize() es una función asíncrona, puedes cargar datos para determinar la relevancia de los que ya existan en la base de datos. El caso más común es que los datos almacenados en caché solo resulten válidos durante un período determinado. El RemoteMediator puede comprobar si se alcanzó el plazo de vencimiento, en cuyo caso la biblioteca de Paging necesitará actualizar los datos por completo. Las implementaciones de initialize() deben mostrar un objeto InitializeAction de la siguiente manera:

  • En los casos en que los datos locales deban actualizarse en su totalidad, initialize() deberá mostrar InitializeAction.LAUNCH_INITIAL_REFRESH. Esto hará que RemoteMediator realice una actualización remota para volver a cargar los datos por completo. Cualquier carga remota de tipo APPEND o PREPEND esperará a que la carga REFRESH tenga éxito antes de continuar.
  • Cuando no resulte necesario actualizar los datos locales, initialize() deberá mostrar InitializeAction.SKIP_INITIAL_REFRESH. Esto hará que RemoteMediator omita la actualización remota y cargue los datos almacenados en caché.

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);
}

Crea una Pager

Por último, debes crear una instancia de Pager para configurar el flujo de datos paginados. Esto es similar a crear un Pager a partir de una fuente de datos de red simple, pero hay dos cosas que debes hacer de manera diferente:

  • En lugar de pasar un constructor PagingSource directamente, debes proporcionar el método de consulta que muestre un objeto PagingSource del DAO.
  • Debes proporcionar una instancia de tu implementación de RemoteMediator como el 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));

Controla las condiciones de carrera

Una situación que tu app debe controlar cuando cargue datos desde varias fuentes es el caso en el que se desincronizan los datos locales almacenados en caché y la fuente de datos remota.

Cuando el método initialize() de tu implementación de RemoteMediator muestra LAUNCH_INITIAL_REFRESH, los datos pasan a estar desactualizados y deberán reemplazarse por datos nuevos. Cualquier solicitud de carga de tipo PREPEND o APPEND se verá forzada a esperar a que la carga remota REFRESH se complete correctamente. Dado que las solicitudes de PREPEND o APPEND se pusieron en cola antes que la solicitud de REFRESH, es posible que el PagingState que se pasó a esas llamadas de carga esté desactualizado al momento en que estas se ejecuten.

En función de la manera en que se almacenen los datos de forma local, tu app puede ignorar las solicitudes redundantes si los cambios en los datos almacenados en caché provocan una invalidación y la recuperación de datos nuevos. Por ejemplo, Room invalida las búsquedas sobre cualquier inserción de datos. Esto significa que los objetos PagingSource nuevos con los datos actualizados se proporcionarán a las solicitudes de carga pendientes cuando se inserten datos nuevos en la base de datos.

Resolver este problema de sincronización de datos resulta esencial para garantizar que los usuarios vean los datos más relevantes y actualizados. La mejor solución depende principalmente de la forma en que la fuente de datos de red realiza la paginación de los datos. En cualquier caso, las claves remotas te permiten guardar información sobre la página más reciente solicitada desde el servidor. Tu app puede usar esta información para identificar y solicitar la página correcta de los datos que se cargarán a continuación.

Cómo administrar claves remotas

Las claves remotas son aquellas que usa una implementación de RemoteMediator para indicarle al servicio de backend qué datos debe cargar a continuación. En el caso más simple, cada elemento de los datos paginados incluye una clave remota a la que puedes hacer referencia fácilmente. Sin embargo, si las claves remotas no corresponden a elementos individuales, debes almacenarlas por separado y administrarlas en tu método load().

En esta sección, se describe cómo recopilar, almacenar y actualizar claves remotas que no se almacenan en elementos individuales.

Claves de elementos

En esta sección, se describe cómo trabajar con claves remotas que corresponden a elementos individuales. Por lo general, cuando en una API se tienen claves asociadas a elementos individuales, el ID del elemento se pasa como un parámetro de consulta. El nombre del parámetro indica si el servidor debe responder con elementos antes o después del ID proporcionado. En el ejemplo de la clase modelo User, el campo id del servidor se usa como clave remota cuando se solicitan datos adicionales.

Cuando tu método load() necesita administrar claves remotas específicas de elementos, estas claves suelen ser los ID de los datos recuperados del servidor. Las operaciones de actualización no necesitan una clave de carga, ya que solo recuperan los datos más recientes. Del mismo modo, las operaciones de anteposición no necesitan recuperar datos adicionales porque la actualización siempre extrae los datos más recientes del servidor.

Sin embargo, las operaciones de posposición sí necesitan un ID. Esto requiere que cargues el último elemento de la base de datos y uses su ID para cargar la siguiente página de datos. Si no hay elementos en la base de datos, endOfPaginationReached se establece como verdadero, lo que indica que se necesita una actualización de datos.

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);
}

Claves de paginación

En esta sección, se describe cómo trabajar con claves remotas que no están asociadas a elementos individuales.

Cómo agregar una tabla de claves remotas

Cuando las claves remotas no están directamente asociadas a los elementos de lista, es mejor almacenarlas en una tabla separada, en la base de datos local. Define una entidad de Room que represente una tabla de claves 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;
}

También debes definir un DAO para la entidad 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);
}

Cómo realizar una carga mediante claves remotas

Cuando tu método load() necesite administrar claves remotas, deberás definirlas de manera diferente en comparación con el uso básico de RemoteMediator:

  • Incluye una propiedad adicional que contenga una referencia al DAO en tu tabla de claves remotas.
  • Para determinar qué clave se debe cargar a continuación, consulta la tabla de claves remotas en lugar de usar PagingState.
  • Inserta o almacena la clave remota que se muestra desde la fuente de datos de red, además de los datos 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);
}

Realiza actualizaciones en el lugar

Si tu app solo necesita admitir actualizaciones de red desde la parte superior de la lista como en los ejemplos anteriores, tu RemoteMediator no necesita definir un comportamiento de carga preparado.

Sin embargo, si tu app necesita compatibilidad con la carga gradual desde la red hacia la base de datos local, debes admitir la reanudación de la paginación desde el ancla, la posición del desplazamiento del usuario. La implementación de PagingSource de Room se encarga de esto por ti, pero si no usas Room, puedes hacerlo anulando PagingSource.getRefreshKey(). Si deseas ver una implementación de ejemplo de getRefreshKey(), consulta Cómo definir la PagingSource.

En la Figura 4, se ilustra el proceso de carga de datos, primero desde la base de datos local y, luego, desde la red cuando la base de datos ya no tenga más datos.

PagingSource realiza la carga de la base de datos a la IU hasta que esta base ya no tenga más datos. Luego, RemoteMediator realiza la carga de la red a la base de datos y, después, PagingSource continúa cargando.
Figura 4: Diagrama que muestra cómo PagingSource y RemoteMediator trabajan en conjunto para cargar datos

Recursos adicionales

Para obtener más información sobre la biblioteca de Paging, consulta los siguientes recursos adicionales:

Codelabs

Ejemplos