Conceptos básicos de Paging de Android

1. Introducción

Qué aprenderás

  • Cuáles son los componentes principales de la biblioteca de Paging
  • Cómo agregar la biblioteca de Paging a tu proyecto

Qué compilarás

En este codelab, comenzarás con una app de ejemplo que ya muestra una lista de artículos. La lista es estática, tiene 500 artículos y todos se guardan en la memoria del teléfono:

7d256d9c74e3b3f5.png

A medida que avances en el codelab, harás lo siguiente:

  • Comprenderás el concepto de "paginación".
  • Conocerás los componentes básicos de la biblioteca de Paging.
  • Aprenderás cómo implementar la paginación con la biblioteca de Paging.

Cuando termines, tendrás una app que podrá hacer lo siguiente:

  • Implementar la paginación correctamente
  • Comunicarse de manera efectiva con el usuario cuando se recuperan más datos

A continuación, se incluye una vista previa de la IU con la que terminaremos:

6277154193f7580.gif

Requisitos

Deseable

2. Configura el entorno

En este paso, descargarás el código para todo el codelab y, luego, ejecutarás una app de ejemplo simple.

Para que comiences lo antes posible, preparamos un proyecto inicial sobre el cual puedes seguir creando.

Si tienes Git instalado, simplemente puedes ejecutar el comando que se indica abajo. Para comprobarlo, escribe git --version en la terminal o línea de comandos, y verifica que se ejecute correctamente.

 git clone https://github.com/googlecodelabs/android-paging

Si no tienes Git, puedes hacer clic en el siguiente botón para descargar todo el código de este codelab:

El código se organiza en dos carpetas: basic y advanced. Para este codelab, solo nos preocupa la carpeta basic.

En la carpeta basic, también hay dos carpetas más: start y end. Comenzaremos a trabajar en el código de la carpeta start y, al final del codelab, el código de la carpeta start debería ser idéntico al de la carpeta end.

  1. Abre el proyecto en el directorio basic/start de Android Studio.
  2. Ejecuta la configuración de ejecución app en un dispositivo o emulador.

89af884fa2d4e709.png

Deberías ver una lista de artículos. Desplázate hasta el final para verificar que la lista sea estática. En otras palabras, no se recuperan más elementos cuando llegamos al final de la lista. Vuelve a la parte superior para verificar que aún tengamos todos nuestros elementos.

3. Introducción a la paginación

Una de las formas más comunes de mostrar información a los usuarios es usando listas. Sin embargo, a veces, estas listas ofrecen una pequeña ventana a todo el contenido disponible para el usuario. A medida que el usuario se desplaza por la información disponible, suele esperarse que se recuperen más datos para complementar la información que ya se vio. Cada vez que se recuperan datos, deben ser eficientes y fluidos para que las cargas incrementales no resten valor a la experiencia del usuario. Las cargas incrementales también ofrecen un beneficio de rendimiento, ya que la app no necesita retener grandes cantidades de datos en la memoria al mismo tiempo.

Este proceso de recuperación incremental de datos se denomina paginación. En él, cada página corresponde a un fragmento de datos que se debe recuperar. Para solicitar una página, la fuente de datos que se pagina suele requerir una consulta que defina la información requerida. En el resto de este codelab, presentaremos la biblioteca de Paging y demostraremos el modo en que te ayuda a implementar la paginación en tu app de manera rápida y eficiente.

Componentes principales de la biblioteca de Paging

