Hilt y los ViewModels
Al terminar: Que Hilt construya los ViewModels de Marcador con @HiltViewModel y hiltViewModel(), sin fábricas a mano.
Al terminar esta lección, ningún ViewModel de Marcador buscará sus propios repositorios. PartidoViewModel y ListaViewModel los recibirán por el constructor, Hilt los construirá, y las pantallas los pedirán con hiltViewModel(). object Repositorios, el atajo provisional de Repository: una sola puerta a los datos, desaparece. La app se verá igual que antes: pulsar «Gol Rayo FC» seguirá cambiando el 3-2 del partido 1 a 4-2 en la lista.
Importa porque con las piezas recibidas de fuera, el mismo ViewModel funciona con la lista en memoria de hoy, con una base de datos mañana o con un repositorio falso en un test. Temas: @HiltViewModel y hiltViewModel() (N2). Instalas una librería más, la que une Hilt con los ViewModel de Compose, y haces un ejercicio corto con la cafetera de la lección anterior.
Quién crea un ViewModel
Hay un problema. Los ViewModel no los construyes tú: los construye Android, para que sobrevivan a girar la pantalla. viewModel() sin más solo sabe crear los que tienen constructor vacío o que reciben solo un SavedStateHandle. Un ViewModel que pide un repositorio por el constructor necesita que alguien le diga a Android cómo construirlo.
Una fábrica (factory) es ese «cómo se construye». Escribirla a mano es largo y se repite en cada ViewModel. Con Hilt no la escribes: la genera él.
Dos piezas nuevas:
| Nombre | Qué es |
|---|---|
@HiltViewModel | Sobre un ViewModel: «Hilt sabe crear este». Va con un @Inject constructor |
hiltViewModel() | La función que usa la pantalla para pedirlo, en lugar de viewModel(). Viene de la librería hilt-lifecycle-viewmodel-compose |
Además, el SavedStateHandle no necesita receta: Hilt lo entrega de serie a cada ViewModel que lo pida en el constructor.
Un ejemplo con piezas inventadas (unas recetas, no son clases de Marcador):
class RecetasRepository @Inject constructor() { // Hilt sabe construirlo: lleva @Inject constructor
fun nombres() = listOf("Tortilla", "Paella")
}
@HiltViewModel // Hilt sabe crear este ViewModel
class RecetasViewModel @Inject constructor( // y le pasa lo que hay entre paréntesis:
private val recetas: RecetasRepository, // el repositorio (Hilt lo construye solo)
private val estadoGuardado: SavedStateHandle, // el estado guardado: Hilt lo trae de serie
) : ViewModel() {
val nombres: List<String> = recetas.nombres()
}
@Composable
fun PantallaRecetas() {
val vm: RecetasViewModel = hiltViewModel() // pide el ViewModel a Hilt (antes: viewModel())
Text(vm.nombres.joinToString()) // en pantalla se lee: Tortilla, Paella
}Cuando se abre PantallaRecetas, hiltViewModel() crea un RecetasViewModel con el repositorio y el SavedStateHandle ya puestos. Si la pantalla se gira, devuelve el mismo; si se cierra, lo descarta.
Prepara el proyecto · La librería que une Hilt y Compose
hiltViewModel() no viene en hilt-android: vive en una librería de AndroidX, androidx.hilt:hilt-lifecycle-viewmodel-compose. Dos ficheros de Gradle.
Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs") y añade una línea al final de cada sección:
[versions]
# hiltViewModel() está en una librería de AndroidX con su propia versión
androidxHilt = "1.4.0"
[libraries]
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }Después, Gradle Scripts → build.gradle.kts (Module :app): una línea dentro de dependencies, debajo de las de Hilt:
dependencies {
// … las de Hilt y el resto, sin tocar
// hiltViewModel(): pide a Hilt un ViewModel desde una función @Composable
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
}Pulsa Sync Now. En los tutoriales verás también androidx.hilt:hilt-navigation-compose: es para la librería de navegación antigua, y Marcador usará la nueva, Navigation 3 (Navigation 3: la pila de pantallas). La función que importas es androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel.
Un ViewModel de Hilt, fuera de Marcador
Reto · Ejercicio: el pedido de la cafetería
En la carpeta ejercicios/cafeteria que creaste en la lección anterior, crea un fichero CafeteriaViewModel.kt (clic derecho sobre la carpeta cafeteria → New → Kotlin Class/File → File). Escribe un ViewModel que Hilt sepa crear y que reciba por el constructor la Cafetera; tendrá una propiedad pedido con lo que devuelve preparar().
Para verlo. Como Hilt no corre en @Preview, usa otra vez una línea temporal. En MainActivity.kt, dentro de setContent { MaterialTheme { … } }, como primera línea del bloque MaterialTheme, escribe Log.d("Cafeteria", hiltViewModel<CafeteriaViewModel>().pedido) (los import los propone Android Studio con Option+Enter en Mac o Alt+Enter en Windows y Linux; el de hiltViewModel es androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel). Ejecuta con Run, abre Logcat con tag:Cafeteria en el filtro y debe salir:
D Cafeteria: Café molido con molinillo eléctricoDespués quita esa línea y sus import de MainActivity.kt y deja el fichero del ejercicio.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.cafeteria
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class CafeteriaViewModel @Inject constructor(private val cafetera: Cafetera) : ViewModel() {
val pedido: String = cafetera.preparar()
}Cafetera ya sabe construirla Hilt (la lección anterior le puso @Inject constructor, y el @Binds del módulo da el Molinillo), así que el ViewModel solo tiene que pedirla. hiltViewModel() lo crea con la cafetera puesta.
Paso · Los ViewModels de Marcador
Objetivo. Hacer que PartidoViewModel y ListaViewModel reciban el repositorio por el constructor, pedirlos con hiltViewModel() y borrar object Repositorios. Al terminar, la app se comporta igual y ya no existe ningún singleton global de repositorios.
Dónde va cada trozo (obligatorio). Abre cada fichero con doble clic en el panel de proyecto (con el selector en Android, dentro de app → kotlin+java → com.sebastiannarvaez.marcador). En PartidoViewModel.kt cambian el constructor y los import; se borran las dos propiedades repositorio y registrarGol que se leían del singleton (bórralas con el comentario que tienen encima). En ListaViewModel.kt, el constructor recibe repositorio, se borra la propiedad del mismo nombre y se borra el import de Repositorios. En AppMarcador.kt y PantallaMarcador.kt, hiltViewModel() en lugar de viewModel(), con su import. En PartidosRepositoryEnMemoria.kt, se borra entero object Repositorios, con su comentario.
Intenta escribirlo tú, en este orden:
PartidoViewModel: creado por Hilt, y recibe por el constructor el estado guardado, el repositorio y el caso de usoRegistrarGol(que Hilt ya sabe construir).ListaViewModel: creado por Hilt, y recibe el repositorio por el constructor.AppMarcadory el valor por defecto dePantallaMarcador: piden elViewModelconhiltViewModel().- Fuera
object Repositorios.
Debes ver la app igual que antes: el primer partido sigue en 3-2 y, tras «Gol Rayo FC», pasa a 4-2 en la lista.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En PartidoViewModel.kt, los import (el de Directo y el de RegistrarGol ya los tienes; desaparece el de Repositorios y entran los de PartidosRepository, HiltViewModel e Inject):
import com.sebastiannarvaez.marcador.feature.marcador.domain.Directo
import com.sebastiannarvaez.marcador.feature.marcador.domain.RegistrarGol
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.InjectEl encabezado de la clase, que sustituye a la línea class PartidoViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() { y a las propiedades borradas:
// @HiltViewModel: Hilt sabe crear este ViewModel. @Inject constructor: le pasa todo lo
// que hay entre paréntesis. El SavedStateHandle también lo da Hilt, sin receta: es una
// pieza que trae de serie para cada ViewModel.
@HiltViewModel
class PartidoViewModel @Inject constructor(
private val estadoGuardado: SavedStateHandle,
private val repositorio: PartidosRepository,
private val registrarGol: RegistrarGol,
) : ViewModel() {En ListaViewModel.kt, los import (borra import com.sebastiannarvaez.marcador.feature.partidos.data.Repositorios; si dejas el viejo, la compilación falla con Unresolved reference 'Repositorios' en la línea del import, porque esa clase ya no existe) y el encabezado:
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject// @HiltViewModel + @Inject constructor: Hilt construye este ViewModel y le pasa el
// repositorio. La pantalla lo pide con hiltViewModel().
@HiltViewModel
class ListaViewModel @Inject constructor(private val repositorio: PartidosRepository) : ViewModel() {En AppMarcador.kt, el import (en lugar del de viewModel) y las dos líneas de los ViewModel:
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel val viewModel: PartidoViewModel = hiltViewModel()
val listaViewModel: ListaViewModel = hiltViewModel()En PantallaMarcador.kt, el import y el valor por defecto del parámetro viewModel:
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel viewModel: PartidoViewModel = hiltViewModel(),En PartidosRepositoryEnMemoria.kt se borra este bloque entero, al final del fichero, con su comentario: object Repositorios { val partidos: PartidosRepositoryEnMemoria = … }.
Qué hace cada pieza:
| Pieza | Qué hace |
|---|---|
@HiltViewModel + @Inject constructor | Hilt crea el ViewModel y le pasa lo que pide entre paréntesis |
hiltViewModel() | La pantalla le pide a Hilt el ViewModel (el mismo mientras la pantalla viva) |
DatosModule.partidos() | De dónde sale el repositorio que reciben los dos ViewModel |
RegistrarGol @Inject constructor | Hilt la construye y se la da a PartidoViewModel; ya no la construye él |
Esa idea, una sola copia que varias pantallas observan, tiene nombre: estado de toda la app. Más adelante lo verás con el partido en juego, y también cuándo no conviene, en Estado de toda la app: el partido en juego.
Probarlo. Ejecuta la app con Run (variante gratisDebug en View → Tool Windows → Build Variants y emulador arrancado, como en La Activity). Pulsa «Gol Rayo FC» y comprueba que el primer partido pasa de 3-2 a 4-2 en la pestaña Partidos. Para descartar cierres de la app, abre View → Tool Windows → Logcat y escribe tag:AndroidRuntime en el campo de filtro (la barra de texto de la parte superior de la ventana de Logcat): si no sale ninguna línea, no hubo excepciones.
Hay otra librería, Koin, que hace lo mismo sin generar código. La verás comparada con Hilt en Inyección en todas las capas.
Punto de control
- La app compila sin nada en rojo y se abre en el emulador.
- Tras «Gol Rayo FC», la fila del primer partido dice 4-2 en la pestaña Partidos.
- Logcat con
tag:AndroidRuntimeno muestra ninguna líneaFATAL EXCEPTION. - El ejercicio de la cafetería mostró «Café molido con molinillo eléctrico» y ya no queda en
MainActivity.ktla línea temporal. object Repositoriosya no existe: si algún fichero siguiera usándolo, Android Studio lo marcaría en rojo y la compilación fallaría.PartidoViewModelyListaViewModelllevan@HiltViewModely reciben el repositorio por el constructor; ninguno lo busca por su cuenta.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué un
ViewModelcon parámetros en el constructor necesita que alguien le diga a Android cómo crearlo?2.¿Qué hace
@HiltViewModeljunto con@Inject constructor?3.¿Qué cambia en la pantalla al pasar de
viewModel()ahiltViewModel()?4.En Marcador, ¿qué ocurre con
PartidoViewModelyListaViewModelsi se quita@Singletonde la receta del repositorio?5.¿Por qué una vista previa (
@Preview) de Marcador no llama ahiltViewModel()?