wandres.dev
INYECCIÓN DE DEPENDENCIAS · Hilt y Koin

Hilt: el grafo que verifica el compilador

Hilt lleva la resolución del grafo de dependencias al tiempo de compilación mediante generación de código, de modo que una dependencia sin proveedor deja de ser un fallo en el arranque y pasa a ser un error de compilación con nombre y ubicación. Esta lección explica qué gana y qué cuesta esa decisión, cómo se declaran los proveedores con `@Provides` y `@Binds`, qué son realmente los componentes y por qué su jerarquía es la que fija los ámbitos, y cómo `@HiltViewModel` cierra el circuito para que el contenedor MVI reciba sus colaboradores sin conocer a ninguno de ellos.

⏱ 20 min

La diferencia entre un error que ves al compilar y uno que ves al abrir la pantalla no es de comodidad: es de coste, y la distancia entre ambos crece de forma exponencial con el tamaño del equipo y con el tiempo que transcurre hasta que alguien ejecuta ese camino concreto de la aplicación. Hilt existe porque alguien tomó en serio esa aritmética y decidió pagar por adelantado: en vez de resolver el grafo cuando se pide un objeto, lo resuelve mientras se compila, generando el código de ensamblaje y comprobando que cada arista tiene destino. El precio es un procesador de anotaciones en la construcción, una sintaxis más ceremoniosa y errores de compilación que a veces cuesta leer. La contrapartida es una garantía muy fuerte y muy concreta: si el proyecto compila, no existe ninguna dependencia sin proveer, ni ninguna que viva más de lo que debe.

🎯 Al terminar esta lección sabrás
  • Explicar qué comprueba Hilt en tiempo de compilación y qué clase de fallos elimina por completo.
  • Declarar proveedores con @Provides para tipos ajenos y con @Binds para vincular interfaz e implementación.
  • Situar los componentes en su jerarquía y elegir el ámbito adecuado en función del ciclo de vida real.
  • Conectar el contenedor MVI al grafo mediante @HiltViewModel y entender qué genera esa anotación.

Qué significa verificar el grafo al compilar

Hilt no es un contenedor que busca objetos en un registro: es un generador de código. Durante la compilación recorre las anotaciones, construye el grafo dirigido de tipos y comprueba tres propiedades antes de emitir una sola clase.

🔗

Completitud

Toda dependencia solicitada tiene exactamente un proveedor alcanzable desde el componente donde se pide. Si falta, el error aparece al compilar y nombra el tipo y la cadena de peticiones que llevó hasta él.

🚫

Ausencia de ciclos

Si A necesita B y B necesita A, el grafo no es acíclico y no existe orden de construcción posible. Hilt lo detecta y lo rechaza en vez de desbordar la pila en tiempo de ejecución.

Coherencia de ámbitos

Un objeto de ámbito largo no puede depender de uno de ámbito corto, porque sobreviviría a su colaborador. Esa comprobación captura fugas de memoria que de otro modo aparecerían semanas después.

Coste cero en arranque

Como el ensamblaje ya está escrito en código generado, no hay reflexión ni resolución dinámica al abrir la aplicación. Lo que en otros enfoques ocurre al arrancar, aquí ya ocurrió en la máquina de compilación.

Merece la pena detenerse en la tercera comprobación, porque es la menos apreciada y la que más problemas evita. Una fuga de memoria por ámbito es un fallo que no se manifiesta como excepción, sino como consumo creciente y ralentización progresiva, y su diagnóstico exige herramientas y paciencia. Que un procesador de anotaciones lo convierta en un error rojo con nombre de tipo es un cambio de categoría en la dificultad del problema.

Proveer y vincular

Hay dos formas de decirle a Hilt cómo obtener un tipo, y confundirlas es el error más común de quien empieza. Se usa @Provides cuando el objeto se construye con código —típicamente porque pertenece a una librería ajena y no se pueden anotar sus constructores— y @Binds cuando lo único que hace falta es declarar que cierta implementación cumple cierta interfaz.

@Module
@InstallIn(SingletonComponent::class)
object RedModule {

    @Provides
    @Singleton
    fun proveerCliente(): OkHttpClient = OkHttpClient.Builder().build()

    @Provides
    @Singleton
    fun proveerApi(cliente: OkHttpClient): PerfilApi =
        Retrofit.Builder()
            .baseUrl("https://api.example.com")
            .client(cliente)
            .build()
            .create(PerfilApi::class.java)
}

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositorioModule {

    // Binds no construye nada: solo declara que esta clase cumple el contrato
    @Binds
    abstract fun vincularRepositorio(impl: RepositorioHttp): RepositorioPerfil
}

class RepositorioHttp @Inject constructor(
    private val api: PerfilApi,
) : RepositorioPerfil {
    override suspend fun obtener(): Perfil = api.perfil().aDominio()
}

La asimetría tiene una razón técnica que conviene entender. @Binds es abstracto y no genera cuerpo alguno: el procesador se limita a anotar en el grafo que una arista apunta a otra, con lo que el código generado es estrictamente menor y la compilación algo más rápida. @Provides sí genera una fábrica completa. La regla práctica es minimalista: usa @Binds siempre que puedas, @Provides solo cuando debas.

💡
Anota el constructor y olvídate del módulo

