Codelab avanzado de Paging de Android

1. Introducción

Qué aprenderás

  • Cuáles son los componentes principales de Paging 3.
  • Cómo agregar Paging 3 a tu proyecto
  • Cómo agregar un encabezado o pie de página a tu lista mediante la API de Paging 3
  • Cómo agregar separadores de lista por medio de la API de Paging 3.
  • Cómo hacer una paginación desde la red y la base de datos

Qué compilarás

En este codelab, comenzarás con una app de ejemplo que ya muestra una lista de repositorios de GitHub. Cada vez que el usuario se desplace hasta el final de esa lista, se activará una solicitud de red nueva y se mostrará su resultado en la pantalla.

Agregarás código por medio de una serie de pasos para lograr lo siguiente:

  • Migrar a los componentes de la biblioteca de Paging
  • Agregar a tu lista un encabezado y un pie de página de estado de carga
  • Mostrar el progreso de carga entre cada búsqueda de repositorio nueva
  • Agregar separadores a tu lista
  • Agregar compatibilidad de base de datos para la paginación desde la red y la base de datos

Así se verá tu app al final:

23643514cb9cf43e.png

Requisitos

Si deseas obtener una introducción a los componentes de la arquitectura, consulta el codelab sobre Room con una View. Para obtener una introducción a los flujos, consulta el codelab sobre corrutinas avanzadas con LiveData y flujo de Kotlin.

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.

A fin de que comiences lo antes posible, preparamos un proyecto inicial sobre el cual puedes compilar.

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 de forma correcta.

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

El estado inicial se encuentra en la rama principal. Puedes ver la solución a determinados pasos de la manera siguiente:

  • En la rama step5-9_paging_3.0, encontrarás la solución de los pasos 5 a 9, en los que agregamos la versión más reciente de Paging a nuestro proyecto.
  • En la rama step10_loading_state_footer, encontrarás la solución al paso 10, en el que agregamos un pie de página que muestra un estado de carga.
  • En la rama step11_loading_state, encontrarás la solución al paso 11, en el que agregamos una visualización del estado de carga entre búsquedas.
  • En la rama step12_separators, encontrarás la solución del paso 12, en el que agregaremos separadores a nuestra app.
  • En la rama step13_network_and_database, encontrarás la solución de los pasos 13 a 19, en los que agregamos soporte sin conexión a nuestra app.

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

  1. Descomprime el código y, luego, abre el proyecto en Android Studio.
  2. Ejecuta la configuración de ejecución app en un dispositivo o emulador.

89af884fa2d4e709.png

La app se ejecutará y mostrará una lista de repositorios de GitHub similares a este:

50d1d2aa6e79e473.png

3. Descripción general del proyecto

La app te permitirá buscar en GitHub los repositorios cuyo nombre o descripción contenga una palabra específica. La lista de repositorios se muestra en orden descendente según la cantidad de estrellas y, luego, alfabéticamente por nombre.

La app sigue la arquitectura recomendada en la "Guía de arquitectura de apps". Esto es lo que encontrarás en cada paquete:

  • api: Llamadas a la API de GitHub con Retrofit
  • data: La clase de repositorio, responsable de activar las solicitudes a la API y almacenar las respuestas en la caché de la memoria
  • model: El modelo de datos Repo, que también es una tabla en la base de datos de Room, y RepoSearchResult, una clase que usa la IU para observar tanto datos de resultados de la búsqueda como errores de red
  • ui: Clases relacionadas con la visualización de una Activity mediante una RecyclerView

La clase GithubRepository recupera la lista de nombres de repositorios de la red cada vez que el usuario se desplaza hacia el final de la lista o busca un repositorio nuevo. La lista de resultados de una búsqueda se guarda en la memoria del GithubRepository, en un ConflatedBroadcastChannel, y se expone como un Flow.

SearchRepositoriesViewModel solicita los datos de GithubRepository y los expone a la SearchRepositoriesActivity. Como queremos asegurarnos de no solicitar los datos varias veces durante el cambio de configuración (p. ej., una rotación), convertiremos Flow en LiveData dentro del ViewModel mediante el método compilador liveData(). De esta manera, LiveData almacena en caché la lista más reciente de resultados en la memoria y, cuando se vuelva a crear la SearchRepositoriesActivity, se mostrará el contenido de LiveData en la pantalla. El ViewModel expone lo siguiente:

  1. Un LiveData<UiState>
  2. Una función (UiAction) -> Unit

El UiState es una representación de todo lo necesario para procesar la IU de la app, con campos diferentes que corresponden a distintos componentes de la IU. Es un objeto inmutable, lo que significa que no se puede cambiar. Sin embargo, la IU puede producir y observar nuevas versiones de esta. En nuestro caso, se generan versiones nuevas del objeto como resultado de las acciones del usuario, ya sea encontrar una búsqueda nueva o desplazarse por la lista para recuperar más objetos.

Las acciones del usuario se representan de forma correcta con el tipo UiAction. Delimitar la API para las interacciones con el ViewModel en un solo tipo tiene los siguientes beneficios:

  • Superficie pequeña de la API: se pueden agregar, quitar o cambiar acciones, pero la firma del método de ViewModel no cambia nunca. Esto hace que la refactorización sea local y menos probable que filtre abstracciones o implementaciones de interfaces.
  • Administración de simultaneidad más sencilla: como verás más adelante en el codelab, es importante poder garantizar el orden de ejecución de determinadas solicitudes. Si creamos la API de manera sólida con UiAction, podemos escribir código con requisitos estrictos sobre lo que puede suceder y cuándo puede ocurrir.

Desde la perspectiva de usabilidad, tenemos los siguientes problemas:

  • El usuario no posee información sobre el estado de carga de la lista: ve una pantalla vacía cuando busca un repositorio nuevo o simplemente un final abrupto de la lista mientras se cargan más resultados para la misma búsqueda.
  • El usuario no puede reintentar una búsqueda con errores.
  • La lista siempre se desplaza hacia la parte superior después de los cambios de orientación o después del cierre del proceso.

Desde la perspectiva de la implementación, tenemos los siguientes problemas:

  • La lista crece sin límite en la memoria, lo cual desperdicia memoria a medida que el usuario se desplaza por la página.
  • Debemos convertir nuestros resultados de Flow a LiveData para almacenarlos en caché, y esto aumentará la complejidad de nuestro código.
  • Si nuestra app necesitara mostrar varias listas, veríamos que hay mucho código estándar para escribir en cada una.

Veamos el modo en que la biblioteca de Paging puede ayudarnos con estos problemas y qué componentes incluye.

4. Componentes de la biblioteca de Paging

La biblioteca de Paging facilita la carga de datos de forma incremental y con facilidad en la IU de tu app. La API de Paging ofrece compatibilidad con muchas de las funciones que, de lo contrario, tendrías que implementar manualmente cuando necesites cargar datos en páginas:

  • Hace un seguimiento de las claves que se usarán para recuperar la página siguiente y la anterior.
  • Solicita automáticamente la página correcta cuando el usuario se desplaza hasta el final de la lista.
  • Garantiza que no se activen varias solicitudes al mismo tiempo.
  • Te permite almacenar los datos en caché: si usas Kotlin, esto se realiza en un CoroutineScope y, si usas Java, se puede hacer con LiveData.
  • Realiza un seguimiento del estado de carga y te permite mostrarlo en un elemento de la lista de una RecyclerView o en cualquier otro lugar de la IU, y volver a intentar con facilidad las cargas que hayan tenido errores.
  • Te permite ejecutar operaciones comunes como map o filter en la lista que se mostrará, independientemente de si usas Flow, LiveData o RxJava Flowable o Observable.
  • Proporciona una forma sencilla de implementar separadores de lista.

La Guía de arquitectura de apps propone una arquitectura con los siguientes componentes principales:

  • Una base de datos local que funciona como una única fuente de información para los datos que se presentan al usuario y que este controla
  • Un servicio de API web
  • Un repositorio que funciona con la base de datos y el servicio de API web, lo que proporciona una interfaz de datos unificada
  • Un ViewModel que proporciona datos específicos de la IU
  • La IU, que muestra una representación visual de los datos en el ViewModel

La biblioteca de Paging funciona con todos estos componentes y coordina las interacciones entre ellos para que pueda cargar "páginas" de contenido desde una fuente de datos y mostrar ese contenido en la IU.