Los componentes principales de la biblioteca de Paging son los siguientes:

  • PagingSource: La clase base cuya función es cargar fragmentos de datos para una búsqueda de página específica. Es parte de la capa de datos y, por lo general, se expone desde una clase DataSource y, luego, Repository para usarla en ViewModel.
  • PagingConfig: Es una clase que define los parámetros que determinan el comportamiento de la paginación. Esto incluye el tamaño de la página, si los marcadores de posición están habilitados, etcétera.
  • Pager: Es una clase responsable de producir el flujo de PagingData. Depende de PagingSource para hacer esto y se debe crear en ViewModel.
  • PagingData: Es un contenedor para datos paginados. Cada actualización de datos tendrá una emisión de PagingData correspondiente por separado, respaldada por su propia PagingSource.
  • PagingDataAdapter: Es una subclase RecyclerView.Adapter que presenta PagingData en un RecyclerView. Se puede conectar PagingDataAdapter a un Flow de Kotlin, un LiveData, un Flowable de RxJava, un Observable de RxJava o incluso una lista estática con métodos de fábrica. El elemento PagingDataAdapter escucha eventos de carga PagingData internos y actualiza la IU de manera eficiente a medida que se cargan las páginas.

566d0f6506f39480.jpeg

En las siguientes secciones, implementarás ejemplos de cada uno de los componentes descritos arriba.

4. Descripción general del proyecto

La app en su formato actual muestra una lista estática de artículos. Cada artículo tiene un título, una descripción y la fecha de creación. Una lista estática funciona bien para una cantidad pequeña de elementos, pero no se ajusta correctamente a medida que los conjuntos de datos se hacen más grandes. Para solucionar ese problema, implementaremos la paginación con la biblioteca de Paging, pero primero revisaremos los componentes que ya están en la app.

La app sigue la arquitectura recomendada en la Guía de arquitectura de apps. En cada paquete, encontrarás lo siguiente:

Capa de datos:

  • ArticleRepository: Es responsable de proporcionar la lista de artículos y mantenerlos en la memoria.
  • Article: Es una clase que representa el modelo de datos, una representación de la información extraída de la capa de datos.

Capa de la IU:

  • Activity, RecyclerView.Adapter y RecyclerView.ViewHolder: Clases responsables de mostrar la lista en la IU.
  • ViewModel: El contenedor de estado responsable de crear el estado que debe mostrar la IU.

El repositorio expone todos sus artículos en un Flow con el campo articleStream. Esto, a su vez, lo lee el elemento ArticleViewModel en la capa de la IU, que luego lo prepara para el consumo por parte de la IU en ArticleActivity con el campo state, un StateFlow.

La exposición de artículos como un Flow del repositorio permite que el repositorio actualice los artículos que se presentan a medida que cambian con el tiempo. Por ejemplo, si se cambia el título de un artículo, se puede comunicar fácilmente ese cambio a los recopiladores de articleStream. El uso de un StateFlow para el estado de la IU en ViewModel garantiza que, incluso si dejamos de recopilar el estado de la IU, por ejemplo, cuando se recrea la Activity durante un cambio de configuración, podemos retomarla justo donde la dejamos cuando comenzamos a recopilarla de nuevo.

Como se mencionó antes, el elemento articleStream actual del repositorio solo presenta noticias del día actual. Si bien esto es suficiente para algunos usuarios, es posible que otros quieran ver artículos más antiguos cuando se hayan desplazado por todos los artículos disponibles del día actual. Esta expectativa hace que la visualización de artículos sea un gran candidato para la paginación. Otros motivos por los que deberíamos explorar la paginación a través de los artículos son los siguientes:

  • ViewModel mantiene todos los elementos cargados en la memoria del StateFlow de items. Esta es una gran preocupación cuando el conjunto de datos se vuelve muy grande, ya que puede afectar el rendimiento.
  • La actualización de uno o más artículos de la lista cuando cambiaron se vuelve más costoso mientras más grande sea la lista de artículos.

La biblioteca de Paging ayuda a resolver todos estos problemas y proporciona una API coherente para recuperar datos de manera incremental (la paginación) en las apps.

5. Define la fuente de datos

