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:
Requisitos
- Android Studio Arctic Fox
- Conocer los siguientes componentes de la arquitectura: LiveData, ViewModel, Vinculación de vista y la arquitectura sugerida en la "Guía de arquitectura de apps"
- Conocer las corrutinas y el flujo de Kotlin
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:
- Descomprime el código y, luego, abre el proyecto en Android Studio.
- Ejecuta la configuración de ejecución
app
en un dispositivo o emulador.
La app se ejecutará y mostrará una lista de repositorios de GitHub similares a este:
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, yRepoSearchResult
, 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 unaRecyclerView
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:
- Un
LiveData<UiState>
- 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
aLiveData
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 conLiveData
. - 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
ofilter
en la lista que se mostrará, independientemente de si usasFlow
,LiveData
o RxJavaFlowable
oObservable
. - 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á unPagingData
diferente.PagingSource
: UnaPagingSource
es la clase básica para cargar instantáneas de datos en un flujo dePagingData
.Pager.flow
: Compila unFlow<PagingData>
a partir de un objetoPagingConfig
y una función que define cómo construir laPagingSource
implementada.PagingDataAdapter
: Es unRecyclerView.Adapter
que presentaPagingData
en unaRecyclerView
. Se puede conectarPagingDataAdapter
a unFlow
de Kotlin, unLiveData
, un RxJavaFlowable
o a un RxJavaObservable
. El elementoPagingDataAdapter
escucha eventos de cargaPagingData
internos mientras las páginas se cargan y usaDiffUtil
en un subproceso en segundo plano a fin de procesar actualizaciones detalladas a medida que se recibe contenido actualizado en forma de objetosPagingData
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 aGithubService
.
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 constanteGITHUB_STARTING_PAGE_INDEX
deGithubRepository
a tu implementación dePagingSource
, 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 exitosoLoadResult.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: usaPager.flow
.LiveData
: usaPager.liveData
.- RxJava
Flowable
: usaPager.flowable
. - RxJava
Observable
: usaPager.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 unaPagingSource
, 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ámetromaxSize
enPagingConfig
. 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ónenablePlaceholders
es verdadera. De esta manera, podrás mostrar una vista del marcador de posición en tu adaptador. Simplifiquemos el trabajo de este codelab: pasaenablePlaceholders = false
a fin de inhabilitar los marcadores de posición.- Una función que define cómo crear la
PagingSource
. En nuestro caso, crearemos unaGithubPagingSource
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:
UiAction.Search
por cada vez que el usuario ingresa una búsqueda en particularUiAction.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:
shareIn
: Es necesario porque, cuando finalmente se consume esteFlow
, se consume con un operadorflatmapLatest
. Cada vez que se emita el flujo ascendente,flatmapLatest
cancelará el últimoFlow
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 operadorFlow
con un valorreplay
de 1 para almacenar en caché el último valor de modo que no se pierda cuando entre una nueva búsqueda.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
implementaListAdapter
. En su lugar, haz que implementePagingDataAdapter
. 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 cargaPagingData
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.
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 delLoadState
de Paging - El archivo del adaptador, que define cómo crear y vincular el
ViewHolder
. En lugar de extender un objetoRecyclerView.Adapter
, extenderemosLoadStateAdapter
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.
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
.
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
deRepo
aUiModel
. - Implementa un comparador
UiModel
que reemplace elREPO_COMPARATOR
. - Crea el
SeparatorViewHolder
y vincúlalo con la descripción deUiModel.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:
- Crea una base de datos de Room, una tabla para guardar los objetos
Repo
y un DAO que usaremos para trabajar con los objetosRepo
. - 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
. - Compila un
Pager
basado en la tabla de repositorios como fuente de datos yRemoteMediator
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 objetosRepo
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>
, muestraPagingSource<Int, Repo>
. De esa manera, la tablarepos
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 extiendaRoomDatabase
. - Anota la clase con
@Database
, define la lista de entidades que contendrán la claseRepo
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 uncompanion object
que compile el objetoRepoDatabase
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 redSuccess
, 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 laPagingConfig
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 obtengamosRepos
de la red, generaremos las claves remotas para ellos - Obtener una **
RemoteKey
** basada en unid
deRepo
- 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()
:
- Averigua qué página debemos cargar desde la red en función del
LoadType
. - Activar la solicitud de red
- Una vez completada la solicitud de red, si la lista recibida de repositorios no está vacía, hagamos lo siguiente:
- Calculemos las
RemoteKeys
para cadaRepo
. - Si esta es una búsqueda nueva (
loadType = REFRESH
), borremos la base de datos. - Guardemos las
RemoteKeys
y losRepos
en la base de datos. - Muestra
MediatorResult.Success(endOfPaginationReached = false)
. - Si la lista de repositorios está vacía, mostraremos
MediatorResult.Success(endOfPaginationReached = true)
. Si se produce un error cuando solicitamos datos, mostraremosMediatorResult.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.
- 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)
}
}
- 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 cuandoendOfPaginationReached = false
, porque Paging volverá a llamar a este método si RemoteKeys deja de ser un valor nulo. Cuando remoteKeys no esnull
, pero sunextKey
sí esnull
, 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.
- 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)
}
}
- 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 cuandoendOfPaginationReached = false
, porque Paging volverá a llamar a este método si RemoteKeys deja de ser un valor nulo. Cuando remoteKeys no esnull
, pero suprevKey
sí esnull
, 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.
- Según la
anchorPosition
delstate
, podemos obtener el elementoRepo
más cercano a esa posición llamando astate.closestItemToPosition()
. - Según el elemento
Repo
, podemos obtener lasRemoteKeys
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)
}
}
}
- Si
remoteKey
no es nula, entonces podemos obtener lanextKey
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 deremoteKey.nextKey
. - Si
RemoteKey
esnull
(porque laanchorPosition
eranull
), 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 constructorGithubRepository
, invocar aRepoDatabase.getInstance
. - El método
provideViewModelFactory
debe obtener un contexto como parámetro y pasarlo aprovideGithubRepository
.
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 unFlow<PagingData>
basado en una configuración y una función que definen la forma de crear una instancia de laPagingSource
. - El
Flow
emite unPagingData
nuevo cada vez que laPagingSource
carga datos nuevos. - La IU observa el
PagingData
modificado y usa unPagingDataAdapter
a fin de actualizar laRecyclerView
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étodoPagingSource.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 unLoadStateAdapter
. Si quieres ejecutar otras acciones en función del estado de carga, usa la devolución de llamadaPagingDataAdapter.addLoadStateListener()
. - Para trabajar con la red y la base de datos, implementa un
RemoteMediator
. - Cuando se agrega un
RemoteMediator
, actualiza el campomediator
enLoadStatesFlow
.