Inyección en todas las capas
Al terminar: Ordenar los módulos de Hilt por capas (red, datos, repositorios), entender los alcances y ver el mismo grafo con Koin.
Al terminar esta lección tendrás en la cabeza el mapa completo de cómo Hilt monta Marcador: tres módulos, uno por capa (la red, los datos locales y los repositorios), y la regla de cuánto vive cada pieza. En el proyecto solo ordenas los comentarios de esos módulos, y la app se ve y se comporta igual. Lo que ganas es saber qué módulo tocar cuando llegue una pieza nueva, qué alcance darle y leer el error que Hilt escribe cuando el grafo (el dibujo de qué pieza necesita a cuál) está mal montado.
Importa porque «¿cómo organizas la inyección de dependencias en una app de verdad?» es pregunta de entrevista: la respuesta buena habla de capas, de alcances y de que la librería comprueba el grafo al compilar, no solo de «poner un constructor». Temas: las capas y sus módulos; alcances y componentes; @Binds frente a @Provides; internal; los errores de grafo; y, en dos recuadros, el Worker y Koin.
Las capas y su módulo
Una app con red y base de datos tiene piezas de cinco alturas. Cada una solo usa a las de debajo, y en Marcador cada capa tiene su sitio en Hilt:
| Capa | Piezas en Marcador | Dónde la declaras |
|---|---|---|
| Red | OkHttpClient, LigaApi | RedModule (@Provides) |
| Datos locales | La base de Room, sus DAO, DataStore, el alcance de la app y el repositorio de partidos | DatosModule (@Provides) |
| Repositorios | Qué implementación recibe quien pide ArbitrosRepository o CronicasRepository | RepositoriosModule (@Binds) |
| Casos de uso | RegistrarGol, PublicarCronica | Sin módulo: llevan @Inject constructor |
| ViewModel | Uno por pantalla | Sin módulo: llevan @HiltViewModel |
El repositorio de partidos está en DatosModule y no en RepositoriosModule por una razón práctica: necesita una receta (@Provides) porque tiene un parámetro con valor por defecto, y así cada módulo usa una sola forma. (Más adelante, en La plantilla, guardada en Room, ese parámetro desaparece; la receta se queda, porque sigue necesitando el alcance etiquetado.) RedModule y DatosModule son un object con recetas; RepositoriosModule es una abstract class con @Binds.
@Binds frente a @Provides
@Provides | @Binds | |
|---|---|---|
| Para qué | Fabricar una pieza que Hilt no sabe construir solo | Decir «para esta interfaz, usa esta clase» |
| Cuándo | La clase es de una librería (OkHttpClient), necesita pasos para crearse (la base de Room) o tiene un constructor que Hilt no entiende | La clase es tuya y ya tiene @Inject constructor |
| Forma del módulo | object, con una función con cuerpo | abstract class, con una función sin cuerpo |
| Ejemplo en Marcador | RedModule.okHttp() | RepositoriosModule.arbitros() |
La regla práctica: si puedes ponerle @Inject constructor a la clase, hazlo y, si hay una interfaz de por medio, usa @Binds. Si no puedes, escribe la receta con @Provides.
Alcance: cuánto vive cada pieza
El alcance (en inglés, scope) de una pieza dice cuántas copias hay y cuánto duran. (No lo confundas con el CoroutineScope de las corrutinas, el ámbito donde viven las tareas de fondo: @AlcanceDeApp etiqueta un CoroutineScope.)
Para entenderlo hace falta el componente: el contenedor que Hilt genera al compilar y que guarda las piezas. No hay uno solo, sino un árbol de ellos, cada uno con la vida de una parte de Android:
SingletonComponent vive lo que el proceso de la app
└─ ActivityRetainedComponent vive lo que una pantalla, aunque gire el móvil
├─ ViewModelComponent vive lo que un ViewModel
└─ ActivityComponent vive lo que una ActivityUn módulo se instala en un componente con @InstallIn. Las piezas de un componente pueden pedir las de ese componente y las de los de arriba, nunca las de los de abajo: un ViewModel puede pedir el cliente HTTP (está arriba), pero el cliente HTTP no puede pedir un ViewModel.
Eso da tres casos, y los tres están en Marcador:
| Alcance | Anotación | Copias | Ejemplos en Marcador |
|---|---|---|---|
| Proceso | @Singleton (en SingletonComponent) | Una en toda la app | Cliente HTTP, LigaApi, la base, los repositorios, las preferencias |
| Sin alcance | Ninguna | Una nueva cada vez que alguien la pide | Los DAO y los casos de uso (RegistrarGol, PublicarCronica) |
| Pantalla | @HiltViewModel en el ViewModel | Uno por entrada de la pila de navegación | Cada ViewModel |
Los DAO no llevan @Singleton porque los guarda la propia base: pedirlos dos veces es barato. Los casos de uso, porque no guardan nada entre llamadas. Para una pieza que se comparta solo entre las piezas de un mismo ViewModel existe @ViewModelScoped; Marcador no lo necesita, y por eso solo lo nombramos.
Un solo cliente HTTP
El cliente HTTP es la pieza de tu app que hace de verdad las peticiones a internet (en Marcador, un OkHttpClient; Retrofit solo le da una interfaz cómoda por encima). Cada OkHttpClient guarda su propio conjunto de conexiones abiertas y de hilos. Crear uno por petición las desperdicia. Por eso RedModule lo marca @Singleton: uno para toda la app, que comparten LigaApi y las interfaces de Retrofit que lleguen. La misma idea vale para la base de datos: abrir dos conexiones que escriben en el mismo fichero a la vez da problemas.
internal y private en un módulo
Un módulo es una clase de tu app como cualquier otra, y puedes limitar quién la ve: internal object RedModule compila y funciona igual, y queda visible solo dentro del módulo de Gradle :app. Lo que no tiene sentido es poner private a una receta: Hilt tiene que poder llamarla. Y que nadie use el cliente HTTP donde no debe no depende de esconderlo: depende de en qué constructores escribes su tipo. Si solo los repositorios lo piden, solo ellos lo reciben.
Dos recuadros
Reto · Ejercicio: la tienda por capas, con Hilt
En la carpeta ejercicios de tu proyecto, crea la subcarpeta tienda_por_capas (clic derecho sobre ejercicios → New → Package → añade .tienda_por_capas al final del nombre que aparece ya escrito) y dentro un fichero Tienda.kt. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.
Monta una tienda con dos clases que avisan al crearse con Log.d("Tienda", …): un cliente HTTP (escribe «Creando cliente HTTP») y una base de datos (escribe «Abriendo base de datos»). Ninguna de las dos lleva @Inject: son «de una librería».
- Dos repositorios, construidos por Hilt: el de productos, que recibe el cliente y la base, y el de pedidos, que recibe solo el cliente. Los dos exponen lo que reciben como propiedad pública.
- Un módulo de red con la receta del cliente y otro de datos con la receta de la base. Los dos viven en el componente de la aplicación y los dos objetos deben ser únicos en toda la app.
Hilt no corre en una @Preview, así que para verlo pon dos líneas temporales en MainActivity.kt, como hiciste en Hilt: instalarlo y el primer módulo: dos campos @Inject lateinit var (uno por repositorio) dentro de la clase y, justo debajo de super.onCreate(savedInstanceState), un Log.d("Tienda", …) que escriba si los dos repositorios usan el mismo cliente (compáralos con ===, que pregunta si son el mismo objeto). Ejecuta la app y en Logcat filtra por tag:Tienda. Debe verse:
D Tienda: Creando cliente HTTP
D Tienda: Abriendo base de datos
D Tienda: mismo cliente: trueCuando lo veas, quita las líneas temporales de MainActivity.kt. Piensa: ¿qué saldría si quitaras @Singleton de la receta del cliente? Pruébalo antes de quitar las líneas.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas
import android.util.Log
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
import javax.inject.Singleton
class ClienteHttp {
init { Log.d("Tienda", "Creando cliente HTTP") }
}
class BaseDeDatos {
init { Log.d("Tienda", "Abriendo base de datos") }
}
// Mias: con @Inject constructor, Hilt las construye y les pasa lo que piden
class ProductosRepository @Inject constructor(val cliente: ClienteHttp, val base: BaseDeDatos)
class PedidosRepository @Inject constructor(val cliente: ClienteHttp)
// Capa de red: el cliente no es mio, asi que va con receta. @Singleton: uno solo.
@Module
@InstallIn(SingletonComponent::class)
object RedTiendaModule {
@Provides
@Singleton
fun cliente(): ClienteHttp = ClienteHttp()
}
// Capa de datos: lo mismo con la base.
@Module
@InstallIn(SingletonComponent::class)
object DatosTiendaModule {
@Provides
@Singleton
fun base(): BaseDeDatos = BaseDeDatos()
}Las dos líneas temporales de MainActivity.kt (se quitan al terminar):
// dentro de la clase MainActivity:
@Inject lateinit var productos: ProductosRepository
@Inject lateinit var pedidos: PedidosRepository
// justo debajo de super.onCreate(savedInstanceState):
Log.d("Tienda", "mismo cliente: ${productos.cliente === pedidos.cliente}")(Necesitan sus import: javax.inject.Inject y las dos clases del ejercicio.) «Creando cliente HTTP» sale una sola vez porque la receta es @Singleton: los dos repositorios reciben la misma copia. Si quitas @Singleton, el cliente se vuelve «sin alcance» y cada repositorio recibe uno nuevo: el logcat muestra «Creando cliente HTTP» dos veces y mismo cliente: false.
El grafo se comprueba al compilar
Con la tienda que acabas de escribir puedes ver los dos errores más típicos de Hilt. Hilt lee todas las anotaciones y dibuja el grafo antes de que la app exista. Si falta una pieza o hay un ciclo, la compilación falla y dice dónde. Es la gran diferencia con una librería que resuelve al ejecutar: aquí no llega a instalarse una app con el grafo roto.
Son los mensajes que salen con la tienda (con las rutas largas recortadas). El primero ocurre si quitas la receta del cliente en RedTiendaModule (o, en una clase tuya, el @Inject constructor); para el segundo, ponle a ClienteHttp un @Inject constructor(val pedidos: PedidosRepository) (y quita su receta). Prueba cada uno y deshaz el cambio antes de seguir:
error: [Dagger/MissingBinding] com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.ClienteHttp cannot be provided without an @Inject constructor or an @Provides-annotated method.
com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.ClienteHttp is injected at
[...ActivityC] com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.PedidosRepository(cliente)
com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.PedidosRepository is injected at
[...ActivityC] com.sebastiannarvaez.marcador.MainActivity.pedidos
…([...ActivityC] es el recorte de ActivityComponent, el contenedor de la Activity; no hace falta que lo entiendas, solo que sepas que la pieza se pide desde la MainActivity.) La primera línea dice qué falta (ClienteHttp); las de debajo, quién lo pide (el parámetro cliente de PedidosRepository) y quién pidió a ese (la MainActivity). La solución es una de las dos que dice el mensaje: @Inject constructor en la clase o una receta en un módulo.
El segundo, un ciclo: A necesita a B y B necesita a A, así que ninguna se puede construir primero:
error: [Dagger/DependencyCycle] Found a dependency cycle:
com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.PedidosRepository is injected at
[...ActivityC] com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.ClienteHttp(pedidos)
com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.ClienteHttp is injected at
[...ActivityC] com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.PedidosRepository(cliente)
com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.PedidosRepository is injected at
[...ActivityC] com.sebastiannarvaez.marcador.ejercicios.tienda_por_capas.ClienteHttp(pedidos)El mensaje enseña el círculo entero. Un ciclo casi siempre significa que dos clases están mal repartidas: una capa baja (el cliente) no debe conocer a una alta (el repositorio). Se arregla sacando de una de las dos lo que la une.
Paso · Ordena los módulos por capas
Objetivo. Dejar escrito en los tres módulos el mapa de capas y de alcances que acabas de ver. No cambia ni una línea de código que se ejecute: solo comentarios. La app, los tests y los módulos funcionan igual.
Los tres módulos están en di, una sola carpeta para toda la app aunque las clases que construyen vivan en features distintas: Hilt monta un único grafo y los módulos son su plano.
Intenta escribirlo tú. Añade un comentario de resumen encima del módulo de datos (qué hace cada módulo y qué significa «sin alcance»), una referencia a él en los comentarios de RedModule y RepositoriosModule, y una nota en PublicarCronica que explique por qué no lleva alcance. Lo que debes ver: la app compila igual, Partidos, Árbitros y Crónica funcionan como antes y los tests siguen en verde.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En DatosModule.kt, encima del comentario que ya tiene la clase (el resto del fichero no cambia):
// LOS MODULOS, POR CAPAS (los tres viven en SingletonComponent, el contenedor de la app):
// RedModule -> la red: OkHttpClient y LigaApi;
// DatosModule -> lo que se guarda en el movil: base, DAO, DataStore, el alcance
// de la app y el repositorio de partidos, que se construye con todo eso;
// RepositoriosModule -> que implementacion recibe quien pide cada interfaz (@Binds).
// Un modulo, una forma: `object` con recetas (@Provides) o `abstract class` con @Binds.
//
// ALCANCES: @Singleton = una sola instancia mientras viva el proceso. Sin anotacion = una
// nueva cada vez que alguien la pide (los DAO y los casos de uso, que no guardan estado).
//En RedModule.kt, la primera línea del comentario de la clase pasa a decir:
// CAPA DE RED (ver el resumen de capas en DatosModule). OkHttpClient y Retrofit no son clases nuestras (no podemos ponerlesEn RepositoriosModule.kt, una línea nueva encima del comentario de la clase:
// CAPA DE REPOSITORIOS (ver el resumen de capas en DatosModule).En PublicarCronica.kt, dos líneas al final del comentario de la clase, justo encima de class PublicarCronica:
// @Inject constructor y SIN alcance: no guarda estado, asi que Hilt crea uno nuevo para
// cada ViewModel que lo pide; no cuesta nada.Qué pasa cuando una pantalla pide algo:
| Paso | Quién | Qué hace |
|---|---|---|
| 1 | hiltViewModel() | Pide el ViewModel de la crónica y le da el id del partido |
| 2 | Hilt | Construye PublicarCronica (sin alcance: una nueva) con los dos repositorios |
| 3 | RepositoriosModule y DatosModule | Entregan el repositorio de crónicas y el de partidos, que son @Singleton |
| 4 | RedModule y DatosModule | Entregan el cliente y la base, que son únicos en toda la app |
Probarlo. Ejecuta la app (Run, variante gratisDebug) y recorre las pantallas: Partidos, «Gol Rayo FC», «Árbitros» y «Crónica» (debe decir «Crónica publicada con el n.º 101») funcionan igual que antes. En la Terminal, ./gradlew :app:testGratisDebugUnitTest. Para comprobar que los tres módulos están donde dice la tabla, abre en la vista Android la carpeta di: deben verse DatosModule.kt, RedModule.kt y RepositoriosModule.kt.
Punto de control
- La app compila y se ejecuta; Partidos, Árbitros y Crónica funcionan igual que antes.
./gradlew :app:testGratisDebugUnitTesttermina enBUILD SUCCESSFULy cuenta 19 tests con tus ejercicios y 16 sin ellos, y 0 fallos (esta lección no añade tests; el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine suma 3; el de la tienda, ninguno).- El ejercicio de la tienda mostró en Logcat «Creando cliente HTTP» una sola vez y
mismo cliente: true, y sus líneas temporales ya no están enMainActivity.kt. - Hay tres módulos en
di(RedModule,DatosModuleyRepositoriosModule).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué la receta del
OkHttpClientenRedModulelleva@Singleton?2.¿Cuándo usas
@Bindsen lugar de@Provides?3.
PublicarCronicano guarda nada entre llamadas. ¿Qué alcance tiene en Marcador y por qué?4.Una receta (
@Provides) deDatosModulepide una pieza que nadie ha declarado. ¿Cuándo te enteras?5.En el ejercicio de la tienda, ¿qué ves en Logcat si quitas
@Singletonde la receta del cliente?