Cuando implementamos la paginación, queremos asegurarnos de que se cumplan las siguientes condiciones:

  • El manejo correcto de las solicitudes de los datos desde la IU para garantizar que no se activen varias solicitudes al mismo tiempo para la misma consulta
  • Una cantidad administrable de datos recuperados en la memoria
  • La activación de solicitudes para recuperar más datos a fin de complementar los datos que ya se recuperaron

Podemos lograr todo esto con una PagingSource. Un PagingSource define la fuente de los datos especificando cómo recuperar datos en fragmentos incrementales. El objeto PagingData busca los datos de la PagingSource en respuesta a la carga de sugerencias que se generan a medida que el usuario se desplaza en una RecyclerView.

Nuestro PagingSource cargará artículos. En data/Article.kt, encontrarás el modelo definido de la siguiente manera:

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

Para compilar la PagingSource, deberás definir lo siguiente:

  • El tipo de clave de paginación: La definición del tipo de búsqueda de página que usamos para solicitar más datos. En nuestro caso, recuperamos artículos antes o después de un determinado ID del artículo, ya que se garantiza que los ID estén en orden y vayan en aumento.
  • Tipo de datos cargados: Cada página muestra un List de artículos, por lo que el tipo es Article.
  • La ubicación desde la que se recuperan los datos: Por lo general, se trata de una base de datos, un recurso de red o cualquier otra fuente de datos paginados. Sin embargo, en el caso de este codelab, usamos datos generados de forma local.

En el paquete data, crearemos una implementación de PagingSource en un archivo nuevo llamado ArticlePagingSource.kt:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource
import androidx.paging.PagingState

class ArticlePagingSource : PagingSource<Int, Article>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        TODO("Not yet implemented")
    }
}

PagingSource requiere que implementemos dos funciones: load() y getRefreshKey().

La biblioteca de Paging llamará a la función load() para recuperar de forma asíncrona más datos que se mostrarán a medida que el usuario se desplaza. El objeto LoadParams mantiene información relacionada con la operación de carga, incluida la que aparece a continuación:

  • Clave de la página que se cargará: Si es la primera vez que se llama a load(), LoadParams.key será null. En este caso, deberás definir la clave de página inicial. Para nuestro proyecto, usamos el ID del artículo como clave. Agreguemos una constante STARTING_KEY de 0 en la parte superior del archivo ArticlePagingSource para la clave de página inicial.
  • Tamaño de carga: Corresponde a la cantidad solicitada de elementos que se cargarán.

La función load() muestra un LoadResult. El LoadResult puede ser uno de los siguientes tipos:

  • LoadResult.Page, si el resultado fue exitoso
  • LoadResult.Error, en caso de error
  • LoadResult.Invalid, si PagingSource debe hacerse no válido, porque ya no puede garantizar la integridad de sus resultados

Un elemento LoadResult.Page tiene tres argumentos obligatorios:

  • data: Una List de los elementos recuperados
  • prevKey: La clave que usa el método load() si necesita recuperar elementos antes de la página actual
  • nextKey: La clave que usa el método load() si necesita recuperar elementos después de la página actual

Además, tiene dos argumentos opcionales:

  • itemsBefore: La cantidad de marcadores de posición que se mostrarán antes de los datos cargados
  • itemsAfter: La cantidad de marcadores de posición que se mostrarán después de los datos cargados

Nuestra clave de carga es el campo Article.id. Podemos usarlo como clave porque el ID de Article aumenta de a uno en cada artículo. Es decir, los ID del artículo son números enteros consecutivos que incrementan de forma monotónica.

El valor de nextKey o prevKey es null si no hay más datos para cargar en la dirección correspondiente. En nuestro caso, para prevKey:

  • Si la startKey es igual a la STARTING_KEY, se muestra un valor nulo, ya que no podemos cargar más elementos detrás de esta clave.
  • De lo contrario, tomamos el primer elemento de la lista y cargamos LoadParams.loadSize detrás de él para asegurarnos de que nunca se muestre una clave inferior a STARTING_KEY. Para ello, define el método ensureValidKey().