Este codelab te presenta la biblioteca de Paging y sus componentes principales:

  • PagingData: Es un contenedor para datos paginados. A cada actualización de datos le corresponderá un PagingData diferente.
  • PagingSource: Una PagingSource es la clase básica para cargar instantáneas de datos en un flujo de PagingData.
  • Pager.flow: Compila un Flow<PagingData> a partir de un objeto PagingConfig y una función que define cómo construir la PagingSource implementada.
  • PagingDataAdapter: Es un RecyclerView.Adapter que presenta PagingData en una RecyclerView. Se puede conectar PagingDataAdapter a un Flow de Kotlin, un LiveData, un RxJava Flowable o a un RxJava Observable. El elemento PagingDataAdapter escucha eventos de carga PagingData internos mientras las páginas se cargan y usa DiffUtil en un subproceso en segundo plano a fin de procesar actualizaciones detalladas a medida que se recibe contenido actualizado en forma de objetos PagingData nuevos.
  • RemoteMediator: Ayuda a implementar la paginación desde la red y la base de datos.

En este codelab, implementarás ejemplos de cada uno de los componentes descritos más arriba.

5. Define la fuente de datos

La implementación de PagingSource define la fuente de los datos y la forma en la que se recuperarán datos de esa fuente. 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.

En la actualidad, el GithubRepository incluye muchas de las responsabilidades de una fuente de datos que la biblioteca de Paging administrará una vez que terminemos de agregarla:

  • Carga los datos de GithubService y garantiza que no se activen varias solicitudes al mismo tiempo.
  • Mantiene los datos recuperados en una caché de la memoria.
  • Lleva un registro de la página que se solicitará.

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

  • El tipo de clave de paginación: en nuestro caso, la API de GitHub usa números de índice basados en 1 para las páginas, por lo que el tipo es Int.
  • El tipo de datos cargado: en nuestro caso, estamos cargando elementos Repo.
  • La ubicación desde donde se recuperarán los datos: obtendremos los datos de GithubService. Nuestra fuente de datos será específica para una búsqueda determinada, por lo que debemos asegurarnos de pasar la información de la búsqueda a GithubService.

Entonces, en el paquete data, vamos a crear una implementación de PagingSource llamada GithubPagingSource:

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        TODO("Not yet implemented")
    }

}

Veremos que 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 esta función, LoadParams.key será null. En este caso, deberás definir la clave de página inicial. Para nuestro proyecto, deberás mover la constante GITHUB_STARTING_PAGE_INDEX de GithubRepository a tu implementación de PagingSource, ya que esta es la clave de página inicial.
  • Tamaño de carga: Corresponde a la cantidad solicitada de elementos que se cargarán.

La función de carga muestra un LoadResult. Esto reemplazará el uso de RepoSearchResult en nuestra app, ya que LoadResult puede tomar uno de los siguientes tipos:

  • LoadResult.Page, si el resultado fue exitoso
  • LoadResult.Error, en caso de error

Cuando construyas la LoadResult.Page, pasa null para nextKey o prevKey si la lista no se puede cargar en la dirección correspondiente. Por ejemplo, en nuestro caso, podríamos considerar que, si la respuesta de la red es correcta pero la lista estaba vacía, no quedan datos para cargar, por lo que la nextKey puede ser null.

A partir de toda esta información, deberíamos poder implementar la función load().

Ahora debemos implementar getRefreshKey(). La clave de actualización sirve para las subsecuentes llamadas de actualización a PagingSource.load() (la primera llamada es la carga inicial con initialKey que proporciona Pager). Esto sucede cada vez que la biblioteca de Paging quiere cargar datos nuevos y reemplazar la lista actual, p. ej., cuando el usuario desliza el dedo para actualizar o se produce una invalidación por actualizaciones en las bases de datos, cambios de configuración, cierres de proceso, etc. Por lo general, las llamadas de actualización posteriores volverán a cargar los datos en PagingState.anchorPosition, que representa el índice de la lista al que más recientemente se accedió.

La implementación de GithubPagingSource se verá de la siguiente manera:

// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
        val apiQuery = query + IN_QUALIFIER
        return try {
            val response = service.searchRepos(apiQuery, position, params.loadSize)
            val repos = response.items
            val nextKey = if (repos.isEmpty()) {
                null
            } else {
                // initial load size = 3 * NETWORK_PAGE_SIZE
                // ensure we're not requesting duplicating items, at the 2nd request
                position + (params.loadSize / NETWORK_PAGE_SIZE)
            }
            LoadResult.Page(
                    data = repos,
                    prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                    nextKey = nextKey
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
    // The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        // We need to get the previous key (or next key if previous is null) of the page
        // that was closest to the most recently accessed index.
        // Anchor position is the most recently accessed index
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

}

6. Crea y configura PagingData

En nuestra implementación actual, usamos un Flow<RepoSearchResult> en el GitHubRepository a fin de obtener los datos de la red y pasarlos al ViewModel. Luego, el ViewModel lo transforma en un LiveData y lo expone en la IU. Cuando lleguemos al final de la lista que se muestra y se carguen más datos de la red, el Flow<RepoSearchResult> contendrá la lista completa de los datos recuperados anteriormente para esa búsqueda, además de los datos más recientes.

RepoSearchResult encapsula tanto los casos de éxito como aquellos en los que se produjo un error. El caso de éxito contiene los datos del repositorio. El caso en el que se produjo un error contiene el motivo de la Exception. Con Paging 3, ya no necesitamos el RepoSearchResult, ya que la biblioteca modela ambos casos posibles mediante LoadResult. Puedes borrar RepoSearchResult, dado que lo reemplazaremos en los siguientes pasos.

Para construir los PagingData, primero debemos decidir qué API 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<RepoSearchResult>, usaremos Flow<PagingData<Repo>>.

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á en la memoria todas las páginas que cargues. 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. 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 a fin de inhabilitar los marcadores de posición.
  • Una función que define cómo crear la PagingSource. En nuestro caso, crearemos una GithubPagingSource para cada búsqueda nueva.

¡Modifiquemos nuestro GithubRepository!

Actualizar GithubRepository.getSearchResultStream

  • Quita el modificador suspend.
  • Muestra Flow<PagingData<Repo>>.
  • Construye Pager.
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
    return Pager(
          config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
          pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

Limpieza de GithubRepository

Paging 3 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 nuestro GithubRepository, excepto getSearchResultStream y el objeto complementario en el que definimos el NETWORK_PAGE_SIZE. Tu GithubRepository debería verse así:

class GithubRepository(private val service: GithubService) {

    fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        return Pager(
                config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
             ),
                pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow
    }

    companion object {
        const val NETWORK_PAGE_SIZE = 50
    }
}

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

7. Solicita y almacena en caché PagingData en ViewModel

Antes de abordar los errores de compilación, revisemos los tipos en ViewModel:

sealed class UiAction {
    data class Search(val query: String) : UiAction()
    data class Scroll(
        val visibleItemCount: Int,
        val lastVisibleItemPosition: Int,
        val totalItemCount: Int
    ) : UiAction()
}

data class UiState(
    val query: String,
    val searchResult: RepoSearchResult
)

En nuestro UiState, exponemos un searchResult. La función de searchResult consiste en ser una caché en la memoria para los resultados de las búsquedas que sobreviven a los cambios de configuración. Con Paging 3, ya no necesitamos convertir nuestro Flow en LiveData. En su lugar, SearchRepositoriesViewModel ahora expondrá un StateFlow<UiState>. Además, descartamos el valor searchResult por completo y optamos por exponer un Flow<PagingData<Repo>> separado que tenga el mismo propósito que searchResult.

PagingData 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 se pueden emitir varios PagingData para una sola búsqueda. Por lo tanto, los Flows de PagingData deben exponerse independientemente de otros Flows.

Además, como un beneficio para la experiencia del usuario, por cada búsqueda nueva que ingreses, te recomendamos que te desplaces hasta la parte superior de la lista para ver el primer resultado de la búsqueda. Sin embargo, como los datos de paginación podrían emitirse varias veces, solo debemos desplazarnos hasta la parte superior de la lista si el usuario no comenzó a hacerlo.

Para ello, actualizaremos UiState y agregaremos campos para lastQueryScrolled y hasNotScrolledForCurrentSearch. Estos indicadores impedirán el desplazamiento a la parte superior de la lista cuando no esto no debería suceder:

data class UiState(
    val query: String = DEFAULT_QUERY,
    val lastQueryScrolled: String = DEFAULT_QUERY,
    val hasNotScrolledForCurrentSearch: Boolean = false
)

Repasemos nuestra arquitectura. Debido a que todas las solicitudes a ViewModel pasan por un único punto de entrada (el campo accept definido como (UiAction) -> Unit), debemos hacer lo siguiente:

  • Convertir ese punto de entrada en flujos que contengan los tipos que nos interesan
  • Transformar esos flujos
  • Volver a combinar los flujos en un StateFlow<UiState>

En términos más funcionales, vamos a reducir (reduce) las emisiones de UiAction en UiState. Es parecido a una línea de ensamblaje: los tipos UiAction son los materiales sin procesar que ingresan, causan efectos (a veces llamados mutaciones) y UiState es el resultado final listo para enlazarse a la IU. A veces, esto se denomina hacer que la IU sea una función de UiState.

Volvamos a escribir el ViewModel para controlar cada tipo de UiAction en dos flujos diferentes y, luego, los transformaremos en un StateFlow<UiState> con algunos operadores Flow de Kotlin.

Primero, actualizamos las definiciones de state en ViewModel a fin de usar StateFlow en lugar de LiveData y, al mismo tiempo, agregamos un campo para exponer un Flow de PagingData:

   /**
     * Stream of immutable states representative of the UI.
     */
    val state: StateFlow<UiState>

    val pagingDataFlow: Flow<PagingData<Repo>>

A continuación, actualizamos la definición de la subclase UiAction.Scroll:

sealed class UiAction {
    ...
    data class Scroll(val currentQuery: String) : UiAction()
}

Ten en cuenta que quitamos todos los campos de la clase de datos UiAction.Scroll y los reemplazamos por la string currentQuery única. Esto nos permite asociar una acción de desplazamiento con una búsqueda determinada. También borramos la extensión shouldFetchMore porque ya no se usa. Este también es un elemento que se debe restablecer después del cierre del proceso, por lo que debemos aseguramos de actualizar el método onCleared() en el SearchRepositoriesViewModel:

class SearchRepositoriesViewModel{
  ...
   override fun onCleared() {
        savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
        savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
        super.onCleared()
    }
}

// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"

También es un buen momento para ingresar el método que creará el Flow de pagingData a partir del GithubRepository:

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {

    override fun onCleared() {
        ...
    }

    private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
        repository.getSearchResultStream(queryString)
}

Flow<PagingData> tiene un método cachedIn() práctico que nos permite almacenar en caché el contenido de un Flow<PagingData> en un CoroutineScope. Como estamos en un ViewModel, usaremos el androidx.lifecycle.viewModelScope.

Ahora, podemos comenzar a convertir el campo accept del ViewModel en un flujo UiAction. Reemplaza el bloqueo init de SearchRepositoriesViewModel con lo siguiente:

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {
    ...
    init {
        val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
        val actionStateFlow = MutableSharedFlow<UiAction>()
        val searches = actionStateFlow
            .filterIsInstance<UiAction.Search>()
            .distinctUntilChanged()
            .onStart { emit(UiAction.Search(query = initialQuery)) }
        val queriesScrolled = actionStateFlow
            .filterIsInstance<UiAction.Scroll>()
            .distinctUntilChanged()
            // This is shared to keep the flow "hot" while caching the last query scrolled,
            // otherwise each flatMapLatest invocation would lose the last query scrolled,
            .shareIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                replay = 1
            )
            .onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
   }
}

