Cómo cargar y mostrar datos paginados

La biblioteca de Paging proporciona capacidades potentes para cargar y mostrar datos paginados desde un conjunto de datos más grande. En esta guía, se muestra cómo usar la biblioteca de Paging para configurar un flujo de datos paginados de una fuente de datos de red y mostrarlos en una RecyclerView.

Cómo definir una fuente de datos

El primer paso es definir una implementación de PagingSource para identificar la fuente de datos. La clase de API PagingSource incluye el método load(), que debes anular para indicar cómo recuperar datos paginados de la fuente de datos correspondiente.

Usa la clase PagingSource directamente para utilizar las corrutinas de Kotlin para la carga asíncrona. La biblioteca de Paging también proporciona clases para admitir otros frameworks asíncronos:

Cómo seleccionar tipos de clave y de valor

PagingSource<Key, Value> tiene dos parámetros de tipo: Key y Value. La clave define el identificador que se usa para cargar los datos, y el valor es el tipo de los datos en sí. Por ejemplo, si cargas páginas de objetos User desde la red pasando números de página Int a Retrofit, selecciona Int como el tipo Key y User como el tipo Value.

Cómo definir PagingSource

En el siguiente ejemplo, se implementa un PagingSource que carga páginas de elementos por número de página. El tipo de Key es Int y el tipo de Value es User.

Kotlin

class ExamplePagingSource(
    val backend: ExampleBackendService,
    val query: String
) : PagingSource<Int, User>() {
  override suspend fun load(
    params: LoadParams<Int>
  ): LoadResult<Int, User> {
    try {
      // Start refresh at page 1 if undefined.
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber)
      return LoadResult.Page(
        data = response.users,
        prevKey = null, // Only paging forward.
        nextKey = response.nextPageNumber
      )
    } catch (e: Exception) {
      // Handle errors in this block and return LoadResult.Error for
      // expected errors (such as a network failure).
    }
  }

  override fun getRefreshKey(state: PagingState<Int, User>): Int? {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    return state.anchorPosition?.let { anchorPosition ->
      val anchorPage = state.closestPageToPosition(anchorPosition)
      anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
    }
  }
}

Java

class ExamplePagingSource extends RxPagingSource<Integer, User> {
  @NonNull
  private ExampleBackendService mBackend;
  @NonNull
  private String mQuery;

  ExamplePagingSource(@NonNull ExampleBackendService backend,
    @NonNull String query) {
    mBackend = backend;
    mQuery = query;
  }

  @NotNull
  @Override
  public Single<LoadResult<Integer, User>> loadSingle(
    @NotNull LoadParams<Integer> params) {
    // Start refresh at page 1 if undefined.
    Integer nextPageNumber = params.getKey();
    if (nextPageNumber == null) {
      nextPageNumber = 1;
    }

    return mBackend.searchUsers(mQuery, nextPageNumber)
      .subscribeOn(Schedulers.io())
      .map(this::toLoadResult)
      .onErrorReturn(LoadResult.Error::new);
  }

  private LoadResult<Integer, User> toLoadResult(
    @NonNull SearchUserResponse response) {
    return new LoadResult.Page<>(
      response.getUsers(),
      null, // Only paging forward.
      response.getNextPageNumber(),
      LoadResult.Page.COUNT_UNDEFINED,
      LoadResult.Page.COUNT_UNDEFINED);
  }

  @Nullable
  @Override
  public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    Integer anchorPosition = state.getAnchorPosition();
    if (anchorPosition == null) {
      return null;
    }

    LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
    if (anchorPage == null) {
      return null;
    }

    Integer prevKey = anchorPage.getPrevKey();
    if (prevKey != null) {
      return prevKey + 1;
    }

    Integer nextKey = anchorPage.getNextKey();
    if (nextKey != null) {
      return nextKey - 1;
    }

    return null;
  }
}

Java

class ExamplePagingSource extends ListenableFuturePagingSource<Integer, User> {
  @NonNull
  private ExampleBackendService mBackend;
  @NonNull
  private String mQuery;
  @NonNull
  private Executor mBgExecutor;

  ExamplePagingSource(
    @NonNull ExampleBackendService backend,
    @NonNull String query, @NonNull Executor bgExecutor) {
    mBackend = backend;
    mQuery = query;
    mBgExecutor = bgExecutor;
  }

  @NotNull
  @Override
  public ListenableFuture<LoadResult<Integer, User>> loadFuture(@NotNull LoadParams<Integer> params) {
    // Start refresh at page 1 if undefined.
    Integer nextPageNumber = params.getKey();
    if (nextPageNumber == null) {
      nextPageNumber = 1;
    }

    ListenableFuture<LoadResult<Integer, User>> pageFuture =
      Futures.transform(mBackend.searchUsers(mQuery, nextPageNumber),
      this::toLoadResult, mBgExecutor);

    ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture =
      Futures.catching(pageFuture, HttpException.class,
      LoadResult.Error::new, mBgExecutor);

    return Futures.catching(partialLoadResultFuture,
      IOException.class, LoadResult.Error::new, mBgExecutor);
  }