Agrega la siguiente función que verifica si la clave de paginación es válida:

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
   /**
     * Makes sure the paging key is never less than [STARTING_KEY]
     */
    private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
}

Para nextKey:

  • Debido a que se admite la carga de elementos infinitos, pasamos range.last + 1.

Además, debido a que cada artículo tiene un campo created, también necesitaremos generar un valor para él. Agrega lo siguiente en la parte superior del archivo:

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
}

Una vez implementado todo el código, podemos implementar la función load():

import kotlin.math.max
...

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        // Start paging with the STARTING_KEY if this is the first load
        val start = params.key ?: STARTING_KEY
        // Load as many items as hinted by params.loadSize
        val range = start.until(start + params.loadSize)

        return LoadResult.Page(
            data = range.map { number ->
                Article(
                    // Generate consecutive increasing numbers as the article id
                    id = number,
                    title = "Article $number",
                    description = "This describes article $number",
                    created = firstArticleCreatedTime.minusDays(number.toLong())
                )
            },

            // Make sure we don't try to load items behind the STARTING_KEY
            prevKey = when (start) {
                STARTING_KEY -> null
                else -> ensureValidKey(key = range.first - params.loadSize)
            },
            nextKey = range.last + 1
        )
    }

    ...
}

Ahora debemos implementar getRefreshKey(). Se llama a este método cuando la biblioteca de Paging necesita volver a cargar elementos para la IU porque los datos en su PagingSource de copia de seguridad cambiaron. Esta situación en la que los datos subyacentes de una PagingSource cambiaron y necesitan actualizarse en la IU se denomina anulación. Cuando deja de ser válido, la biblioteca de Paging crea una PagingSource nueva para volver a cargar los datos y, luego, emite los nuevos PagingData para informar a la IU. Obtendrás más información sobre la invalidación en una sección posterior.

Cuando se realiza la carga desde una nueva PagingSource, se llama a getRefreshKey() para proporcionar la clave que la nueva PagingSource debería comenzar a cargar para garantizar que el usuario no pierda su lugar actual en la lista después de la actualización.

La invalidación en la biblioteca de paginación se produce por uno de estos dos motivos:

  • Llamaste a refresh() en el PagingAdapter.
  • Llamaste a invalidate() en la PagingSource.

La clave que se muestra (en nuestro caso, un elemento Int) se pasará a la siguiente llamada del método load() en la nueva PagingSource a través del argumento LoadParams. Para evitar que los elementos salten después de la invalidación, debemos asegurarnos de que la clave que se muestra cargue los elementos suficientes para llenar la pantalla. Esto aumenta la posibilidad de que el nuevo conjunto de elementos incluya elementos que estaban presentes en los datos invalidados, lo que ayuda a mantener la posición de desplazamiento actual. Observemos la implementación en nuestra app:

   // The refresh key is used for the initial load of the next PagingSource, after invalidation
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        // In our case we grab the item closest to the anchor position
        // then return its id - (state.config.pageSize / 2) as a buffer
        val anchorPosition = state.anchorPosition ?: return null
        val article = state.closestItemToPosition(anchorPosition) ?: return null
        return ensureValidKey(key = article.id - (state.config.pageSize / 2))
    }

En el fragmento anterior, usamos PagingState.anchorPosition. Si te preguntas cómo la biblioteca de paginación sabe que debe recuperar más elementos, esta es una pista. Cuando la IU intenta leer elementos de PagingData, trata de leer en un índice determinado. Si se leyeron los datos, estos se mostrarán en la IU. Sin embargo, si no hay datos, la biblioteca de paginación sabe que debe recuperar datos para completar la solicitud de lectura con errores. El último índice que recuperó correctamente los datos cuando se leyó es anchorPosition.

Cuando realizamos la actualización, tomamos la clave del Article más cercano a anchorPosition para usarlo como clave de carga. De esa manera, cuando volvemos a cargar una PagingSource nueva, el conjunto de elementos recuperados incluye elementos ya cargados, lo que garantiza una experiencia del usuario fluida y coherente.