Revisemos el fragmento de código anterior. Comenzamos con dos elementos, el String de initialQuery, que se extrae del estado guardado o un valor predeterminado, junto con lastQueryScrolled, una String que representa el último término de búsqueda en el que el usuario interactuó con la lista. Luego, comenzamos a dividir Flow en tipos UiAction específicos:

  1. UiAction.Search por cada vez que el usuario ingresa una búsqueda en particular
  2. UiAction.Scroll por cada vez que el usuario se desplaza por la lista para encontrar una búsqueda específica.

UiAction.Scroll Flow tiene algunas transformaciones adicionales aplicadas. Veámoslas a continuación:

  1. shareIn: Es necesario porque, cuando finalmente se consume este Flow, se consume con un operador flatmapLatest. Cada vez que se emita el flujo ascendente, flatmapLatest cancelará el último Flow en el que haya estado funcionando y comenzará a trabajar según el nuevo flujo que se le proporcionó. En nuestro caso, esto nos haría perder el valor de la última búsqueda por la que el usuario se desplazó. Por lo tanto, usamos el operador Flow con un valor replay de 1 para almacenar en caché el último valor de modo que no se pierda cuando entre una nueva búsqueda.
  2. onStart: También se usa para el almacenamiento en caché. Si se cerró la app, pero el usuario ya se había desplazado por una búsqueda, no queremos desplazar la lista hasta la parte superior porque volvería a perder su lugar.

Todavía debería haber errores de compilación porque aún no definimos los campos state, pagingDataFlow y accept. Sin embargo, podemos solucionarlo. Con las transformaciones aplicadas a cada UiAction, ahora podemos usarlas para crear flujos tanto para PagingData como para UiState.

init {
        ...
        pagingDataFlow = searches
            .flatMapLatest { searchRepo(queryString = it.query) }
            .cachedIn(viewModelScope)

        state = combine(
            searches,
            queriesScrolled,
            ::Pair
        ).map { (search, scroll) ->
            UiState(
                query = search.query,
                lastQueryScrolled = scroll.currentQuery,
                // If the search query matches the scroll query, the user has scrolled
                hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
            )
        }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                initialValue = UiState()
            )

        accept = { action ->
            viewModelScope.launch { actionStateFlow.emit(action) }
        }
    }
}

Usamos el operador flatmapLatest en el flujo searches porque cada búsqueda nueva requiere que se cree un nuevo Pager. Luego, aplicamos el operador cachedIn al flujo PagingData para mantenerlo activo dentro de viewModelScope y asignar el resultado al campo pagingDataFlow. En cuanto a UiState, usamos el operador de combinación para propagar los campos UiState requeridos y asignar el Flow resultante al campo state expuesto. También definimos accept como una expresión lambda que inicia una función de suspensión que alimenta nuestra máquina de estados.

Eso es todo. Ahora tenemos un ViewModel funcional desde un punto de vista literal y reactivo de programación.

8. Haz que el Adapter funcione con PagingData

A fin de vincular un PagingData a una 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 la IU de ReposAdapter para que funcione con un flujo de PagingData:

  • Actualmente, ReposAdapter implementa ListAdapter. En su lugar, haz que implemente PagingDataAdapter. El resto del cuerpo de la clase permanecerá sin cambios:
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}

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

9. Activa las actualizaciones de red

Reemplaza LiveData con Flow

Actualicemos SearchRepositoriesActivity para que funcione con Paging 3. A fin de poder trabajar con Flow<PagingData>, necesitamos lanzar una corrutina nueva. Haremos esto en el lifecycleScope, que será el responsable de cancelar la solicitud cuando se vuelva a crear la actividad.

Afortunadamente, no necesitamos cambiar demasiado. En lugar de observe() un LiveData, lo que haremos es launch() una coroutine y collect() un Flow. Se combinará el UiState con el Flow de LoadState de PagingAdapter para garantizar que no desplazaremos la lista hasta la parte superior con nuevas emisiones de PagingData si el usuario ya se desplazó.

Primero, como estamos mostrando el estado como StateFlow en lugar de LiveData, todas las referencias de Activity a LiveData se deben reemplazar por StateFlow. Además, asegúrate de agregar un argumento para el Flow de pagingData. El primer lugar se encuentra en el método bindState:

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        ...
    }

Este cambio tiene un efecto en cascada, ya que ahora debemos actualizar bindSearch() y bindList(). bindSearch() tiene el cambio más pequeño, así que comencemos aquí:

   private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener {...}
        searchRepo.setOnKeyListener {...}

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

El cambio principal que se muestra aquí es la necesidad de iniciar una corrutina y recopilar el cambio de búsqueda desde el Flow de UiState.

Soluciona el problema de desplazamiento y vincula datos

