Paging de Android

Qué aprenderás

  • Cuáles son los componentes principales de Paging 3.0
  • Cómo agregar Paging 3.0 a tu proyecto
  • Cómo agregar un encabezado o pie de página a tu lista mediante la API de Paging 3.0
  • Cómo agregar separadores de lista por medio de la API de Paging 3.0
  • 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:

e662a697dd078356.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.

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 Paging 3.0 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:

Download source code

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

b3c0dfdb92dfed77.png

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

86fcb1b9b845c2f6.png

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.

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 consulta.
  • El usuario no puede reintentar una consulta con errores.

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 cómo la biblioteca de Paging puede ayudarnos con estos problemas y qué componentes incluye.

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.

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

}

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.0, 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.0 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 {
        private 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.

En nuestro SearchRepositoriesViewModel, exponemos un repoResult: LiveData<RepoSearchResult>. La función repoResult 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.0, ya no necesitamos convertir nuestro Flow en LiveData. En cambio, SearchRepositoriesViewModel tendrá un miembro Flow<PagingData<Repo>> privado con el mismo propósito que tenía repoResult.

En lugar de usar un objeto LiveData para cada búsqueda nueva, podemos usar una String. Esto nos ayudará a garantizar que, cuando recibamos una nueva búsqueda que sea igual a la actual, mostraremos el Flow existente. Solo tendremos que llamar a repository.getSearchResultStream() si la nueva búsqueda es diferente.

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.

Reescribiremos la mayor parte del SearchRepositoriesViewModel para aprovechar la funcionalidad integrada de Paging 3.0. Tu SearchRepositoriesViewModel se verá de la siguiente manera:

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
                .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

Veamos qué cambios hicimos en SearchRepositoriesViewModel:

  • Se agregaron los miembros nuevos String de búsqueda y Flow de resultados de la búsqueda.
  • Se actualizó el método searchRepo() con la funcionalidad que se describió antes.
  • Se quitaron queryLiveData y repoResult, puesto que su propósito se incluye en Paging 3.0 y Flow.
  • Se quitó el listScrolled(), ya que la biblioteca de Paging se encargará de esto.
  • Se quitó el companion object porque VISIBLE_THRESHOLD ya no resulta necesario.

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.

Actualicemos SearchRepositoriesActivity para que funcione con Paging 3.0. 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.

También queremos asegurarnos de que se cancele la búsqueda anterior cuando un usuario haga una nueva. Para ello, nuestro SearchRepositoriesActivity puede contener una referencia a un nuevo Job que se cancelará cada vez que se realice una búsqueda nueva.

Creemos una nueva función de búsqueda que reciba una búsqueda como un parámetro. La función deberá hacer lo siguiente:

  • Cancelar el trabajo de búsqueda anterior
  • Iniciar un trabajo nuevo en lifecycleScope
  • Llamar a viewModel.searchRepo
  • Recopilar el resultado de PagingData
  • Transferir el PagingData al ReposAdapter mediante una llamada a adapter.submitData(pagingData)
private var searchJob: Job? = null

private fun search(query: String) {
   // Make sure we cancel the previous job before creating a new one
   searchJob?.cancel()
   searchJob = lifecycleScope.launch {
       viewModel.searchRepo(query).collectLatest {
           adapter.submitData(it)
       }
   }
}

Se debe llamar a la función de búsqueda en SearchRepositoriesActivity, en el método onCreate(). En updateRepoListFromInput(), reemplaza las llamadas a viewModel y adapter por search():

private fun updateRepoListFromInput() {
    binding.searchRepo.text.trim().let {
        if (it.isNotEmpty()) {
            binding.list.scrollToPosition(0)
            search(it.toString())
        }
    }
}

Dado que queríamos asegurarnos de restablecer la posición de desplazamiento para cada nueva búsqueda, teníamos: binding.list.scrollToPosition(0). Sin embargo, en lugar de restablecer la posición en la nueva búsqueda, deberíamos restablecerla cuando el adaptador de lista se actualice con el resultado de una nueva búsqueda. Para lograr esto, podemos usar la API de 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 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.

Realicemos una recopilación de este flujo cuando inicialicemos la búsqueda, en el método initSearch y en cada nueva emisión del flujo, y desplacémonos a la posición 0.

private fun initSearch(query: String) {
    ...
    // First part of the method is unchanged

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                   // Only emit when REFRESH LoadState changes.
                   .distinctUntilChangedBy { it.refresh }
                   // Only react to cases where REFRESH completes i.e., NotLoading.
                   .filter { it.refresh is LoadState.NotLoading }
                   .collect { binding.list.scrollToPosition(0) }
        }
}

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

De momento, estamos usando un OnScrollListener adjunto a la RecyclerView a fin de determinar cuándo activar más datos. Podemos permitir que la biblioteca de Paging se encargue del desplazamiento de la lista. Quita el método setupScrollListener() y todas sus referencias.