Una vez hecho esto, definiste por completo una PagingSource. El siguiente paso es conectarla a la IU.

6. Produce PagingData para la IU

En nuestra implementación actual, usamos un Flow<List<Article>> en el ArticleRepository para exponer los datos cargados en el ViewModel. A su vez, el ViewModel mantiene un estado de datos siempre disponible con el operador stateIn para la exposición a la IU.

En cambio, con la biblioteca de Paging, expondremos un Flow<PagingData<Article>> de ViewModel. PagingData es un tipo que une los datos que cargamos y ayuda a la biblioteca de Paging a decidir cuándo recuperar más datos. Además, nos aseguramos de no solicitar la misma página dos veces.

Para construir los PagingData, usaremos uno de varios métodos de compilador de la clase Pager según la API que queremos usar para pasar los PagingData a otras capas de nuestra app:

  • Flow de Kotlin: usa Pager.flow.
  • LiveData: usa Pager.liveData.
  • RxJava Flowable: usa Pager.flowable.
  • RxJava Observable: usa Pager.observable.

Como ya estamos usando Flow en nuestra aplicación, continuaremos con este enfoque; pero, en lugar de usar Flow<List<Article>>, usaremos Flow<PagingData<Article>>.

Independientemente del compilador de PagingData que utilices, tendrás que pasar los siguientes parámetros:

  • PagingConfig: Esta clase establece opciones para cargar contenido desde una PagingSource, como la cantidad de contenido para cargar y la solicitud de tamaño de la carga inicial, entre otras. El único parámetro obligatorio que debes definir es el tamaño de la página, es decir, cuántos elementos se deben cargar en cada página. De manera predeterminada, Paging mantendrá todas las páginas que cargues en la memoria. A los efectos de asegurarte de no desperdiciar memoria a medida que el usuario se desplaza, establece el parámetro maxSize en PagingConfig. De forma predeterminada, Paging mostrará elementos nulos como un marcador de posición para el contenido que aún no se haya cargado si puede contar los elementos descargados y si la marca de configuración enablePlaceholders es verdadera true. De esta manera, podrás mostrar una vista del marcador de posición en tu adaptador. Simplifiquemos el trabajo de este codelab: pasa enablePlaceholders = false para inhabilitar los marcadores de posición.
  • Una función que define cómo crear la PagingSource. En nuestro caso, crearemos una ArticlePagingSource, por lo que necesitamos una función que le indique a la biblioteca de Paging cómo hacerlo.

¡Modifiquemos nuestro ArticleRepository!

Actualizar ArticleRepository

  • Borra el campo articlesStream.
  • Agrega un método llamado articlePagingSource() que muestre la ArticlePagingSource que acabamos de crear.
class ArticleRepository {

    fun articlePagingSource() = ArticlePagingSource()
}

Limpieza ArticleRepository

La biblioteca de Paging se encarga de una gran cantidad de cosas:

  • Controla la caché de la memoria.
  • Solicita datos cuando el usuario está cerca del final de la lista.

Esto significa que se puede quitar todo el contenido de ArticleRepository, excepto articlePagingSource(). Tu archivo ArticleRepository ahora debería tener el siguiente aspecto:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource

class ArticleRepository {
    fun articlePagingSource() = ArticlePagingSource()
}

En este punto, deberías tener errores de compilación en el ArticleViewModel. Veamos qué cambios se deben realizar.

7. Solicita y almacena en caché PagingData en ViewModel

Antes de solucionar los errores de compilación, revisemos ViewModel.

class ArticleViewModel(...) : ViewModel() {

    val items: StateFlow<List<Article>> = ...
}

