El id del partido: inyección asistida
Al terminar: Que un ViewModel reciba a la vez lo que Hilt sabe construir y un dato que solo se conoce al navegar, con @AssistedInject y hiltViewModel(creationCallback).
Al terminar esta lección, el partido que tocas en la lista es el que se abre: el ViewModel de cada partido recibe su número, Partido n.º 3 dice 3, y «Provocar error (partido 99)» enseña por fin el error. Abrir el partido 3 y después el 1 crea dos ViewModel, cada uno con su número.
Importa porque en casi toda app hay datos que solo se saben en el momento de crear una pantalla (qué partido, qué producto, qué usuario) y que Hilt no puede tener en ningún módulo. Para esos casos Hilt tiene la inyección asistida: el constructor se reparte entre lo que Hilt sabe dar y lo que pone quien lo crea. Temas: @AssistedInject, @Assisted, @AssistedFactory y hiltViewModel con una fábrica.
El dato que Hilt no puede saber
PartidoViewModel necesita el repositorio, el caso de uso y las preferencias, y Hilt los tiene. Pero también necesita saber qué partido abrió el usuario, y ese número no está en ningún módulo: sale de la clave de la pantalla, PartidoKey(3), y cambia cada vez que tocas una fila. Hilt no puede adivinarlo.
La solución se llama inyección asistida: el constructor se reparte entre Hilt, que pone lo que sabe, y quien crea el ViewModel, que pone el resto. Son tres anotaciones, y la tercera es la menos evidente:
@AssistedInjectva en el constructor, en lugar de@Inject: «este constructor se completa entre Hilt y quien lo pida».@Assistedva delante del parámetro que pone quien lo crea (aquí, el id del partido). Los demás los da Hilt.@AssistedFactoryva en una interfaz con una función,crear(...), con los parámetros asistidos. Tú escribes la interfaz y Hilt escribe, al compilar, la clase que la implementa: es la fábrica a la que le dices «crea uno con este id».
Por último, @HiltViewModel(assistedFactory = …) une el ViewModel con su fábrica.
Un ejemplo con datos ajenos a Marcador: un recetario donde cada receta abierta tiene su propio ViewModel.
class RecetasRepository @Inject constructor() { // una pieza que Hilt sabe construir
fun nombre(id: Int) = listOf("Tortilla", "Paella", "Ensalada").getOrElse(id - 1) { "Desconocida" }
}
@HiltViewModel(assistedFactory = RecetaViewModel.Fabrica::class) // une el ViewModel con su fábrica
class RecetaViewModel @AssistedInject constructor( // constructor mixto, no @Inject
private val recetas: RecetasRepository, // este parámetro lo da Hilt
@Assisted private val recetaId: Int, // este lo pones tú, al crear el ViewModel
) : ViewModel() {
val titulo = "Receta $recetaId: ${recetas.nombre(recetaId)}"
@AssistedFactory
interface Fabrica { // Hilt escribe la clase que la cumple
fun crear(recetaId: Int): RecetaViewModel // los parámetros @Assisted, y solo ellos
}
}
// En la entrada de la pantalla: Hilt da la fábrica y tú le das el número
entry<RecetaKey> { clave -> // clave es la RecetaKey de esta entrada
val vm = hiltViewModel<RecetaViewModel, RecetaViewModel.Fabrica> { fabrica ->
fabrica.crear(clave.recetaId) // el número sale de la clave de la pantalla
}
PantallaReceta(vm)
}Con recetaId = 2 el título es Receta 2: Paella: Hilt puso el repositorio y la entrada puso el 2. Hay cuatro detalles de Kotlin en ese código:
interface Fabricadentro de la clase. Una interfaz o una clase puede declararse dentro de otra; fuera se nombra con el punto:RecetaViewModel.Fabrica. Aquí es solo para tener la fábrica junto alViewModelal que sirve.RecetaViewModel.Fabrica::class. El::classconvierte una clase en un valor: no «un objeto de tipoFabrica», sino «la claseFabricamisma».@HiltViewModello pide porque debe saber qué fábrica usar, igual que@InstallIn(SingletonComponent::class)dice a Hilt dónde vive un módulo.hiltViewModel<RecetaViewModel, RecetaViewModel.Fabrica>. Los<…>dicen con qué tipos trabaja la función. Esta tiene dos: elViewModelque quieres y su fábrica.- La lambda final
{ fabrica -> … }. Es un bloque de código quehiltViewModelejecuta cuando hace falta crear elViewModel, y al que le entrega la fábrica; tu código la usa (fabrica.crear(…)) y devuelve elViewModel. Como cada entrada de la pila tiene su almacén (Navigation 3: la pila de pantallas), se ejecuta una vez por pantalla abierta: abrir la receta 3 y después la 1 crea dosViewModel, cada uno con su número.
Ejercicio · la fábrica que Hilt escribe, escrita a mano
Para ver qué hace Hilt por ti, escribe tú la fábrica con Kotlin puro. Es exactamente la clase que Hilt genera: guarda lo que ya sabe (el repositorio) y recibe lo que falta (el número).
Reto · Ejercicio: una fábrica de boletos
En el Kotlin Playground, fuera de tu proyecto.
Cartelera: una clase con una funciónpelicula(sala: Int)que devuelve «Metrópolis», «Casablanca» o «Vértigo» para las salas 1, 2 y 3, y «Sin película» para cualquier otra (getOrElsesobre una lista, como en el recetario).Boleto(cartelera, sala): tiene una propiedadtextoconSala 2: Casablanca, por ejemplo. Dentro de la clase, una interfazFabricaconcrear(sala: Int): Boleto.FabricaManual(cartelera): una clase que cumpleBoleto.Fabrica: guarda la cartelera y, al crear, le pasa aBoletola cartelera y la sala.- En
main: crea unaFabricaManualcon unaCartelerae imprime eltextode los boletos de las salas 2, 3 y 9.
La consola debe mostrar:
Sala 2: Casablanca
Sala 3: Vértigo
Sala 9: Sin películaPiensa: ¿cuál de las dos piezas (cartelera o sala) pondría Hilt y cuál pondrías tú al llamar a crear?
Ver solución
class Cartelera {
fun pelicula(sala: Int) = listOf("Metrópolis", "Casablanca", "Vértigo").getOrElse(sala - 1) { "Sin película" }
}
class Boleto(private val cartelera: Cartelera, private val sala: Int) {
val texto = "Sala $sala: ${cartelera.pelicula(sala)}"
interface Fabrica { // lo que tú declaras con @AssistedFactory
fun crear(sala: Int): Boleto
}
}
class FabricaManual(private val cartelera: Cartelera) : Boleto.Fabrica { // lo que Hilt escribe por ti
override fun crear(sala: Int) = Boleto(cartelera, sala)
}
fun main() {
val fabrica = FabricaManual(Cartelera())
println(fabrica.crear(2).texto) // Sala 2: Casablanca
println(fabrica.crear(3).texto) // Sala 3: Vértigo
println(fabrica.crear(9).texto) // Sala 9: Sin película
}La cartelera la pone la fábrica (en Marcador, Hilt) y la sala la pones tú al llamar a crear (en Marcador, la pantalla). En el proyecto solo declaras la interfaz Fabrica; la clase FabricaManual la escribe Hilt al compilar.
Paso · El número del partido, hasta el ViewModel
Objetivo. PartidoViewModel recibe el id del partido con inyección asistida y AppMarcador se lo da desde la clave. Tocar un partido de la lista abre ese partido.
Intenta escribirlo tú
Lo que ya tienes: PartidoViewModel se construye con @HiltViewModel y @Inject constructor, con cuatro piezas que Hilt da (estadoGuardado, repositorio, registrarGol y preferencias), y usa estadoGuardado.getStateFlow(CLAVE_PARTIDO, 1) para empezar siempre en el partido 1. Cámbialo así:
- En
PartidoViewModel: el constructor pasa a ser asistido, con un quinto parámetro, el id inicial del partido, que pone quien lo crea. Dentro de la clase, la interfazFabricacon su funcióncrear.@HiltViewModelahora nombra esa fábrica. En lugar del 1 fijo, elStateFlowdel handle empieza en el id recibido. ElSavedStateHandlesigue en el constructor: Hilt lo da solo, y convive con el parámetro asistido. - En
AppMarcador: la entrada del partido recibe la clave y pide elViewModelcon su fábrica, a la que le da el id de la clave.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// 1. En los import: borra `import javax.inject.Inject` y añade estos tres
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject// 2. La cabecera de la clase queda así (sustituye la que tenías, hasta la llave que abre el cuerpo)
@HiltViewModel(assistedFactory = PartidoViewModel.Fabrica::class) // el ViewModel y su fábrica
class PartidoViewModel @AssistedInject constructor( // constructor mixto
private val estadoGuardado: SavedStateHandle, // lo da Hilt
private val repositorio: PartidosRepository, // lo da Hilt
private val registrarGol: RegistrarGol, // lo da Hilt
private val preferencias: PreferenciasRepository, // lo da Hilt
@Assisted partidoInicial: Int, // lo pone la pantalla, desde la PartidoKey
) : ViewModel() {
@AssistedFactory
interface Fabrica {
fun crear(partidoInicial: Int): PartidoViewModel
}// 3. La línea de partidoId: el 1 fijo pasa a ser el id recibido
val partidoId: StateFlow<Int> = estadoGuardado.getStateFlow(CLAVE_PARTIDO, partidoInicial)// 4. La entrada del partido, dentro de entryProvider. Sustituye la que tenías.
entry<PartidoKey> { clave ->
val vm = hiltViewModel<PartidoViewModel, PartidoViewModel.Fabrica> { fabrica ->
fabrica.crear(clave.partidoId)
}
PantallaMarcador(
onVerDemos = onVerDemos,
onVerGoleadores = { pila.add(GoleadoresKey) },
onVolver = { pila.removeAt(pila.lastIndex) },
onProvocarError = { pila.add(PartidoKey(99)) },
viewModel = vm,
)
}Compila con ./gradlew :app:assembleGratisDebug en la Terminal; debe terminar en BUILD SUCCESSFUL.
Qué hace cada cambio
- El id tiene un solo origen. El id del partido viaja en la
PartidoKey, y de ahí pasa alViewModelcomopartidoInicial: la entrada se lo da a la fábrica confabrica.crear(clave.partidoId). ElViewModelsigue guardándolo en elSavedStateHandle(así lo escribiste en SavedStateHandle) y el resto de su código lo lee de ahí, pero ya no hace falta para sobrevivir aam kill: eso lo hace la clave, que es serializable. Como nadie cambia el id desde dentro, la clave y elSavedStateHandleno pueden contradecirse. - No hay ninguna fábrica escrita a mano. La interfaz
Fabricaes lo único que declaras, y Hilt escribe su implementación al compilar. - Solo el partido es asistido.
ListaViewModelyGoleadoresViewModelno reciben ningún dato de la pantalla, así que siguen con@InjectyhiltViewModel()a secas. - «Provocar error» ya funciona. Apila
PartidoKey(99), que no existe, y esa entrada pide el partido 99, que no se encuentra: la pantalla enseña elError.
Punto de control
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFUL.- Toca un partido de la lista (el 3, por ejemplo): la pantalla dice
Partido n.º 3. Vuelve y toca otro: dice su número. Antes siempre decía 1. - «Provocar error (partido 99)» abre una entrada con el mensaje de error y un botón «Volver» que regresa al partido anterior.
- «Ver goleadores» y «atrás» siguen funcionando como en la lección anterior.
- Muerte del proceso: entra en el partido 3, pulsa Home y ejecuta en la Terminal
adb shell am kill com.sebastiannarvaez.marcador.debug. Reábrela desde recientes: sigue en el partido 3, porque la clave se guarda con la pila.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué
PartidoViewModelnecesita inyección asistida yGoleadoresViewModelno?2.¿Qué hace
fabrica.crear(clave.partidoId)dentro de la entrada del partido?3.En
@AssistedFactory interface Fabrica { fun crear(partidoInicial: Int): PartidoViewModel }, ¿quién escribe la clase que cumple esa interfaz?4.En el constructor de
PartidoViewModel, ¿qué parámetros llevan@Assisted?5.En
hiltViewModel<PartidoViewModel, PartidoViewModel.Fabrica> { fabrica -> … }, ¿qué dicen los dos tipos entre<>?