Ahora veamos la parte de desplazamiento. Primero, al igual que en los dos últimos cambios, reemplazamos LiveData con StateFlow y agregamos un argumento para el Flow de pagingData. Con eso listo, podemos pasar al objeto de escucha de desplazamiento. Ten en cuenta que antes usamos un OnScrollListener adjunto a la RecyclerView para determinar cuándo activar más datos. La biblioteca de Paging se encarga del desplazamiento de la lista, pero aún necesitamos el OnScrollListener como indicador de si el usuario se desplazó por la lista para la búsqueda actual. En el método bindList(), reemplacemos setupScrollListener() con un RecyclerView.OnScrollListener intercalado. También borraremos el método setupScrollListener() por completo.

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        // the rest of the code is unchanged
    }

A continuación, configuraremos la canalización para crear una marca booleana shouldScrollToTop. Ahora tenemos dos flujos que podemos usar para collect: Flow de PagingData y Flow de shouldScrollToTop.

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(...)
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }

En el ejemplo anterior, usamos collectLatest en el Flow de pagingData para poder cancelar la recopilación en emisiones anteriores de pagingData por emisiones nuevas de pagingData. En el caso de la marca shouldScrollToTop, las emisiones de PagingDataAdapter.loadStateFlow son síncronas con lo que se muestra en la IU, por lo que es seguro llamar de inmediato a list.scrollToPosition(0) en cuanto la marca booleana emitida sea verdadera.

El tipo en un LoadStateFlow es un objeto CombinedLoadStates.

CombinedLoadStates nos permite obtener el estado de carga de los 3 tipos diferentes de operaciones de carga:

  • CombinedLoadStates.refresh representa el estado cuando se carga PagingData por primera vez.
  • CombinedLoadStates.prepend representa el estado cuando se cargan los datos al comienzo de la lista.
  • CombinedLoadStates.append representa el estado cuando se cargan los datos al final de la lista.

En nuestro caso, queremos restablecer la posición de desplazamiento solo cuando la actualización haya finalizado; es decir, cuando LoadState sea refresh, NotLoading.

Ahora podemos quitar binding.list.scrollToPosition(0) de updateRepoListFromInput().

Una vez finalizados estos pasos, tu actividad debería verse de la siguiente manera:

class SearchRepositoriesActivity : AppCompatActivity() {

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

        // get the view model
        val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this))
            .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        // bind the state
        binding.bindState(
            uiState = viewModel.state,
            pagingData = viewModel.pagingDataFlow,
            uiActions = viewModel.accept
        )
    }

    /**
     * Binds the [UiState] provided  by the [SearchRepositoriesViewModel] to the UI,
     * and allows the UI to feed back user actions to it.
     */
    private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter

        bindSearch(
            uiState = uiState,
            onQueryChanged = uiActions
        )
        bindList(
            repoAdapter = repoAdapter,
            uiState = uiState,
            pagingData = pagingData,
            onScrollChanged = uiActions
        )
    }

    private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }
        searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

    private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
        searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                list.scrollToPosition(0)
                onQueryChanged(UiAction.Search(query = it.toString()))
            }
        }
    }

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }
}

Nuestra app debería compilarse y ejecutarse, pero sin el pie de página del estado de carga y el Toast que se muestra cuando se produce un error. En el siguiente paso, veremos la manera de mostrar el pie de página del estado de carga.

Puedes encontrar el código completo correspondiente a los pasos que realizamos hasta ahora en la rama step5-9_paging_3.0.

10. Muestra el estado de carga en un pie de página

En nuestra app, queremos poder mostrar un pie de página en función del estado de carga: queremos mostrar un ícono giratorio de progreso cuando se esté cargando la lista. En caso de que se produzca un error, queremos mostrar el error y un botón de reintentar.

3f6f2cd47b55de92.png 661da51b58c32b8c.png

El encabezado o pie de página que necesitamos compilar sigue la idea de una lista que se debe anexar al principio (como encabezado) o al final (como pie de página) de la lista real de elementos que estamos mostrando. El encabezado o pie de página es una lista que contiene un solo elemento: una vista que muestra una barra de progreso o un error con un botón de reintentar, según el LoadState de Paging.

Dado que mostrar un encabezado o pie de página basado en el estado de carga e implementar un mecanismo para reintentar son tareas comunes, la API de Paging 3 nos ayuda con ambas tareas.

Para la implementación de encabezado o pie de página, usaremos un LoadStateAdapter. Esta implementación de RecyclerView.Adapter recibe automáticamente una notificación de los cambios en el estado de carga. Garantiza que solo los estados Loading y Error hagan que se muestren elementos y notifica a la RecyclerView cuando se quita, inserta o modifica un elemento, según el LoadState.

Para el mecanismo de reintento, usamos adapter.retry(). De forma interna, este método llamará a tu implementación de PagingSource para la página correcta. La respuesta se propagará automáticamente a través de Flow<PagingData>.

Veamos cómo será el aspecto de nuestra implementación de encabezado o pie de página.

Al igual que con cualquier lista, tenemos 3 archivos para crear:

  • El archivo de diseño, que contiene los elementos de la IU que muestran el progreso, el error y el botón de reintentar
  • El archivo **ViewHolder** que permite que los elementos de la IU sean visibles en función del LoadState de Paging
  • El archivo del adaptador, que define cómo crear y vincular el ViewHolder. En lugar de extender un objeto RecyclerView.Adapter, extenderemos LoadStateAdapter de Paging 3

Crea el diseño de la vista

Crea el diseño de repos_load_state_footer_view_item para el estado de carga del repositorio. Debería tener una ProgressBar, una TextView (para mostrar el error) y un Button de reintentar. Las strings y dimensiones necesarias ya están declaradas en el proyecto.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:padding="8dp">
    <TextView
        android:id="@+id/error_msg"
        android:textColor="?android:textColorPrimary"
        android:textSize="@dimen/error_text_size"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAlignment="center"
        tools:text="Timeout"/>
    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"/>
    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/retry"/>
</LinearLayout>

Crea el ViewHolder

Crea un ViewHolder nuevo llamado ReposLoadStateViewHolder en la carpeta ui**.** Debería recibir una función para reintentar como parámetro, y se llamará cuando se presione el botón de reintentar. Crea una función bind() que reciba el LoadState como parámetro y configure la visibilidad de cada vista según el LoadState. Una implementación de ReposLoadStateViewHolder con ViewBinding se verá de la siguiente manera:

class ReposLoadStateViewHolder(
        private val binding: ReposLoadStateFooterViewItemBinding,
        retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.retryButton.setOnClickListener { retry.invoke() }
    }

    fun bind(loadState: LoadState) {
        if (loadState is LoadState.Error) {
            binding.errorMsg.text = loadState.error.localizedMessage
        }
        binding.progressBar.isVisible = loadState is LoadState.Loading
        binding.retryButton.isVisible = loadState is LoadState.Error
        binding.errorMsg.isVisible = loadState is LoadState.Error
    }

    companion object {
        fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.repos_load_state_footer_view_item, parent, false)
            val binding = ReposLoadStateFooterViewItemBinding.bind(view)
            return ReposLoadStateViewHolder(binding, retry)
        }
    }
}

Crea el LoadStateAdapter

Crea también un ReposLoadStateAdapter que extienda LoadStateAdapter en la carpeta ui. El adaptador debería recibir la función de reintento como parámetro, ya que esta se pasará al ViewHolder cuando se construya.

Como con cualquier Adapter, necesitamos implementar los métodos onBind() y onCreate(). LoadStateAdapter facilita las cosas, ya que pasa el LoadState en ambas funciones. En onBindViewHolder(), vincula tu ViewHolder. En onCreateViewHolder(), define cómo crear el ReposLoadStateViewHolder según el ViewGroup superior y la función de reintentar:

class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
    override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
        return ReposLoadStateViewHolder.create(parent, retry)
    }
}

Vincula el adaptador de pie de página con la lista

Ahora que tenemos todos los elementos de nuestro pie de página, los vincularemos a nuestra lista. Para ello, el PagingDataAdapter tiene 3 métodos útiles:

  • withLoadStateHeader, si solo queremos mostrar un encabezado: Se usa cuando tu lista solo permite que se agreguen elementos al comienzo.
  • withLoadStateFooter, si solo queremos mostrar un pie de página: Se usa cuando tu lista solo permite que se agreguen elementos al final.
  • withLoadStateHeaderAndFooter, si queremos mostrar un encabezado y un pie de página: Se usa en caso de que se pueda paginar la lista en ambas direcciones.