Para integrar la biblioteca de Paging en ViewModel, cambiaremos el tipo de datos que se muestra de items de StateFlow<List<Article>> a Flow<PagingData<Article>>. Para ello, primero agrega una constante privada llamada ITEMS_PER_PAGE en la parte superior del archivo:

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel {
    ...
}

A continuación, actualizamos items para que sea el resultado de la salida de una instancia de Pager. Para ello, pasamos dos parámetros a Pager:

  • Un objeto PagingConfig con un pageSize de ITEMS_PER_PAGE y marcadores de posición inhabilitados
  • Un objeto PagingSourceFactory que proporciona una instancia del ArticlePagingSource que acabamos de crear.
class ArticleViewModel(...) : ViewModel() {

   val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        ...
}

A continuación, para mantener el estado de paginación a través de los cambios de configuración o navegación, usamos el método cachedIn() y le pasamos el objeto androidx.lifecycle.viewModelScope.

Después de completar los cambios anteriores, nuestro ViewModel debería verse así:

package com.example.android.codelabs.paging.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.android.codelabs.paging.data.Article
import com.example.android.codelabs.paging.data.ArticleRepository
import com.example.android.codelabs.paging.data.ITEMS_PER_PAGE
import kotlinx.coroutines.flow.Flow

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel(
    private val repository: ArticleRepository,
) : ViewModel() {

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        .cachedIn(viewModelScope)
}

Otro aspecto para tener en cuenta sobre PagingData es que es un tipo independiente que contiene un flujo mutable de actualizaciones de los datos que se mostrarán en RecyclerView. Cada emisión de PagingData es completamente independiente y es posible que se emitan varias instancias de PagingData para una sola consulta si se invalida la copia de seguridad de PagingSource debido a cambios en el conjunto de datos subyacente. Por lo tanto, los Flows de PagingData deben exponerse independientemente de otros Flows.

Eso es todo. Ahora tenemos la funcionalidad de paginación en ViewModel

8. Haz que el Adapter funcione con PagingData

Para vincular PagingData a RecyclerView, usa un PagingDataAdapter. El PagingDataAdapter recibirá una notificación cada vez que se cargue el contenido de PagingData y, luego, indicará a la RecyclerView que debe actualizarse.

Actualiza ArticleAdapter para que funcione con un flujo de PagingData:

  • Actualmente, ArticleAdapter implementa ListAdapter. En su lugar, haz que implemente PagingDataAdapter. El resto del cuerpo de la clase permanecerá sin cambios:
import androidx.paging.PagingDataAdapter
...

class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
// body is unchanged
}

Hicimos muchos cambios hasta el momento, pero ahora solo queda un paso para poder ejecutar la app: conectar la IU.

9. Consume PagingData en la IU

En nuestra implementación actual, tenemos un método llamado binding.setupScrollListener() que llama al ViewModel para cargar más datos si se cumplen ciertas condiciones. La biblioteca de Paging hace todo esto automáticamente, de modo que podemos borrar este método y sus usos.

A continuación, como ArticleAdapter ya no es ListAdapter, sino PagingDataAdapter, hacemos dos cambios pequeños:

  • Cambiamos el operador de la terminal de Flow de ViewModel a collectLatest en lugar de collect.
  • Notificamos a ArticleAdapter sobre los cambios con submitData() en lugar de submitList().

Usamos collectLatest en Flow de pagingData para que la recopilación en emisiones anteriores de pagingData se cancele cuando se emita una nueva instancia de pagingData.

Con esos cambios, Activity debería verse así:

import kotlinx.coroutines.flow.collectLatest

class ArticleActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityArticlesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        val viewModel by viewModels<ArticleViewModel>(
            factoryProducer = { Injection.provideViewModelFactory(owner = this) }
        )

        val items = viewModel.items
        val articleAdapter = ArticleAdapter()

        binding.bindAdapter(articleAdapter = articleAdapter)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                items.collectLatest {
                    articleAdapter.submitData(it)
                }
            }
        }
    }
}

