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

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:

  • @AssistedInject va en el constructor, en lugar de @Inject: «este constructor se completa entre Hilt y quien lo pida».
  • @Assisted va delante del parámetro que pone quien lo crea (aquí, el id del partido). Los demás los da Hilt.
  • @AssistedFactory va 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.

ejemplo mínimo: un ViewModel con inyección asistida (con comentarios)
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 Fabrica dentro 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 al ViewModel al que sirve.
  • RecetaViewModel.Fabrica::class. El ::class convierte una clase en un valor: no «un objeto de tipo Fabrica», sino «la clase Fabrica misma». @HiltViewModel lo 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: el ViewModel que quieres y su fábrica.
  • La lambda final { fabrica -> … }. Es un bloque de código que hiltViewModel ejecuta cuando hace falta crear el ViewModel, y al que le entrega la fábrica; tu código la usa (fabrica.crear(…)) y devuelve el ViewModel. 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 dos ViewModel, 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ón pelicula(sala: Int) que devuelve «Metrópolis», «Casablanca» o «Vértigo» para las salas 1, 2 y 3, y «Sin película» para cualquier otra (getOrElse sobre una lista, como en el recetario).
  • Boleto(cartelera, sala): tiene una propiedad texto con Sala 2: Casablanca, por ejemplo. Dentro de la clase, una interfaz Fabrica con crear(sala: Int): Boleto.
  • FabricaManual(cartelera): una clase que cumple Boleto.Fabrica: guarda la cartelera y, al crear, le pasa a Boleto la cartelera y la sala.
  • En main: crea una FabricaManual con una Cartelera e imprime el texto de los boletos de las salas 2, 3 y 9.

La consola debe mostrar:

Sala 2: Casablanca
Sala 3: Vértigo
Sala 9: Sin película

Piensa: ¿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
solución: la fábrica a mano
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.

app → kotlin+java → com.sebastiannarvaez.marcador
ui
AppMarcador.ktse modifica (la entrada del partido)
feature
marcador
ui
PartidoViewModel.ktse modifica (imports y cabecera)
Dos ficheros se modifican, en app → kotlin+java → com.sebastiannarvaez.marcador.

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í:

  1. 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 interfaz Fabrica con su función crear. @HiltViewModel ahora nombra esa fábrica. En lugar del 1 fijo, el StateFlow del handle empieza en el id recibido. El SavedStateHandle sigue en el constructor: Hilt lo da solo, y convive con el parámetro asistido.
  2. En AppMarcador: la entrada del partido recibe la clave y pide el ViewModel con 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
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
// 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
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
// 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
    }
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
// 3. La línea de partidoId: el 1 fijo pasa a ser el id recibido
    val partidoId: StateFlow<Int> = estadoGuardado.getStateFlow(CLAVE_PARTIDO, partidoInicial)
app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
// 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 al ViewModel como partidoInicial: la entrada se lo da a la fábrica con fabrica.crear(clave.partidoId). El ViewModel sigue guardándolo en el SavedStateHandle (así lo escribiste en SavedStateHandle) y el resto de su código lo lee de ahí, pero ya no hace falta para sobrevivir a am kill: eso lo hace la clave, que es serializable. Como nadie cambia el id desde dentro, la clave y el SavedStateHandle no pueden contradecirse.
  • No hay ninguna fábrica escrita a mano. La interfaz Fabrica es lo único que declaras, y Hilt escribe su implementación al compilar.
  • Solo el partido es asistido. ListaViewModel y GoleadoresViewModel no reciben ningún dato de la pantalla, así que siguen con @Inject y hiltViewModel() 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 el Error.

Punto de control

  • ./gradlew :app:assembleGratisDebug termina en BUILD 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. 1.¿Por qué PartidoViewModel necesita inyección asistida y GoleadoresViewModel no?

  2. 2.¿Qué hace fabrica.crear(clave.partidoId) dentro de la entrada del partido?

  3. 3.En @AssistedFactory interface Fabrica { fun crear(partidoInicial: Int): PartidoViewModel }, ¿quién escribe la clase que cumple esa interfaz?

  4. 4.En el constructor de PartidoViewModel, ¿qué parámetros llevan @Assisted?

  5. 5.En hiltViewModel<PartidoViewModel, PartidoViewModel.Fabrica> { fabrica -> … }, ¿qué dicen los dos tipos entre <>?

Faltan 5 de 5