  private LoadResult<Integer, User> toLoadResult(@NonNull SearchUserResponse response) {
    return new LoadResult.Page<>(response.getUsers(),
    null, // Only paging forward.
    response.getNextPageNumber(),
    LoadResult.Page.COUNT_UNDEFINED,
    LoadResult.Page.COUNT_UNDEFINED);
  }

  @Nullable
  @Override
  public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    Integer anchorPosition = state.getAnchorPosition();
    if (anchorPosition == null) {
      return null;
    }

    LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
    if (anchorPage == null) {
      return null;
    }

    Integer prevKey = anchorPage.getPrevKey();
    if (prevKey != null) {
      return prevKey + 1;
    }

    Integer nextKey = anchorPage.getNextKey();
    if (nextKey != null) {
      return nextKey - 1;
    }

    return null;
  }
}

Una implementación típica de PagingSource pasa parámetros proporcionados en su constructor al método load() para cargar los datos apropiados para una búsqueda. En el ejemplo anterior, esos parámetros son los siguientes:

  • backend: Instancia del servicio de backend que proporciona los datos
  • query: La búsqueda para enviar al servicio indicado por backend

El objeto LoadParams contiene información sobre la operación de carga que se realizará. Esto incluye la clave y la cantidad de elementos que se cargarán.

El objeto LoadResult contiene el resultado de la operación de carga. LoadResult es una clase sellada que toma una de dos posibles formas en virtud de si la llamada a load() se realizó correctamente o no:

  • Si la carga se realizó correctamente, devuelve un objeto LoadResult.Page.
  • Si no se realizó correctamente, devuelve un objeto LoadResult.Error.

En la siguiente figura, se ilustra el modo en que la función load() de este ejemplo recibe la clave para cada carga y proporciona la clave de la carga posterior.

En cada llamada a load(), ExamplePagingSource toma la clave actual y devuelve la siguiente que se cargará.
Figura 1: Diagrama que muestra cómo load() usa y actualiza la clave

La implementación de PagingSource también debe implementar un método getRefreshKey() que tome un objeto PagingState como parámetro. Devuelve la clave para pasar al método load() cuando los datos se actualizan o se invalidan después de la carga inicial. La biblioteca de Paging llama a este método automáticamente en las actualizaciones posteriores de los datos.

Cómo solucionar errores

Las solicitudes de carga de datos pueden fallar por varias razones, en especial cuando la carga se realiza a través de una red. Para informar errores detectados durante la carga, devuelve un objeto LoadResult.Error desde el método load().

Por ejemplo, puedes detectar e informar errores de carga en ExamplePagingSource del ejemplo anterior si agregas lo siguiente al método load():

Kotlin

catch (e: IOException) {
  // IOException for network failures.
  return LoadResult.Error(e)
} catch (e: HttpException) {
  // HttpException for any non-2xx HTTP status codes.
  return LoadResult.Error(e)
}

Java

return backend.searchUsers(searchTerm, nextPageNumber)
  .subscribeOn(Schedulers.io())
  .map(this::toLoadResult)
  .onErrorReturn(LoadResult.Error::new);

Java

ListenableFuture<LoadResult<Integer, User>> pageFuture = Futures.transform(
  backend.searchUsers(query, nextPageNumber), this::toLoadResult,
  bgExecutor);

ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture = Futures.catching(
  pageFuture, HttpException.class, LoadResult.Error::new,
  bgExecutor);

return Futures.catching(partialLoadResultFuture,
  IOException.class, LoadResult.Error::new, bgExecutor);

Para obtener más información sobre la solución de errores de Retrofit, consulta los ejemplos en la referencia de la API de PagingSource.

PagingSource recopila y entrega objetos LoadResult.Error a la IU para que puedas realizar acciones sobre ellos. Si deseas obtener más información para exponer el estado de carga en la IU, consulta Cómo administrar y presentar estados de carga.

Cómo configurar el flujo de PagingData

A continuación, necesitas un flujo de datos paginados desde la implementación de PagingSource. Configura el flujo de datos en tu ViewModel. La clase Pager proporciona métodos que exponen un flujo reactivo de objetos PagingData desde PagingSource. La biblioteca de Paging admite el uso de varios tipos de flujo, incluidos Flow, LiveData y los tipos Flowable y Observable de RxJava.

Cuando creas una instancia de Pager para configurar tu flujo reactivo, debes proporcionarle un objeto de configuración PagingConfig y una función que le indique a Pager cómo obtener una instancia de tu implementación de PagingSource:

Kotlin

val flow = Pager(
  // Configure how data is loaded by passing additional properties to
  // PagingConfig, such as prefetchDistance.
  PagingConfig(pageSize = 20)
) {
  ExamplePagingSource(backend, query)
}.flow
  .cachedIn(viewModelScope)

Java

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
  new PagingConfig(/* pageSize = */ 20),
  () -> ExamplePagingSource(backend, query));

