Una aplicación en Android real, generalmente almacena información del usuario y muestra información al usuario, la información puede ser mucha como colecciones de imágenes de datos, Android puede mostrar esta información en forma de un grid (como un collage) o como una lista de elementos. Para llevar a cabo está tarea hay un elemento llamado RecyclerView, con el cuál podemos crear este tipo de elementos para nuestra aplicación.
El resultado será algo como la animación de la izquierda y en general la aplicación hará esto:
- Consumirá un servicio REST que nos mostrará una lista de películas, para hacer esto te recomiendo ver el tuto para crear y exponer el mock server usando json-server y ngrok.
- Crearemos una architectura MVVM usando Jetpack y coroutines.
- Usaremos Retrofit para el consumo del servicio REST
- Implementaremos el onClick a cada elemento del recyclerView, usando Navigation.
Así que mejor comencemos ya!
Lo primero que hay que hacer es crear nuestro proyecto en Android Studio como se muestra a continuación.
Previamente configuramos nuestro proyecto como un proyecto sin actividad, así que para agregarla lo haremos así:
Lo siguiente que hay que hacer es crear el modelo de datos donde vamos a almacenar las películas en una lista, esto lo haremos con el json:
{ | |
"poster_path": "https://img2.rtve.es/v/3682901?w=1600&preview=1470297144384.jpg", | |
"overview": "From DC Comics comes the Suicide Squad, an antihero team of incarcerated supervillains who act as deniable assets for the United States government, undertaking high-risk black ops missions in exchange for commuted prison sentences.", | |
"release_date": "2016-08-03", | |
"id": 297761, | |
"original_title": "Suicide Squad", | |
"original_language": "en", | |
"title": "Suicide Squad", | |
"backdrop_path": "/ndlQ2Cuc3cjTL7lTynw6I4boP4S.jpg", | |
"popularity": 48.261451, | |
"vote_count": 1466, | |
"vote_average": 5.91 | |
} |
Este json es parte de la respuesta del servicio web, en realidad vamos a recibir una lista de estos datos por eso debemos mapear al menos uno para crear el modelo.
Para crearlo yo uso el plugin JsonToKotlinClass que puedes instalar en Android Studio: Preferencias -> Plugins -> MarketPlace: JsonToKotlinClass
De igual forma puedes crearlo de manera manual, solo que hay veces que los modelos pueden llevar muchísimos datos y realmente te ahorra tiempo el usarlo.
data class Movie( | |
val backdrop_path: String, | |
val id: Int, | |
val original_language: String, | |
val original_title: String, | |
val overview: String, | |
val popularity: Double, | |
val poster_path: String, | |
val release_date: String, | |
val title: String, | |
val vote_average: Double, | |
val vote_count: Int | |
) |
Este modelo lo vamos a modificar después, por buena práctica.
Una vez que tenemos esto vamos a configurar Retrofit, Coroutines y Jetpack, más todo lo que necesitemos por parte de la interfaz de usuario como RecyclerView, CardView, etc
Nuestro build.gradle (:App) file debe de quedar así:
dependencies { | |
implementation fileTree(dir: "libs", include: ["*.jar"]) | |
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" | |
implementation 'androidx.core:core-ktx:1.3.1' | |
implementation 'androidx.appcompat:appcompat:1.1.0' | |
implementation 'androidx.constraintlayout:constraintlayout:1.1.3' | |
testImplementation 'junit:junit:4.12' | |
androidTestImplementation 'androidx.test.ext:junit:1.1.1' | |
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' | |
//Libraries | |
//Recycler | |
implementation 'androidx.cardview:cardview:1.0.0' | |
implementation "androidx.recyclerview:recyclerview:1.1.0" | |
//Coroutines | |
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8' | |
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.8' | |
//Network | |
implementation 'com.squareup.retrofit2:retrofit:2.9.0' | |
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' | |
implementation 'com.squareup.okhttp3:okhttp:4.8.0' | |
implementation 'com.squareup.okhttp3:logging-interceptor:4.8.0' | |
//Images | |
implementation 'com.github.bumptech.glide:glide:4.11.0' | |
kapt 'com.github.bumptech.glide:compiler:4.11.0' | |
//MVVM Jetpack | |
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" | |
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" | |
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0" | |
implementation "androidx.activity:activity-ktx:1.1.0" | |
implementation "androidx.fragment:fragment-ktx:1.2.5" | |
} |
Una vez que configuramos Retrofit y gson converter vamos de regreso a la clase Movie.kt para hacer las siguientes modificaciones:
data class Movie( | |
@SerializedName("backdrop_path") | |
val backdropPath: String, | |
val id: Int, | |
@SerializedName("original_language") | |
val originalLanguage: String, | |
@SerializedName("original_title") | |
val originalTitle: String, | |
val overview: String, | |
val popularity: Double, | |
@SerializedName("poster_path") | |
val posterPath: String, | |
@SerializedName("release_date") | |
val releaseDate: String, | |
val title: String, | |
@SerializedName("vote_average") | |
val voteAverage: Double, | |
@SerializedName("vote_count") | |
val voteCount: Int | |
) |
Esto es únicamente con el fin de seguir las buenas prácticas al nombrar los atributos de una clase
Levantar el server usando json-server y ngrok
El servidor es prácticamente el siguiente archivo: Descargar aquí
usando el siguiente comando:
json-server –watch movieServer.json
Para exponer nuestro server usaremos ngrok y el siguiente comando:
./ngrok http 3000
puedes ver el tutorial completo de como levantar el server aquí: Crea tu propio mock server con json-server y ngrok.
Permisos de la aplicación
El único permiso que vamos a usar es un permiso de nivel normal, puesto que únicamente haremos peticiones a internet, en el manifiesto hay que declarar este permiso:
<uses-permission android:name=”android.permission.INTERNET”/>
<?xml version="1.0" encoding="utf-8"?> | |
<manifest xmlns:android="http://schemas.android.com/apk/res/android" | |
package="com.codetecuhtli.recyclertuto"> | |
<uses-permission android:name="android.permission.INTERNET"/> | |
<application | |
android:allowBackup="true" | |
android:icon="@mipmap/ic_launcher" | |
android:label="@string/app_name" | |
android:roundIcon="@mipmap/ic_launcher_round" | |
android:supportsRtl="true" | |
android:theme="@style/AppTheme"> | |
<activity android:name=".core.MainActivity"> | |
<intent-filter> | |
<action android:name="android.intent.action.MAIN" /> | |
<category android:name="android.intent.category.LAUNCHER" /> | |
</intent-filter> | |
</activity> | |
</application> | |
</manifest> |
y como vamos a usar el patrón arquitectura MVVM con jetpack necesitamos crear estos componentes
Creando el cliente del servicio web (Retrofit)
El cliente tendrá las siguientes características:
- Singleton
- Usará Retrofit
Para ello construiremos está interfaz
interface MovieApi { | |
@GET("movies") | |
suspend fun getMovies(): List<Movie> | |
@GET("movies/{id}") | |
suspend fun getMovie(@Path("id") id: Int): Movie | |
companion object { | |
private var backedApi: MovieApi? = null | |
val instance: MovieApi by lazy { | |
if (backedApi == null){ | |
backedApi = createApi() | |
backedApi!! | |
}else { | |
backedApi!! | |
} | |
} | |
private fun createApi(): MovieApi { | |
//Interceptor | |
val interceptor = HttpLoggingInterceptor().apply { | |
level = HttpLoggingInterceptor.Level.BODY | |
} | |
//Client | |
val client = OkHttpClient.Builder() | |
.addInterceptor(interceptor) | |
.build() | |
//Retrofit | |
val retrofit = Retrofit.Builder() | |
.addConverterFactory(GsonConverterFactory.create()) | |
.client(client) | |
.baseUrl("https://0e6e640e1d5a.ngrok.io/") //No es la mejor forma de hacerlo | |
.build() | |
return retrofit.create(MovieApi::class.java) | |
} | |
} | |
} |
Con la siguiente parte del código creamos los 2 métodos que nos van a regresar datos el primero es equivalente a consumir:
https://<url>/movies
y el segundo a consumir
https://<url>/movies/<id>
Para poder usar coroutines no olvidemos colocar la palabra suspend para crear una función suspendida.
En el siguiente método: createApi se configura Retrofit, el cliente http, el interceptor y el convertidor con gson, además de la url a la que vamos a hacer las peticiones.
Mi forma para hacerlo singleton es la siguiente:
- Todo va en un companion object
- No queremos crear más de una vez el cliente
- Usamos una variable extra donde podemos saber sí ya se creo o no, este variable es backedApi
- La única que exponemos es instance, que es una variable de lectura que usa el delegado lazy es decir, se va a crear cuando se use y va a revisar que si ya se ha creado la instancia no se vaya a crear de nuevo.
Repositorio
El repositorio básicamente va a obtener los datos y los va a proporcionar los datos para que puedan ser procesados, por ejemplo si el servicio falla, aquí se va a procesar, si todo sale bien se dará una respuesta al ViewModel.
La manera en que se deben de regresar los datos es en forma de estados:
- Cargando
- Éxito
- Error
El servicio se está ejecutando –> Cargando
El servicio retornó una respuesta correcta -> Éxito con los datos esperados.
El servicio falló -> Error con el mensaje de error
Para eso usamos sealed class para tener un tipo de enum más dinámico.
sealed class MovieResult | |
object Loading: MovieResult() | |
class MoviesSuccess(val movies: List<Movie>): MovieResult() | |
class MoviesError(val error: Exception): MovieResult() | |
class MovieSuccess(val movie: Movie): MovieResult() | |
class MovieError(val error: Exception): MovieResult() |
El repositorio va a consumir lo que venga de Retrofit y lo va a transformar en alguna de estás respuestas.
class MovieRepository(private val movieApi: MovieApi) { | |
fun getMovies(): LiveData<MovieResult> = liveData(Dispatchers.IO) { | |
emit(Loading) | |
try { | |
emit(MoviesSuccess(movieApi.getMovies())) | |
}catch (e: Exception){ | |
emit(MoviesError(e)) | |
} | |
} | |
fun getMovie(id: Int): LiveData<MovieResult> = liveData(Dispatchers.IO){ | |
emit(Loading) | |
try { | |
emit(MovieSuccess(movieApi.getMovie(id))) | |
}catch (e: Exception){ | |
emit(MovieError(e)) | |
} | |
} | |
} |
Dado que marcamos las funciones en la interfaz de Retrofit como suspend, ya no se pueden ejecutar en cualquier parte debe de ser en un Scope que yo lo llamaría ámbito en este caso el scope es el LiveData es decir mientras el live data este vigente la coroutina se podrá ejecutar y para esto sirve
Para crear un scope de un liveData es como se muestra en la figura se define el contexto que en este caso hay vario contextos disponibles en nuestro caso usamos IO porque será una operación fuera del hilo principal que no es tan exhaustiva, y al iniciar emitiremos el estado Loading, cuando tengamos la respuesta del server emitiremos el estado MoviesSuccess, con la lista de la películas y si algo falló en el catch emitimos MoviesError estos serán emitidos al ViewModel que será observado desde la app para que sean procesados por la interfaz de usuario.
ViewModel
El ViewModel debe de recibir en su constructor el repositorio, simplemente para desacoplar lo más que podamos y poder hacer las pruebas mejor en un futuro.
Prácticamente el se ve a encargar de procesar la respuesta del repositorio para que la interfaz de usuario reciba la respuesta esperada y reaccione a ella.
Aunque tal vez en este ejemplo no sea muy obvio, el ViewModel no le interesa de donde saca la información el Repository, el ViewModel solo recibe los datos y los puede procesar un poco más a fin de que la interfaz de usuario (UI) reciba lo que le sea más fácil de procesar.
Dentro del ViewModel es buena práctica todo manejarlo por LiveData, como por ejemplo el id de la película se recibe en un método que agrega el valor al liveData, lo que desencadena la ejecución del servicio que el UI observará.
class MovieViewModel(private val repository: MovieRepository): ViewModel() { | |
private val movieIdInput = MutableLiveData<Int>() | |
val movie: LiveData<MovieResult> by lazy { | |
Transformations.switchMap(movieIdInput) { id -> | |
repository.getMovie(id) | |
} | |
} | |
val movies: LiveData<MovieResult> by lazy { | |
repository.getMovies() | |
} | |
fun getMovie(id: Int) { | |
movieIdInput.value = id | |
} | |
} |
Hasta este punto ya tenemos los datos necesarios, ahora ya solo los vamos a observar en nuestra UI que para este ejemplo es una actividad, pero puede ser un fragmento también.
Instanciar MovieViewModel con argumentos
Para instanciar un ViewModel como esté con argumentos hay que crear un factory que implementa la interfaz ViewModelProvider.Factory, recibir los mismo argumentos en el constructor que nuestro ViewModel y pasarlos en el método create que debemos de sobreescribir.
class MovieViewModelFactory(private val repository: MovieRepository): ViewModelProvider.Factory { | |
override fun <T : ViewModel?> create(modelClass: Class<T>): T = MovieViewModel(repository) as T | |
} |
y en nuestra UI se inicializa así:
class MainActivity : AppCompatActivity() { | |
private val movieViewModel: MovieViewModel by viewModels { | |
MovieViewModelFactory(MovieRepository(MovieApi.instance)) | |
} | |
} |
Ahora ya todo está listo para poder observar los cambios cuando tengamos la lista de películas. Pero lo haremos más adelante.
Construyendo el CardView
El cardView es un elemento que define la vista de cada elemento en nuestro RecyclerView
Para este caso vamos a usar el siguiente xml para nuestra vista por ítem.
<?xml version="1.0" encoding="utf-8"?> | |
<androidx.cardview.widget.CardView | |
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"> | |
<androidx.constraintlayout.widget.ConstraintLayout | |
android:layout_width="match_parent" | |
android:layout_height="match_parent"> | |
<ImageView | |
android:id="@+id/movieImage" | |
android:layout_width="0dp" | |
android:layout_height="@dimen/image_height" | |
android:layout_marginStart="8dp" | |
android:layout_marginTop="8dp" | |
android:layout_marginEnd="8dp" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent" /> | |
<TextView | |
android:id="@+id/movieTitle" | |
android:layout_width="0dp" | |
android:layout_height="wrap_content" | |
android:layout_marginStart="8dp" | |
android:layout_marginTop="8dp" | |
android:layout_marginEnd="8dp" | |
android:gravity="start" | |
android:textSize="@dimen/movie_title_text" | |
android:textStyle="bold" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@+id/movieImage" /> | |
<TextView | |
android:id="@+id/movieDate" | |
android:layout_width="0dp" | |
android:layout_height="wrap_content" | |
android:layout_marginStart="4dp" | |
android:layout_marginTop="8dp" | |
android:layout_marginEnd="8dp" | |
android:gravity="center" | |
android:textSize="@dimen/movie_secondary_text" | |
android:textStyle="italic" | |
app:layout_constraintBottom_toTopOf="@+id/movieOverview" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toEndOf="@+id/movieAverage" | |
app:layout_constraintTop_toBottomOf="@+id/movieTitle" /> | |
<TextView | |
android:id="@+id/movieAverage" | |
android:layout_width="0dp" | |
android:layout_height="wrap_content" | |
android:layout_marginStart="8dp" | |
android:layout_marginTop="8dp" | |
android:layout_marginEnd="4dp" | |
android:gravity="center" | |
android:textSize="@dimen/movie_secondary_text" | |
android:textStyle="italic" | |
app:layout_constraintBottom_toTopOf="@+id/movieOverview" | |
app:layout_constraintEnd_toStartOf="@+id/movieDate" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@+id/movieTitle" /> | |
<TextView | |
android:id="@+id/movieOverview" | |
android:layout_width="0dp" | |
android:layout_height="wrap_content" | |
android:layout_marginStart="8dp" | |
android:layout_marginTop="8dp" | |
android:layout_marginEnd="8dp" | |
android:gravity="start" | |
android:textSize="@dimen/movie_normal_text" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@+id/movieDate" /> | |
</androidx.constraintlayout.widget.ConstraintLayout> | |
</androidx.cardview.widget.CardView> |
En nuestro Android Studio debería de verse algo así, no se verá nada porque no hay contenido.
Sigue el Adapter
Hemos construido el cardView y ahora sigue el adapter para esto se utiliza un patrón de diseño llamado ViewHolder el cuál es utilizado para delegar la construcción de la vista para cada item.
class MovieAdapter(private val context: Context, | |
private val onClick: (movie: Movie) -> Unit): RecyclerView.Adapter<MovieAdapter.MovieHolder>() { | |
var movies: MutableList<Movie> = mutableListOf() | |
set(value) { | |
field.let { | |
it.clear() | |
it.addAll(value) | |
} | |
notifyDataSetChanged() | |
} | |
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieHolder { | |
return MovieHolder(LayoutInflater | |
.from(context) | |
.inflate(R.layout.card_movie, parent, false), onClick) | |
} | |
override fun getItemCount(): Int = movies.size | |
override fun onBindViewHolder(holder: MovieHolder, position: Int) { | |
holder.bind(movies[position]) | |
} | |
inner class MovieHolder(private val view: View, | |
private val onClick: (movie: Movie) -> Unit): RecyclerView.ViewHolder(view){ | |
fun bind(movie: Movie){ | |
view.setOnClickListener { onClick(movie) } | |
view.movieTitle.text = movie.title | |
view.movieDate.text = movie.releaseDate | |
view.movieAverage.text = "${movie.voteAverage}" | |
view.movieOverview.text = movie.overview | |
view.movieImage.loadImage(movie.posterPath) | |
} | |
} | |
} |
Hay varias cosas que quiero hacer notar.
- Recibimos una función llamada onClick que se le va asignar a cada vista en su creación, para ejecutar el onClick del item.
- La inner class MovieHolder Implementa este patrón de diseño, yo agregué el método bind para que ahí se concentre la lógica para asignarle al cardview los valores de la vista
class MovieAdapter(private val context: Context, | |
private val onClick: (movie: Movie) -> Unit): RecyclerView.Adapter<MovieAdapter.MovieHolder>() { | |
var movies: MutableList<Movie> = mutableListOf() | |
set(value) { | |
field.let { | |
it.clear() | |
it.addAll(value) | |
} | |
notifyDataSetChanged() | |
} | |
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieHolder { | |
return MovieHolder(LayoutInflater | |
.from(context) | |
.inflate(R.layout.card_movie, parent, false), onClick) | |
} | |
override fun getItemCount(): Int = movies.size | |
override fun onBindViewHolder(holder: MovieHolder, position: Int) { | |
holder.bind(movies[position]) | |
} | |
inner class MovieHolder(private val view: View, | |
private val onClick: (movie: Movie) -> Unit): RecyclerView.ViewHolder(view){ | |
fun bind(movie: Movie){ | |
view.setOnClickListener { onClick(movie) } | |
view.movieTitle.text = movie.title | |
view.movieDate.text = movie.releaseDate | |
view.movieAverage.text = "${movie.voteAverage}" | |
view.movieOverview.text = movie.overview | |
view.movieImage.loadImage(movie.posterPath) | |
} | |
} | |
} |
- Las películas no se van a recibir inmediatamente, porque debemos de esperar que el servicio haya respondido de manera correcta, es por eso que movies no está en el constructor, y esa parte tiene lógica para actualizar la vista del recycler.
class MovieAdapter(private val context: Context, | |
private val onClick: (movie: Movie) -> Unit): RecyclerView.Adapter<MovieAdapter.MovieHolder>() { | |
var movies: MutableList<Movie> = mutableListOf() | |
set(value) { | |
field.let { | |
it.clear() | |
it.addAll(value) | |
} | |
notifyDataSetChanged() | |
} | |
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieHolder { | |
return MovieHolder(LayoutInflater | |
.from(context) | |
.inflate(R.layout.card_movie, parent, false), onClick) | |
} | |
override fun getItemCount(): Int = movies.size | |
override fun onBindViewHolder(holder: MovieHolder, position: Int) { | |
holder.bind(movies[position]) | |
} | |
inner class MovieHolder(private val view: View, | |
private val onClick: (movie: Movie) -> Unit): RecyclerView.ViewHolder(view){ | |
fun bind(movie: Movie){ | |
view.setOnClickListener { onClick(movie) } | |
view.movieTitle.text = movie.title | |
view.movieDate.text = movie.releaseDate | |
view.movieAverage.text = "${movie.voteAverage}" | |
view.movieOverview.text = movie.overview | |
view.movieImage.loadImage(movie.posterPath) | |
} | |
} | |
} |
Ahora si, nuestra actividad
Lo único que hay que hacer es inicializar el Recycler con los valores por default y observar cuando recibimos un dato para agregar esos valores al atributo movies del adapter.
Si ves que en el constructor hay un {} es porque ese será el onclick a cada item, esa función se va a ejecutar cuando reciba el item que se dio click.
class MainActivity : AppCompatActivity() { | |
private val movieViewModel: MovieViewModel by viewModels { | |
MovieViewModelFactory(MovieRepository(MovieApi.instance)) | |
} | |
private lateinit var movieAdapter: MovieAdapter | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_main) | |
init() | |
} | |
private fun init(){ | |
initInstances() | |
initView() | |
initObservables() | |
} | |
private fun initView(){ | |
moviesRecycler.apply { | |
setHasFixedSize(true) | |
adapter = movieAdapter | |
} | |
} | |
private fun initInstances(){ | |
movieAdapter = MovieAdapter(applicationContext) { | |
Log.i(MainActivity::class.java.simpleName, "$it") | |
} | |
} | |
private fun initObservables(){ | |
movieViewModel.movies.observe(this) { | |
when(it){ | |
is Loading -> { | |
} | |
is MoviesSuccess -> { | |
movieAdapter.movies = it.movies.toMutableList() | |
} | |
is MoviesError -> { | |
} | |
} | |
} | |
} | |
} |
Muchas gracias!