Quitemos también el uso de repoResult. Así debería verse tu actividad:

class SearchRepositoriesActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySearchRepositoriesBinding
    private lateinit var viewModel: SearchRepositoriesViewModel
    private val adapter = ReposAdapter()

    private var searchJob: Job? = null

    private fun search(query: String) {
        // Make sure we cancel the previous job before creating a new one
        searchJob?.cancel()
        searchJob = lifecycleScope.launch {
            viewModel.searchRepo(query).collect {
                adapter.submitData(it)
            }
        }
    }

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

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

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

        initAdapter()
        val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        search(query)
        initSearch(query)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString())
    }

    private fun initAdapter() {
        binding.list.adapter = adapter
    }

    private fun initSearch(query: String) {
        binding.searchRepo.setText(query)

        binding.searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
        binding.searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                    // Only emit when REFRESH LoadState for RemoteMediator changes.
                    .distinctUntilChangedBy { it.refresh }
                    // Only react to cases where Remote REFRESH completes i.e., NotLoading.
                    .filter { it.refresh is LoadState.NotLoading }
                    .collect { binding.list.scrollToPosition(0) }
        }
    }

    private fun updateRepoListFromInput() {
        binding.searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                search(it.toString())
            }
        }
    }

    private fun showEmptyList(show: Boolean) {
        if (show) {
            binding.emptyList.visibility = View.VISIBLE
            binding.list.visibility = View.GONE
        } else {
            binding.emptyList.visibility = View.GONE
            binding.list.visibility = View.VISIBLE
        }
    }

    companion object {
        private const val LAST_SEARCH_QUERY: String = "last_search_query"
        private const val DEFAULT_QUERY = "Android"
    }
}

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.

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.0 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.0

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

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 SearchRepositoriesActivity.initAdapter() y llama a withLoadStateHeaderAndFooter() en el adaptador. Como función para reintentar, podemos llamar a adapter.retry().

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.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.

Quizá hayas notado dos problemas:

  • Durante la migración a Paging 3.0, 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 para 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.addLoadStateListener(). Esta devolución de llamada nos envía una notificación cada vez que hay un cambio en el estado de carga por medio 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.initAdapter(), llamaremos a addLoadStateListener. La lista está vacía cuando el estado refresh de CombinedLoadStates es NotLoading y adapter.itemCount == 0. Luego, llamaremos a showEmptyList, como se muestra a continuación:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(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

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.retryButton.setOnClickListener { adapter.retry() }
}

Ahora implementemos reacciones a los cambios en el estado de carga en SearchRepositoriesActivity.initAdapter. 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 SearchRepositoriesActivity.initAdapter para tener esta funcionalidad:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)

        // Only show the list if refresh succeeds.
        binding.list.isVisible = loadState.source.refresh is LoadState.NotLoading
        // Show loading spinner during initial load or refresh.
        binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading
        // Show the retry state if initial load or refresh fails.
        binding.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,
                    "\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.

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.0 permite insertar separadores en PagingData.

170f5fa2945e7d95.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>>. Haz que currentSearchResult sea del mismo tipo.

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<UiModel>>? = null

    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:

fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
    val lastResult = currentSearchResult
    if (queryString == currentQueryValue && lastResult != null) {
        return lastResult
    }
    currentQueryValue = queryString
    val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators<UiModel.RepoItem, UiModel> { 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
                    }
                }
            }
            .cachedIn(viewModelScope)
    currentSearchResult = newResult
    return newResult
}

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.

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.

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 consulta 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.

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.0 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 consulta 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.

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 un RemoteKey basado en una Repo id
  • 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
}

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.

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 prevKey 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 prevKey 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 cargar.
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.
        // 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 prevKey is null, that means we've reached
        // the end of pagination for prepend.
        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
    }
}

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): ViewModelProvider.Factory {
        return ViewModelFactory(provideGithubRepository(context))
    }
}

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

// get the view model
viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(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.

Actualiza la fuente del estado de carga

Por el momento, nuestra app carga datos de la red y los guarda en la base de datos, pero a fin de mostrar un ícono giratorio en la carga inicial de la página (en SearchRepositoriesActivity.initAdapter), la app se basa en LoadState.source. Pero ahora queremos mostrar un ícono giratorio de carga solo para las cargas de RemoteMediator. Para ello, debemos cambiar de LoadState.source a LoadState.mediator, de la siguiente manera:

private fun initAdapter() {
         ...
        adapter.addLoadStateListener { loadState ->
            // Only show the list if refresh succeeds.
            binding.list.isVisible = loadState.mediator?.refresh is LoadState.NotLoading
            // Show loading spinner during initial load or refresh.
            binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
            // Show the retry state if initial load or refresh fails.
            binding.retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error
            ... // everything else stays the same
    }

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

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.