Actualiza el método ActivitySearchRepositoriesBinding.bindState() y llama a withLoadStateHeaderAndFooter() en el adaptador. Como función para reintentar, podemos llamar a adapter.retry().

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { repoAdapter.retry() },
            footer = ReposLoadStateAdapter { repoAdapter.retry() }
        )
        ...
    }

Dado que tenemos una lista de desplazamiento infinito, una forma fácil de ver el pie de página es colocar el teléfono o el emulador en modo de avión y desplazarte hasta el final de la lista.

Ejecutemos la app.

Puedes encontrar el código completo de los pasos que realizamos hasta el momento en la rama step10_loading_state_footer.

11. Muestra el estado de carga en Activity

Quizá hayas notado dos problemas:

  • Durante la migración a Paging 3, ya no se muestra un mensaje cuando la lista de resultados está vacía.
  • Cuando realizas una búsqueda nueva, el resultado actual de la búsqueda permanece en pantalla hasta que obtengamos una respuesta de red. Eso representa una mala experiencia del usuario. En su lugar, debemos mostrar una barra de progreso o un botón de reintentar.

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

La solución a ambos problemas es reaccionar a los cambios de estado de carga en nuestra SearchRepositoriesActivity.

Muestra el mensaje de lista vacía

Primero, recuperemos el mensaje de lista vacía. Solo debería aparecer una vez que se haya cargado la lista y el número de elementos de la lista sea igual a 0. Para saber cuándo se cargó la lista, usaremos el método PagingDataAdapter.loadStateFlow. Este Flow se emite cada vez que hay un cambio en el estado de carga a través de un objeto CombinedLoadStates.

CombinedLoadStates nos proporciona el estado de carga de la PageSource que definimos o del RemoteMediator necesario para los casos de red y bases de datos (volveremos sobre este punto más adelante).

En SearchRepositoriesActivity.bindList(), recopilamos directamente de loadStateFlow. La lista está vacía cuando el estado refresh de CombinedLoadStates es NotLoading y adapter.itemCount == 0. Luego, activaremos o desactivaremos la visibilidad de emptyList y list respectivamente:

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                }
            }
        }
    }

Muestra el estado de carga

Actualicemos nuestro activity_search_repositories.xml para agregar un botón de reintentar y una barra de progreso a la IU:

<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.SearchRepositoriesActivity">
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/input_layout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginLeft="8dp"
        android:layout_marginRight="8dp"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <EditText
            android:id="@+id/search_repo"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/search_hint"
            android:imeOptions="actionSearch"
            android:inputType="textNoSuggestions"
            android:selectAllOnFocus="true"
            tools:text="Android"/>
    </com.google.android.material.textfield.TextInputLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:paddingVertical="@dimen/row_item_margin_vertical"
        android:scrollbars="vertical"
        app:layoutManager="LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/input_layout"
        tools:ignore="UnusedAttribute"/>

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/retry"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <TextView android:id="@+id/emptyList"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/no_results"
        android:textSize="@dimen/repo_name_size"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Nuestro botón de reintentar debería activar una nueva carga de PagingData. A tal efecto, llamamos a adapter.retry() en la implementación de onClickListener, como hicimos para el encabezado o el pie de página:

// SearchRepositoriesActivity.kt

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        retryButton.setOnClickListener { repoAdapter.retry() }
        ...
}

Ahora implementemos reacciones a los cambios en el estado de carga en SearchRepositoriesActivity.bindList. Como solo queremos que nuestra barra de progreso se muestre cuando tengamos una búsqueda nueva, debemos basarnos en la carga de nuestra fuente de paginación, en especial, en CombinedLoadStates.source.refresh y el LoadState: Loading o Error. Además, una función que comentamos en un paso anterior consistía en mostrar un Toast en caso de obtener un error, así que asegurémonos de incorporarlo también. A fin de mostrar el mensaje de error, necesitaremos comprobar si CombinedLoadStates.prepend o CombinedLoadStates.append son una instancia de LoadState.Error y recuperar el mensaje de error correspondiente.

Actualicemos nuestro método ActivitySearchRepositoriesBinding.bindList en SearchRepositoriesActivity para tener esta funcionalidad:

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.source.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.source.refresh is LoadState.Error

                // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
                val errorState = loadState.source.append as? LoadState.Error
                    ?: loadState.source.prepend as? LoadState.Error
                    ?: loadState.append as? LoadState.Error
                    ?: loadState.prepend as? LoadState.Error
                errorState?.let {
                    Toast.makeText(
                        this@SearchRepositoriesActivity,
                        "\uD83D\uDE28 Wooops ${it.error}",
                        Toast.LENGTH_LONG
                    ).show()
                }
            }
        }
    }

Ahora, ejecutemos la app y veamos cómo funciona.

Eso es todo. Con la configuración actual, los componentes de la biblioteca de Paging son los que activan las solicitudes a la API en el momento adecuado, controlan la caché en la memoria y muestran los datos. Ejecuta la app y busca repositorios.

Puedes encontrar el código completo correspondiente a los pasos que realizamos hasta ahora en la rama step11_loading_state.

12. Agrega separadores de lista

Una forma de mejorar la legibilidad de la lista es agregar separadores. Por ejemplo, en la app, debido a que los repositorios se ordenan de forma descendente según la cantidad de estrellas, podríamos tener separadores cada 10,000 estrellas. Para ayudar a implementar esto, la API de Paging 3 permite insertar separadores en PagingData.

573969750b4c719c.png

Si agregas separadores en PagingData, podrás modificar la lista que mostramos en pantalla. Ya no mostraremos solo los objetos Repo, sino también los objetos de separación. Por lo tanto, debemos cambiar el modelo de la IU que exponemos del ViewModel de Repo a otro tipo que pueda encapsular los tipos RepoItem y SeparatorItem. A continuación, deberemos actualizar nuestra IU a fin de que admita separadores:

  • Agrega un diseño y un ViewHolder para los separadores.
  • Actualiza RepoAdapter para que admita la creación y vinculación de separadores y repositorios.

Veamos en detalle cómo es la implementación.

Cambia el modelo de la IU

Actualmente, SearchRepositoriesViewModel.searchRepo() muestra Flow<PagingData<Repo>>. A fin de admitir repositorios y separadores, crearemos una clase UiModel sellada en el mismo archivo con SearchRepositoriesViewModel. Podemos tener 2 tipos de objetos UiModel: RepoItem y SeparatorItem.

sealed class UiModel {
    data class RepoItem(val repo: Repo) : UiModel()
    data class SeparatorItem(val description: String) : UiModel()
}

Como queremos separar los repositorios basados en 10,000 estrellas, crearemos una propiedad de extensión en RepoItem que redondee la cantidad de estrellas:

private val UiModel.RepoItem.roundedStarCount: Int
    get() = this.repo.stars / 10_000

Inserta separadores

Ahora SearchRepositoriesViewModel.searchRepo() debería mostrar Flow<PagingData<UiModel>>.

class SearchRepositoriesViewModel(
    private val repository: GithubRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    ...

    fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
        ...
    }
}

Veamos cómo cambia la implementación. De momento, repository.getSearchResultStream(queryString) muestra un Flow<PagingData<Repo>>, por lo que la primera operación que necesitamos agregar es la de transformar cada Repo en un UiModel.RepoItem. Para hacerlo, podemos usar el operador Flow.map y, luego, mapear cada PagingData a fin de compilar un nuevo UiModel.Repo a partir del elemento Repo actual, lo que da como resultado un Flow<PagingData<UiModel.RepoItem>>:

...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
                .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...

Ya podemos insertar los separadores. Para cada emisión del Flow, llamaremos a PagingData.insertSeparators(). Este método muestra un PagingData que contiene cada elemento original, con un separador opcional que generarás, según los elementos anteriores y posteriores. En las condiciones de límite (al principio o al final de la lista), los valores respectivos que se encuentren antes o después de los elementos serán null. Si no se necesita crear un separador, muestra null.

Debido a que cambiamos el tipo de elementos PagingData de UiModel.Repo a UiModel, asegúrate de configurar de forma explícita los argumentos de tipo del método insertSeparators().

