Saltar al contenido
K
Jetpack y arquitecturaPaso a paso35 min de lectura · 8 min de máquina Básico

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:

NombreQué es
@HiltViewModelSobre 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):

ejemplo mínimo: un ViewModel de Hilt (con comentarios)
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.

Gradle Scripts
libs.versions.toml (Version Catalog "libs")se modifica: [versions] y [libraries]
build.gradle.kts (Module :app)se modifica: dependencies
Los dos ficheros de esta instalación, tal como los ves en la vista Android.

Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs") y añade una línea al final de cada sección:

catálogo: líneas nuevas (con comentarios)
[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:

build.gradle.kts (Module :app): línea nueva (con comentarios)
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éctrico

Después quita esa línea y sus import de MainActivity.kt y deja el fichero del ejercicio.

Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/cafeteria/CafeteriaViewModel.kt
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.

app → kotlin+java → com.sebastiannarvaez.marcador → feature
partidos
data
PartidosRepositoryEnMemoria.ktse modifica (se borra Repositorios)
ui
ListaViewModel.ktse modifica
marcador → ui
PartidoViewModel.ktse modifica
PantallaMarcador.ktse modifica
app → kotlin+java → com.sebastiannarvaez.marcador → ui
AppMarcador.ktse modifica
Cinco ficheros que se modifican, en la vista Android.

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:

  1. PartidoViewModel: creado por Hilt, y recibe por el constructor el estado guardado, el repositorio y el caso de uso RegistrarGol (que Hilt ya sabe construir).
  2. ListaViewModel: creado por Hilt, y recibe el repositorio por el constructor.
  3. AppMarcador y el valor por defecto de PantallaMarcador: piden el ViewModel con hiltViewModel().
  4. 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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
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.Inject

El encabezado de la clase, que sustituye a la línea class PartidoViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() { y a las propiedades borradas:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
// @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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/ListaViewModel.kt
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/ListaViewModel.kt
// @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:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
    val viewModel: PartidoViewModel = hiltViewModel()
    val listaViewModel: ListaViewModel = hiltViewModel()

En PantallaMarcador.kt, el import y el valor por defecto del parámetro viewModel:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    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:

PiezaQué hace
@HiltViewModel + @Inject constructorHilt 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 constructorHilt 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:AndroidRuntime no muestra ninguna línea FATAL EXCEPTION.
  • El ejercicio de la cafetería mostró «Café molido con molinillo eléctrico» y ya no queda en MainActivity.kt la línea temporal.
  • object Repositorios ya no existe: si algún fichero siguiera usándolo, Android Studio lo marcaría en rojo y la compilación fallaría.
  • PartidoViewModel y ListaViewModel llevan @HiltViewModel y 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. 1.¿Por qué un ViewModel con parámetros en el constructor necesita que alguien le diga a Android cómo crearlo?

  2. 2.¿Qué hace @HiltViewModel junto con @Inject constructor?

  3. 3.¿Qué cambia en la pantalla al pasar de viewModel() a hiltViewModel()?

  4. 4.En Marcador, ¿qué ocurre con PartidoViewModel y ListaViewModel si se quita @Singleton de la receta del repositorio?

  5. 5.¿Por qué una vista previa (@Preview) de Marcador no llama a hiltViewModel()?

Faltan 5 de 5