Room con Flow, y un ejemplo de paginación
Esto avanza, y si en el artículo anterior veíamos cómo usar Flow en un proyecto Android, aquí vamos a ir más allá e integrarlo con Flow.
En realidad la integración es extremadamente sencilla, pero vamos a construir un ejemplo en el que tenga un sentido real, como es el de la paginación.
Integración de Flow con Room
Desde las versiones más recientes de Room, podemos hacer que las peticiones devuelvan un Flow, de forma equivalente a como lo hacían con su homónimo LiveData.
La ventaja de usar Flow en vez de LiveData es que no acoplamos nuestras capas de lógica de negocio al framework de Android, lo que es una gran noticia.
El único cambio que necesitas es pasar de esto:
@Query("SELECT * FROM Movie")
suspend fun getAll(): List<Movie>
A esto:
@Query("SELECT * FROM Movie")
fun getAll(): Flow<List<Movie>>
Ya está. A partir de este momento, cada vez que se actualice la tabla involucrada en esta petición, se nos devolverán los nuevos valores.
¿Y esto es todo? Pues la verdad es que sí, pero vamos a hacer un ejemplo más complejo para probarlo
Vamos a hacer un sistema de paginación muy sencillo. Quizá en otro artículo pruebe la librería de Paging 3, que ahora también viene con soporte para Flow, pero de momento vamos a hacerlo a mano para entenderlo todo.
Adaptando el repository
Vas a tener que cambiar algunas cosas, porque ahora el LocalDataSource devuelve un Flow en vez de un listado:
interface LocalDataSource { ... fun getMovies(): Flow<List<Movie>>
}
El cambio en RoomDataSource es un poco enrevesado, porque tenemos que hacer la conversión de las películas al vuelo. Pero esto nos ayuda a ver que podemos transformar Flows en otros de forma sencilla:
override fun getMovies(): Flow<List<Movie>> = movieDao.getAll().map { movies -> movies.map { it.toDomainMovie() }
}
Mapeamos el objeto a uno nuevo que va a ser el mapping de cada item al tipo del dominio.
A nivel de repositorio, es simplemente devolver el Flow que recuperamos desde base de datos:
fun getMovies(): Flow<List<Movie>> = localDataSource.getMovies()
Creando una paginación propia con Flow
Bien, ya tenemos el camino “de vuelta” desde la fuente de datos local (la base de datos), ¿pero cómo informamos al repositorio de que hay que cargar datos?
De arriba a abajo vamos viendo. Primero sería detectar que realmente necesitamos nuevos datos. Pero eso no se lo vamos a hacer decidir a la interfaz, sino que ésta solo va a informa al ViewModel del último elemento visible:
val layoutManager = recycler.layoutManager as GridLayoutManager recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { viewModel.notifyLastVisible(layoutManager.findLastVisibleItemPosition()) }
})
Y después, en el ViewModel, informamos al repository. Como la función es suspend, necesitamos llamarlo desde una corrutina:
fun notifyLastVisible(lastVisible: Int) { viewModelScope.launch { repository.checkRequireNewPage(lastVisible) _spinner.value = false }
}
Vamos a necesitar que la primera vez, cuando no hay datos, se notifique que estamos en la posición 0. Esto se hace en el init del ViewModel:
init { _spinner.value = true notifyLastVisible(0)
}
El repositorio va a comprobar cuántos elementos hay hasta ahora, y si necesita más, llamará a la fuente de datos remota. Esto se podría optimizar más cacheando algunos valores, pero vamos a mantenerlo simple:
suspend fun checkRequireNewPage(lastVisible: Int) { val size = localDataSource.size() if (lastVisible >= size - PAGE_THRESHOLD) { val page = size / PAGE_SIZE + 1 val newMovies = remoteDataSource.getMovies(page) localDataSource.saveMovies(newMovies) }
}
Si el último elemento visible es mayor que el total menos un cierto umbral, cargamos nuevos items. Es mejor no esperar a que el valor sea el último, primero para hacer la experiencia más fluida, y segundo porque podemos perder valores por el camino y que no nos devuelva exactamente el último.
Al guardarse los nuevos valores en la base de datos, el Flow automáticamente notificará de los cambios a la UI, así que no tenemos que hacer nada.
Sí que necesitarás algunos cambios extra en la fuente remota, que te dejo aquí a modo de ejercicio:
interface RemoteDataSource { suspend fun getMovies(page: Int): List<Movie>
}
-
class TheMovieDbDataSource(private val apiKey: String) : RemoteDataSource { override suspend fun getMovies(page: Int): List<Movie> = TheMovieDb.service .listPopularMoviesAsync(apiKey, page) .results .map { it.toDomainMovie() }
}
-
interface TheMovieDbService { @GET("discover/movie?sort_by=popularity.desc") suspend fun listPopularMoviesAsync( @Query("api_key") apiKey: String, @Query("page") page: Int ): MovieDbResult
}
Houston, tenemos un problema
Si ahora ejecutas este código rápidamente, te darás cuenta de que hay páginas que se cargan 2 veces.
Esto es porque las notificaciones al repositorio van más rápidas que las peticiones al servidor, y por tanto, la corrutina que tenemos en el ViewModel se crea muchas veces, y todas ellas lanzan una nueva petición.
¿Qué podemos hacer para solucionar esto? Para ello, necesitamos aprender un nuevo concepto, que te voy a dejar para un siguiente capítulo.
Nos vamos a cargar un componente de los Architecture Components al que probablemente tenías mucho cariño.
¡Nos leemos en el siguiente artículo!