El método searchRepo() debería verse de la siguiente manera:

   private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
        repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators { before, after ->
                    if (after == null) {
                        // we're at the end of the list
                        return@insertSeparators null
                    }

                    if (before == null) {
                        // we're at the beginning of the list
                        return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                    }
                    // check between 2 items
                    if (before.roundedStarCount > after.roundedStarCount) {
                        if (after.roundedStarCount >= 1) {
                            UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                        } else {
                            UiModel.SeparatorItem("< 10.000+ stars")
                        }
                    } else {
                        // no separator
                        null
                    }
                }
            }

Admite varios tipos de vistas

Los objetos SeparatorItem deben mostrarse en nuestro RecyclerView. Aquí solo mostramos una string, así que vamos a crear un diseño separator_view_item con una TextView en la carpeta res/layout:

<?xml version="1.0" encoding="utf-8"?>
<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="wrap_content"
    android:background="@color/separatorBackground">

    <TextView
        android:id="@+id/separator_description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:padding="@dimen/row_item_margin_horizontal"
        android:textColor="@color/separatorText"
        android:textSize="@dimen/repo_name_size"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>

Creemos un SeparatorViewHolder en la carpeta ui, donde solo vincularemos una string a la TextView:

class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    private val description: TextView = view.findViewById(R.id.separator_description)

    fun bind(separatorText: String) {
        description.text = separatorText
    }

    companion object {
        fun create(parent: ViewGroup): SeparatorViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.separator_view_item, parent, false)
            return SeparatorViewHolder(view)
        }
    }
}

Actualiza ReposAdapter a fin de que admita un UiModel, en lugar de un Repo:

  • Actualiza el parámetro PagingDataAdapter de Repo a UiModel.
  • Implementa un comparador UiModel que reemplace el REPO_COMPARATOR.
  • Crea el SeparatorViewHolder y vincúlalo con la descripción de UiModel.SeparatorItem.

Como ahora debemos mostrar 2 ViewHolder diferentes, reemplaza RepoViewHolder con ViewHolder:

  • Actualiza el parámetro PagingDataAdapter.
  • Actualiza el tipo de datos que se muestra onCreateViewHolder.
  • Actualiza el parámetro holder onBindViewHolder.

Tu ReposAdapter final tendrá el siguiente aspecto:

class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == R.layout.repo_view_item) {
            RepoViewHolder.create(parent)
        } else {
            SeparatorViewHolder.create(parent)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is UiModel.RepoItem -> R.layout.repo_view_item
            is UiModel.SeparatorItem -> R.layout.separator_view_item
            null -> throw UnsupportedOperationException("Unknown view")
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val uiModel = getItem(position)
        uiModel.let {
            when (uiModel) {
                is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
                is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
            }
        }
    }

    companion object {
        private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
            override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
                return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
                        oldItem.repo.fullName == newItem.repo.fullName) ||
                        (oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
                                oldItem.description == newItem.description)
            }

            override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
                    oldItem == newItem
        }
    }
}

Eso es todo. Cuando ejecutes la app, deberías ver los separadores.

Puedes encontrar el código completo correspondiente a los pasos que realizamos hasta ahora en la rama step12_separators.

13. Paging desde la red y la base de datos

Guardemos los datos en una base de datos local a fin de agregar soporte sin conexión a nuestra app. De esta manera, la base de datos será la fuente de información de la app, y siempre se cargarán datos desde allí. Cuando no tengamos más datos, solicitaremos más recursos a la red y los guardamos en la base de datos. Como la base de datos es la fuente de información, la IU se actualizará automáticamente cuando se guarden más datos.

Esto es lo que debemos hacer para agregar soporte sin conexión:

  1. Crea una base de datos de Room, una tabla para guardar los objetos Repo y un DAO que usaremos para trabajar con los objetos Repo.
  2. Define cómo cargar datos desde la red cuando lleguemos al final de los datos de la base de datos mediante la implementación de un RemoteMediator.
  3. Compila un Pager basado en la tabla de repositorios como fuente de datos y RemoteMediator para cargar y guardar datos.

Sigamos estos pasos.

14. Define la base de datos de Room, la tabla y el DAO

Nuestros objetos Repo deben guardarse en la base de datos, así que comencemos haciendo de la clase Repo una entidad, con tableName = "repos", donde Repo.id es la clave primaria. Para hacerlo, anota la clase Repo con @Entity(tableName = "repos") y agrega la anotación @PrimaryKey a id. Tu clase Repo debería verse así:

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey @field:SerializedName("id") val id: Long,
    @field:SerializedName("name") val name: String,
    @field:SerializedName("full_name") val fullName: String,
    @field:SerializedName("description") val description: String?,
    @field:SerializedName("html_url") val url: String,
    @field:SerializedName("stargazers_count") val stars: Int,
    @field:SerializedName("forks_count") val forks: Int,
    @field:SerializedName("language") val language: String?
)

Crea un nuevo paquete db. Aquí implementaremos la clase que accederá a los datos en la base de datos y la clase que definirá dicha base.

Implementa el objeto de acceso a datos (DAO) a fin de acceder a la tabla repos mediante la creación de una interfaz RepoDao, anotada con @Dao. Necesitamos tomar las siguientes acciones en Repo:

  • Inserta una lista de objetos Repo. Si los objetos Repo ya están en la tabla, reemplázalos.
 @Insert(onConflict = OnConflictStrategy.REPLACE)
 suspend fun insertAll(repos: List<Repo>)
  • Consulta los repositorios que contengan la cadena de búsqueda en el nombre o en la descripción y ordena esos resultados en orden descendente según la cantidad de estrellas y, luego, alfabéticamente por nombre. En lugar de mostrar un List<Repo>, muestra PagingSource<Int, Repo>. De esa manera, la tabla repos se convierte en la fuente de datos de Paging.
@Query("SELECT * FROM repos WHERE " +
  "name LIKE :queryString OR description LIKE :queryString " +
  "ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
  • Borra todos los datos de la tabla Repos.
@Query("DELETE FROM repos")
suspend fun clearRepos()

Tu RepoDao debería verse así:

@Dao
interface RepoDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(repos: List<Repo>)

    @Query("SELECT * FROM repos WHERE " +
   "name LIKE :queryString OR description LIKE :queryString " +
   "ORDER BY stars DESC, name ASC")
    fun reposByName(queryString: String): PagingSource<Int, Repo>

    @Query("DELETE FROM repos")
    suspend fun clearRepos()
}

Implementa la base de datos del repositorio:

  • Crea una clase abstracta RepoDatabase que extienda RoomDatabase.
  • Anota la clase con @Database, define la lista de entidades que contendrán la clase Repo y establece la versión de base de datos en 1. A los fines de este codelab, no necesitamos exportar el esquema.
  • Define una función abstracta que muestre el ReposDao.
  • Crea una función getInstance() en un companion object que compile el objeto RepoDatabase si aún no existe.

Tu RepoDatabase tendrá este aspecto:

@Database(
    entities = [Repo::class],
    version = 1,
    exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao

    companion object {

        @Volatile
        private var INSTANCE: RepoDatabase? = null

        fun getInstance(context: Context): RepoDatabase =
                INSTANCE ?: synchronized(this) {
                    INSTANCE
                            ?: buildDatabase(context).also { INSTANCE = it }
                }

        private fun buildDatabase(context: Context) =
                Room.databaseBuilder(context.applicationContext,
                        RepoDatabase::class.java, "Github.db")
                        .build()
    }
}

Ahora que configuramos nuestra base de datos, veamos cómo solicitar datos de la red y guardarlos en ella.

15. Solicita y guarda datos: Descripción general

La biblioteca de Paging usa la base de datos como fuente de información para los datos que deben mostrarse en la IU. Cuando no tengamos más datos en la base de datos, necesitaremos solicitar más a la red. Para ayudar con esto, Paging 3 define la clase abstracta RemoteMediator, con un método que se debe implementar: load(). Se llamará a este método cada vez que necesitemos cargar más datos de la red. Esta clase muestra un objeto MediatorResult, que puede ser uno de los siguientes:

  • Error, si se produjo un error al momento de solicitar datos de la red
  • Success, si recibimos datos de la red de forma correcta; aquí, también necesitamos pasar un indicador que muestre si se pueden cargar más datos o no (por ejemplo, si la respuesta de la red es correcta, pero tenemos una lista vacía de repositorios, significa que no hay más datos para cargar)