Si una clase es tuya y sus dependencias también están en el grafo, no necesita módulo ninguno: basta con @Inject constructor. Hilt sabrá construirla. Los módulos existen para lo que no puedes anotar —tipos de librerías, valores primitivos de configuración— y para vincular interfaces. Un proyecto sano tiene pocos módulos y muchos constructores anotados; cuando ocurre lo contrario suele ser síntoma de que se están proveyendo a mano cosas que el procesador podría deducir.

Componentes y ámbitos

Un componente es un contenedor de objetos con un ciclo de vida asociado, y la jerarquía entre componentes determina qué puede ver qué. Un componente hijo alcanza todo lo que provee su padre; el padre no ve nada del hijo. Esa asimetría es exactamente la que hace imposible que un objeto de vida larga capture uno de vida corta.

flowchart TD
S[SingletonComponent vive con la aplicacion] --> A[ActivityRetainedComponent sobrevive a la rotacion]
A --> V[ViewModelComponent muere con el ViewModel]
A --> AC[ActivityComponent muere con la Activity]
AC --> F[FragmentComponent]
AC --> VW[ViewComponent]
style S fill:#89b4fa,color:#11111b
style A fill:#a6e3a1,color:#11111b
style V fill:#f9e2af,color:#11111b

Cada componente tiene su anotación de ámbito: @Singleton para el de aplicación, @ActivityRetainedScoped para el que sobrevive a los cambios de configuración, @ViewModelScoped para el que acompaña al ViewModel y @ActivityScoped para el que muere con la pantalla. Un objeto sin anotación de ámbito no es un error: significa que se crea uno nuevo en cada petición, que suele ser lo correcto para objetos baratos y sin estado.

La decisión de ámbito no es de rendimiento sino de corrección, y se resuelve con una única pregunta: ¿este objeto debe ser el mismo para todos los que lo pidan dentro de una misma vida? Una caché en memoria compartida sí; un mapeador sin estado no. Marcar @Singleton por si acaso convierte cualquier estado accidental en estado global, y el estado global es la fuente de la clase de fallos más difícil de reproducir que existe: la que depende del orden en que el usuario visitó las pantallas.

El ViewModel dentro del grafo

El punto donde todo se junta es la anotación que conecta el contenedor MVI con el grafo sin que ninguna pantalla sepa construir nada.

@HiltViewModel
class PerfilViewModel @Inject constructor(
    private val repositorio: RepositorioPerfil,
    private val estadoGuardado: SavedStateHandle,
) : ViewModel(), ContainerHost<PerfilState, PerfilEfecto> {

    override val container = container<PerfilState, PerfilEfecto>(PerfilState())

    fun cargar() = intent {
        reduce { state.copy(cargando = true) }
        val perfil = repositorio.obtener()
        reduce { state.copy(cargando = false, perfil = perfil) }
    }
}

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { PantallaPerfil(viewModel = hiltViewModel()) }
    }
}

Lo que @HiltViewModel genera es una fábrica de ViewModel registrada en el grafo, de modo que la llamada a hiltViewModel en Compose no construye el objeto: lo pide. El detalle que suele pasar inadvertido es SavedStateHandle, que Hilt provee automáticamente dentro del ViewModelComponent sin que haya que declarar nada, y que llega con los argumentos de navegación ya dentro. Eso permite que el contenedor lea el identificador de la entidad que debe cargar sin recibirlo por un método aparte, y por tanto sin un estado intermedio inválido entre la construcción y la primera intención.

Un error de compilación es una demostración; una excepción es una anécdota

Lo que Hilt hace de verdad no es inyectar dependencias, porque eso lo hace igual de bien un constructor escrito a mano. Lo que hace es mover una propiedad del programa desde el dominio de lo observado hasta el dominio de lo demostrado. Antes, la afirmación “todas las dependencias están provistas” era una hipótesis que se sostenía sobre evidencia empírica: hemos abierto muchas pantallas y no ha fallado ninguna. Esa clase de evidencia tiene una asimetría brutal, la misma que Popper señaló para las teorías científicas: mil ejecuciones correctas no demuestran la ausencia de fallos, pero una sola excepción demuestra su presencia. Después, la misma afirmación es un teorema comprobado por el procesador sobre la totalidad del grafo, incluidas las ramas que ningún usuario ha recorrido todavía y las que solo se alcanzan en el mercado del país donde nadie del equipo ha probado nada. Ese es el eje real que separa a Hilt de cualquier alternativa en runtime, y también el que explica por qué su ceremonia parece desproporcionada en un proyecto de tres pantallas y providencial en uno de doscientas: el valor de una demostración no depende del tamaño del enunciado sino del número de casos que cubre, y ese número crece con el cuadrado de las pantallas mientras la ceremonia crece de forma lineal. Quien argumenta contra Hilt citando su verbosidad está comparando dos magnitudes que no se miden en la misma escala. Y quien lo adopta sin entender qué demuestra acaba escribiendo módulos como conjuros, sin saber por qué el rojo desaparece.

⚔️ Construye y rompe un grafo verificado
  1. Declara una interfaz de repositorio, su implementación con @Inject constructor y el módulo con @Binds que las vincula.
  2. Elimina el módulo y lee con atención el error de compilación: identifica el tipo que falta y la cadena de peticiones que lo reclama.
  3. Introduce un ciclo deliberado entre dos clases y comprueba que el error aparece al compilar y no al ejecutar.
  4. Marca con @Singleton una clase que dependa de otra @ViewModelScoped y explica por qué el compilador lo rechaza.
  5. Conecta un @HiltViewModel que lea un argumento de navegación desde SavedStateHandle y razona qué estado inválido has eliminado con ello.