private fun ActivityArticlesBinding.bindAdapter(
    articleAdapter: ArticleAdapter
) {
    list.adapter = articleAdapter
    list.layoutManager = LinearLayoutManager(list.context)
    val decoration = DividerItemDecoration(list.context, DividerItemDecoration.VERTICAL)
    list.addItemDecoration(decoration)
}

Ahora la app debería compilarse y ejecutarse. Migraste correctamente la app a la biblioteca de Paging.

f97136863cfa19a0.gif

10. Muestra los estados de carga en la IU

Cuando la biblioteca de Paging recupera más elementos para mostrar en la IU, se recomienda indicar al usuario que habrá más datos en camino. Por suerte, la biblioteca de Paging ofrece una forma conveniente de acceder a su estado de carga con el tipo CombinedLoadStates.

Las instancias de CombinedLoadStates describen el estado de carga de todos los componentes de la biblioteca de Paging que cargan datos. En nuestro caso, solo nos interesa el LoadState de ArticlePagingSource, por lo que trabajaremos principalmente con el tipo LoadStates en el campo CombinedLoadStates.source. Puedes acceder a CombinedLoadStates mediante PagingDataAdapter a través de PagingDataAdapter.loadStateFlow.

CombinedLoadStates.source es un tipo LoadStates, con campos para tres tipos diferentes de LoadState:

  • LoadStates.append: para el LoadState de los elementos que se recuperan después de la posición actual del usuario
  • LoadStates.prepend: para el LoadState de los elementos que se recuperan antes de la posición actual del usuario
  • LoadStates.refresh: para el LoadState de la carga inicial

Cada LoadState puede corresponder a alguno de los siguientes estados:

  • LoadState.Loading: se están cargando los elementos.
  • LoadState.NotLoading: no se están cargando los elementos.
  • LoadState.Error: se produjo un error de carga.

En nuestro caso, solo nos importa si el elemento LoadState es LoadState.Loading, ya que nuestro elemento ArticlePagingSource no incluye un caso de error.

Lo primero que hacemos es agregar barras de progreso a la parte superior e inferior de la IU para indicar el estado de carga de las recuperaciones en cualquier dirección.

En activity_articles.xml, agrega dos barras LinearProgressIndicator de la siguiente manera:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.ArticleActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scrollbars="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/prepend_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/append_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

A continuación, reaccionamos a CombinedLoadState mediante la recopilación de LoadStatesFlow desde PagingDataAdapter. Recopila el estado en ArticleActivity.kt:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                articleAdapter.loadStateFlow.collect {
                    binding.prependProgress.isVisible = it.source.prepend is Loading
                    binding.appendProgress.isVisible = it.source.append is Loading
                }
            }
        }
        lifecycleScope.launch {
        ...
    }

Por último, agregamos un poco de retraso en ArticlePagingSource para simular la carga:

private const val LOAD_DELAY_MILLIS = 3_000L

class ArticlePagingSource : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = startKey.until(startKey + params.loadSize)

        if (start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
        return ...

}

Espera a que la app vuelva a ejecutarse y desplázate hasta la parte inferior de la lista. Deberías ver que la barra de progreso inferior aparece cuando la biblioteca de paginación recupera más elementos y desaparece cuando finaliza.

6277154193f7580.gif

11. Finalización

A continuación, hagamos un breve repaso de los temas que abordamos, como los siguientes:

  • Vimos una descripción general de la paginación y por qué es necesaria.
  • Agregamos un sistema de paginación a nuestra app. Para ello, creamos Pager, definimos PagingSource y emitimos PagingData.
  • Se almacenó PagingData en caché en ViewModel con el operador cachedIn.
  • Se consumieron PagingData en la IU usando un PagingDataAdapter.
  • Reaccionamos a CombinedLoadStates con PagingDataAdapter.loadStateFlow.

Eso es todo. Para ver conceptos de paginación más avanzados, consulta el codelab de Paging avanzado.