En el paquete de data, crearemos una nueva clase llamada GithubRemoteMediator que extienda RemoteMediator. Esta clase se volverá a crear con cada búsqueda nueva. Por lo tanto, recibirá los siguientes parámetros:

  • La búsqueda String
  • El GithubService, para que podamos realizar solicitudes de red
  • El RepoDatabase, para que podamos guardar los datos que recibimos de la solicitud de red
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
    private val query: String,
    private val service: GithubService,
    private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {

   }
}

A los efectos de poder crear la solicitud de red, el método de carga tiene 2 parámetros que nos proporcionan toda la información que necesitamos:

  • PagingState: Incluye información sobre las páginas que se cargaron antes, el índice de la lista al que más recientemente se accedió y la PagingConfig que definimos al inicializar el flujo de paginación.
  • LoadType: Indica si debemos cargar datos al final (LoadType.APPEND) o al principio de los datos (LoadType.PREPEND) que cargamos con anterioridad, o bien si esta es la primera vez que cargamos datos (LoadType.REFRESH).

Por ejemplo, si el tipo de carga es LoadType.APPEND, recuperaremos el último elemento que se cargó desde el PagingState. En función de esto, podremos averiguar cómo cargar el próximo lote de objetos Repo mediante el cálculo de la siguiente página que se cargará.

En la sección que aparece a continuación, descubrirás cómo calcular las claves para que se carguen las páginas siguientes y las anteriores.

16. Calcula y guarda claves de página remotas

A los fines de la API de GitHub, la clave de página que usamos para solicitar las páginas de los repositorios es solo un índice de página que aumenta cuando se obtiene la página siguiente. Esto significa que, con un objeto Repo, se puede solicitar el siguiente lote de objetos Repo en función del índice de la página + 1. El lote anterior de objetos Repo se puede solicitar en función del índice de la página - 1. Todos los objetos Repo recibidos en una determinada respuesta de la página tendrán las mismas claves siguiente y anterior.

Cuando recibimos el último elemento cargado desde el PagingState, no hay forma de conocer el índice de la página a la que pertenecía. Para solucionar este problema, podemos agregar otra tabla que almacene las claves de página siguiente y anterior correspondiente a cada Repo. Podemos llamarla remote_keys. Si bien esto se puede hacer en la tabla Repo, crear una tabla nueva para las claves remotas siguiente y anterior asociadas con un Repo nos permitirá tener una mejor separación de problemas.

En el paquete db, creemos una nueva clase de datos llamada RemoteKeys, anotémosla con @Entity y agreguemos 3 propiedades: el repositorio id (que también es la clave primaria) y las claves anterior y siguiente (que pueden ser null cuando no podemos agregar datos a continuación o anteponerlos).

@Entity(tableName = "remote_keys")
data class RemoteKeys(
    @PrimaryKey
    val repoId: Long,
    val prevKey: Int?,
    val nextKey: Int?
)

Creemos una interfaz RemoteKeysDao. Necesitaremos las siguientes capacidades:

  • Insertar una lista de **RemoteKeys**, ya que cada vez que obtengamos Repos de la red, generaremos las claves remotas para ellos
  • Obtener una **RemoteKey** basada en un id de Repo
  • Borrar las **RemoteKeys**, que usaremos cuando tengamos una búsqueda nueva
@Dao
interface RemoteKeysDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
    suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

    @Query("DELETE FROM remote_keys")
    suspend fun clearRemoteKeys()
}

Agreguemos la tabla RemoteKeys a nuestra base de datos y démosle acceso a RemoteKeysDao. Para ello, actualiza el RepoDatabase de la siguiente manera:

  • Agrega RemoteKeys a la lista de entidades.
  • Expón RemoteKeysDao como una función abstracta.
@Database(
        entities = [Repo::class, RemoteKeys::class],
        version = 1,
        exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao
    abstract fun remoteKeysDao(): RemoteKeysDao

    ...
    // rest of the class doesn't change
}

17. Solicita y guarda datos: Implementación

Ahora que guardamos las claves remotas, volvamos a GithubRemoteMediator y veamos cómo usarlas. Esta clase reemplazará nuestra GithubPagingSource. Copiemos la declaración GITHUB_STARTING_PAGE_INDEX de GithubPagingSource en nuestro GithubRemoteMediator y borremos la clase GithubPagingSource.

Veamos cómo podemos implementar el método GithubRemoteMediator.load():

  1. Averigua qué página debemos cargar desde la red en función del LoadType.
  2. Activar la solicitud de red
  3. Una vez completada la solicitud de red, si la lista recibida de repositorios no está vacía, hagamos lo siguiente:
  4. Calculemos las RemoteKeys para cada Repo.
  5. Si esta es una búsqueda nueva (loadType = REFRESH), borremos la base de datos.
  6. Guardemos las RemoteKeys y los Repos en la base de datos.
  7. Muestra MediatorResult.Success(endOfPaginationReached = false).
  8. Si la lista de repositorios está vacía, mostraremos MediatorResult.Success(endOfPaginationReached = true). Si se produce un error cuando solicitamos datos, mostraremos MediatorResult.Error.

Así se ve el código en general. Más adelante, reemplazaremos los TODO.

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
    val page = when (loadType) {
        LoadType.REFRESH -> {
         // TODO
        }
        LoadType.PREPEND -> {
        // TODO
        }
        LoadType.APPEND -> {
        // TODO
        }
    }
    val apiQuery = query + IN_QUALIFIER

    try {
        val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)

        val repos = apiResponse.items
        val endOfPaginationReached = repos.isEmpty()
        repoDatabase.withTransaction {
            // clear all tables in the database
            if (loadType == LoadType.REFRESH) {
                repoDatabase.remoteKeysDao().clearRemoteKeys()
                repoDatabase.reposDao().clearRepos()
            }
            val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
            val nextKey = if (endOfPaginationReached) null else page + 1
            val keys = repos.map {
                RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
            }
            repoDatabase.remoteKeysDao().insertAll(keys)
            repoDatabase.reposDao().insertAll(repos)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    } catch (exception: IOException) {
        return MediatorResult.Error(exception)
    } catch (exception: HttpException) {
        return MediatorResult.Error(exception)
    }
}

Veamos cómo encontrar la página que se debe cargar según el LoadType.

18. Obtén la página según el LoadType

Ahora que sabemos lo que sucede en el método GithubRemoteMediator.load() una vez que tenemos la clave de página, veamos cómo calcularla. Esto dependerá de la LoadType.

LoadType.APPEND

Cuando necesitemos cargar datos al final del conjunto de datos cargados actualmente, el parámetro de carga será LoadType.APPEND. Ahora, según el último elemento de la base de datos, deberemos calcular la clave de la página de red.

  1. Necesitaremos obtener la clave remota del último elemento Repo cargado de la base de datos. Separemos esto en una función:
    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
        // Get the last page that was retrieved, that contained items.
        // From that last page, get the last item
        return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
                ?.let { repo ->
                    // Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
                }
    }
  1. Si remoteKeys es un valor nulo, significa que el resultado de la actualización aún no está en la base de datos. Podemos mostrar un mensaje de éxito cuando endOfPaginationReached = false, porque Paging volverá a llamar a este método si RemoteKeys deja de ser un valor nulo. Cuando remoteKeys no es null, pero su nextKey sí es null, significa que se alcanzó el final de la paginación y no hay más datos para cargar.
val page = when (loadType) {
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        // We can return Success with endOfPaginationReached = false because Paging
        // will call this method again if RemoteKeys becomes non-null.
        // If remoteKeys is NOT NULL but its nextKey is null, that means we've reached
        // the end of pagination for append.
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
      ...
  }

LoadType.PREPEND

Cuando necesitemos cargar datos al comienzo del conjunto de datos cargados actualmente, el parámetro de carga será LoadType.PREPEND. En función del primer elemento de la base de datos, deberemos calcular la clave de la página de red.

  1. Necesitaremos obtener la clave remota del primer elemento Repo cargado de la base de datos. Separemos esto en una función:
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
    // Get the first page that was retrieved, that contained items.
    // From that first page, get the first item
    return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let { repo ->
                // Get the remote keys of the first items retrieved
                repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
            }
}
  1. Si remoteKeys es un valor nulo, significa que el resultado de la actualización aún no está en la base de datos. Podemos mostrar un mensaje de éxito cuando endOfPaginationReached = false, porque Paging volverá a llamar a este método si RemoteKeys deja de ser un valor nulo. Cuando remoteKeys no es null, pero su prevKey sí es null, significa que se alcanzó el final de la paginación y no hay más datos para anteponer.