Flowable<PagingData<User>> flowable = PagingRx.getFlowable(pager);
PagingRx.cachedIn(flowable, viewModelScope);

Java

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
  new PagingConfig(/* pageSize = */ 20),
  () -> ExamplePagingSource(backend, query));

PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), viewModelScope);

El operador cachedIn() permite que el flujo de datos se pueda compartir y almacena en caché los datos cargados con el CoroutineScope proporcionado. En este ejemplo, se usa el viewModelScope que proporciona el artefacto lifecycle-viewmodel-ktx del ciclo de vida.

El objeto Pager llama al método load() desde el objeto PagingSource, lo cual hace que le proporcione el objeto LoadParams y que reciba el objeto LoadResult a cambio.

Cómo definir un adaptador RecyclerView

También debes configurar un adaptador para recibir los datos en tu lista RecyclerView. A tal fin, la biblioteca de Paging proporciona la clase PagingDataAdapter.

Define una clase que extienda PagingDataAdapter. En el ejemplo, UserAdapter extiende PagingDataAdapter para proporcionar un adaptador RecyclerView para los elementos de lista de tipo User y usa UserViewHolder como contenedor de vistas:

Kotlin

class UserAdapter(diffCallback: DiffUtil.ItemCallback<User>) :
  PagingDataAdapter<User, UserViewHolder>(diffCallback) {
  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ): UserViewHolder {
    return UserViewHolder(parent)
  }

  override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
    val item = getItem(position)
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item)
  }
}

Java

class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
  UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
    super(diffCallback);
  }

  @NonNull
  @Override
  public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    return new UserViewHolder(parent);
  }

  @Override
  public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
    User item = getItem(position);
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item);
  }
}

Java

class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
  UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
    super(diffCallback);
  }

  @NonNull
  @Override
  public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    return new UserViewHolder(parent);
  }

  @Override
  public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
    User item = getItem(position);
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item);
  }
}

El adaptador también debe definir los métodos onCreateViewHolder() y onBindViewHolder(), y especificar una DiffUtil.ItemCallback. Esto funciona como lo hace normalmente cuando defines los adaptadores de lista RecyclerView:

Kotlin

object UserComparator : DiffUtil.ItemCallback<User>() {
  override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
    // Id is unique.
    return oldItem.id == newItem.id
  }

  override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
    return oldItem == newItem
  }
}

Java

class UserComparator extends DiffUtil.ItemCallback<User> {
  @Override
  public boolean areItemsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    // Id is unique.
    return oldItem.id.equals(newItem.id);
  }

  @Override
  public boolean areContentsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    return oldItem.equals(newItem);
  }
}

Java

class UserComparator extends DiffUtil.ItemCallback<User> {
  @Override
  public boolean areItemsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    // Id is unique.
    return oldItem.id.equals(newItem.id);
  }

  @Override
  public boolean areContentsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    return oldItem.equals(newItem);
  }
}

Cómo mostrar los datos paginados en tu IU

Ahora que definiste una PagingSource, creaste una forma para que tu app genere un flujo de PagingData y definiste un PagingDataAdapter, está todo listo para que conectes estos elementos y muestres los datos paginados en tu actividad.

Realiza los siguientes pasos en el método onCreate de la actividad o del onViewCreated del fragmento:

  1. Crea una instancia de tu clase PagingDataAdapter.
  2. Pasa la instancia PagingDataAdapter a la lista RecyclerView que deseas que muestre tus datos paginados.
  3. Observa el flujo de PagingData y pasa cada valor generado al método submitData() del adaptador.

Kotlin

val viewModel by viewModels<ExampleViewModel>()

val pagingAdapter = UserAdapter(UserComparator)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter

// Activities can use lifecycleScope directly; fragments use
// viewLifecycleOwner.lifecycleScope.
lifecycleScope.launch {
  viewModel.flow.collectLatest { pagingData ->
    pagingAdapter.submitData(pagingData)
  }
}

Java

ExampleViewModel viewModel = new ViewModelProvider(this)
  .get(ExampleViewModel.class);

UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
  R.id.recycler_view);
recyclerView.adapter = pagingAdapter

viewModel.flowable
  // Using AutoDispose to handle subscription lifecycle.
  // See: https://github.com/uber/AutoDispose.
  .to(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
  .subscribe(pagingData -> pagingAdapter.submitData(lifecycle, pagingData));

Java

ExampleViewModel viewModel = new ViewModelProvider(this)
  .get(ExampleViewModel.class);

UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
  R.id.recycler_view);
recyclerView.adapter = pagingAdapter

// Activities can use getLifecycle() directly; fragments use
// getViewLifecycleOwner().getLifecycle().
viewModel.liveData.observe(this, pagingData ->
  pagingAdapter.submitData(getLifecycle(), pagingData));

La lista RecyclerView ahora muestra los datos paginados de la fuente de datos y carga automáticamente otra página cuando es necesario.

Recursos adicionales

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

Codelabs

Ejemplos