val page = when (loadType) {
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }

      ...
  }

LoadType.REFRESH

Se llamará a LoadType.REFRESH cuando sea la primera vez que carguemos datos o cuando se llame a PagingDataAdapter.refresh(). El punto de referencia para cargar nuestros datos ahora será la state.anchorPosition. Si esta es la primera carga, entonces la anchorPosition será null. Cuando se llame a PagingDataAdapter.refresh(), anchorPosition será la primera posición visible de la lista que se muestre, por lo que deberemos cargar la página que contenga ese elemento específico.

  1. Según la anchorPosition del state, podemos obtener el elemento Repo más cercano a esa posición llamando a state.closestItemToPosition().
  2. Según el elemento Repo, podemos obtener las RemoteKeys de la base de datos.
private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Repo>
): RemoteKeys? {
    // The paging library is trying to load data after the anchor position
    // Get the item closest to the anchor position
    return state.anchorPosition?.let { position ->
        state.closestItemToPosition(position)?.id?.let { repoId ->
   repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
        }
    }
}
  1. Si remoteKey no es nula, entonces podemos obtener la nextKey a partir de ella. En la API de GitHub, las claves de página se incrementan de forma secuencial. Por lo tanto, para obtener la página que contenga el elemento actual, restaremos 1 de remoteKey.nextKey.
  2. Si RemoteKey es null (porque la anchorPosition era null), la página que deberemos cargar será la inicial: GITHUB_STARTING_PAGE_INDEX.

El cálculo de la página completa tendrá el siguiente aspecto:

val page = when (loadType) {
    LoadType.REFRESH -> {
        val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
        remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
    }
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
}

19. Actualiza la creación de flujos de paginación

Ahora que el GithubRemoteMediator y la PagingSource de nuestro ReposDao están implementados, deberemos actualizar GithubRepository.getSearchResultStream a fin de usarlos.

Para hacerlo, GithubRepository necesitará acceder a la base de datos. Pasaremos la base de datos como parámetro en el constructor. Además, como esta clase usará GithubRemoteMediator:

class GithubRepository(
        private val service: GithubService,
        private val database: RepoDatabase
) { ... }

Actualiza el archivo Injection:

  • El método provideGithubRepository debe obtener un contexto como parámetro y, en el constructor GithubRepository, invocar a RepoDatabase.getInstance.
  • El método provideViewModelFactory debe obtener un contexto como parámetro y pasarlo a provideGithubRepository.
object Injection {
    private fun provideGithubRepository(context: Context): GithubRepository {
        return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
    }

    fun provideViewModelFactory(context: Context, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
        return ViewModelFactory(owner, provideGithubRepository(context))
    }
}

Actualiza el método SearchRepositoriesActivity.onCreate() y pasa el contexto a Injection.provideViewModelFactory():

       // get the view model
        val viewModel = ViewModelProvider(
            this, Injection.provideViewModelFactory(
                context = this,
                owner = this
            )
        )
            .get(SearchRepositoriesViewModel::class.java)

Volvamos al GithubRepository. Primero, para poder buscar repositorios por nombre, tendremos que agregar % al principio y al final de la string de búsqueda. Luego, cuando llamemos a reposDao.reposByName, obtendremos una PagingSource. Como la PagingSource se invalida cada vez que realizamos un cambio en la base de datos, necesitamos decirle a Paging cómo obtener una nueva instancia de la PagingSource. Para hacer esto, crearemos una función que llame a la búsqueda de la base de datos:

// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory =  { database.reposDao().reposByName(dbQuery)}

Ahora podemos cambiar el compilador de Pager para que use un GithubRemoteMediator y la pagingSourceFactory. Como Pager es una API experimental, tendremos que anotarla con @OptIn:

@OptIn(ExperimentalPagingApi::class)
return Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
        remoteMediator = GithubRemoteMediator(
                query,
                service,
                database
        ),
        pagingSourceFactory = pagingSourceFactory
).flow

Eso es todo. Ejecutemos la app.

Reacciona a los estados de carga cuando se usa un RemoteMediator

Hasta ahora, cuando leíamos desde CombinedLoadStates, siempre leíamos desde CombinedLoadStates.source. Sin embargo, cuando se usa RemoteMediator, la información de carga precisa solo se puede obtener si se verifican CombinedLoadStates.source y CombinedLoadStates.mediator. En particular, actualmente, se activa un desplazamiento a la parte superior de la lista de búsquedas nuevas cuando el elemento LoadState de source es NotLoading. También debemos asegurarnos de que el RemoteMediator que acabamos de agregar tenga un LoadState de NotLoading.

Para ello, definimos una enumeración que resuma los estados de presentación de nuestra lista como recuperados por Pager:

enum class RemotePresentationState {
    INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}

Con la definición anterior, podemos comparar las emisiones consecutivas de CombinedLoadStates y usarlas para determinar el estado exacto de los elementos de la lista.

@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
    scan(RemotePresentationState.INITIAL) { state, loadState ->
        when (state) {
            RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
                is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
                else -> state
            }
            RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
                is LoadState.NotLoading -> RemotePresentationState.PRESENTED
                else -> state
            }
        }
    }
        .distinctUntilChanged()

Lo anterior nos permite actualizar la definición del Flow de notLoading que usamos para verificar si podemos desplazarnos hasta la parte superior de la lista:

       val notLoading = repoAdapter.loadStateFlow
            .asRemotePresentationState()
            .map { it == RemotePresentationState.PRESENTED }

Del mismo modo, cuando se trata de mostrar un ícono giratorio durante la carga inicial de la página (en la extensión bindList en SearchRepositoriesActivity), la app se basa en LoadState.source. Pero ahora queremos mostrar un ícono giratorio de carga solo para las cargas de RemoteMediator. Otros elementos de la IU cuya visibilidad depende de LoadStates también comparten esta preocupación. Por lo tanto, actualizamos la vinculación de LoadStates a los elementos de la IU de la siguiente manera:

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                ...
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds, either from the the local db or the remote.
                list.isVisible =  loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
                }
            }
        }
    }

Además, debido a que la base de datos es nuestra única fuente de confianza, es posible iniciar la app en un estado en el que tengamos datos en la base de datos, pero que una actualización con RemoteMediator falle. Este es un caso límite interesante, pero es fácil de manejar. Para hacerlo, podemos conservar una referencia al encabezado LoadStateAdapter y anular su LoadState para que sea el del elemento RemoteMediator siempre y cuando su estado de actualización tenga un error. De lo contrario, elegiremos la opción predeterminada.

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                // Show a retry header if there was an error refreshing, and items were previously
                // cached OR default to the default prepend state
                header.loadState = loadState.mediator
                    ?.refresh
                    ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
                    ?: loadState.prepend
                ...
            }
        }
    }

Puedes encontrar el código completo correspondiente a los pasos que realizamos hasta ahora en la rama step13-19_network_and_database.

20. Conclusión

Ahora que agregamos todos los componentes, resumamos lo aprendido.

  • La PagingSource carga de forma asíncrona los datos desde una fuente que tú defines.
  • El Pager.flow crea un Flow<PagingData> basado en una configuración y una función que definen la forma de crear una instancia de la PagingSource.
  • El Flow emite un PagingData nuevo cada vez que la PagingSource carga datos nuevos.
  • La IU observa el PagingData modificado y usa un PagingDataAdapter a fin de actualizar la RecyclerView que presenta los datos.
  • Para reintentar una carga que haya tenido errores desde la IU, usa el método PagingDataAdapter.retry. Internamente, la biblioteca de Paging activará el método PagingSource.load().
  • A los efectos de agregar separadores a tu lista, crea un tipo de alto nivel con separadores como uno de los tipos admitidos. Luego, usa el método PagingData.insertSeparators() para implementar la lógica de generación del separador.
  • A fin de mostrar el estado de carga como encabezado o pie de página, usa el método PagingDataAdapter.withLoadStateHeaderAndFooter() e implementa un LoadStateAdapter. Si quieres ejecutar otras acciones en función del estado de carga, usa la devolución de llamada PagingDataAdapter.addLoadStateListener().
  • Para trabajar con la red y la base de datos, implementa un RemoteMediator.
  • Cuando se agrega un RemoteMediator, actualiza el campo mediator en LoadStatesFlow.