Cómo usar Hilt en tu app para Android

En este codelab, aprenderás sobre la importancia de la inserción de dependencias (DI) para crear una aplicación sólida y extensible que se ajuste a proyectos grandes. Usaremos Hilt como la herramienta de DI para administrar dependencias.

La inserción de dependencias (DI) es una técnica muy utilizada en programación y adecuada para el desarrollo de Android. Si sigues los principios de la DI, sentarás las bases para una buena arquitectura de apps.

Implementar la inserción de dependencias te proporciona las siguientes ventajas:

  • Reutilización de código
  • Facilidad de refactorización
  • Facilidad de prueba

Hilt es una biblioteca de inserción de dependencias estable para Android que te permite reducir el código estándar que usas en la DI manual en tu proyecto. Para realizar una inserción manual de dependencias, es necesario construir cada clase y sus dependencias manualmente, y usar contenedores para reutilizar y administrar las dependencias.

Hilt ofrece una manera estándar de inserción de dependencias en tu aplicación proporcionando contenedores a cada componente de Android de tu proyecto y administrando el ciclo de vida de los contenedores automáticamente. Para ello, se aprovecha la popular biblioteca de DI: Dagger.

Si a medida que avanzas con este codelab encuentras algún problema (errores de código, errores gramaticales, texto poco claro, etc.), infórmalo mediante el vínculo Informa un error que se encuentra en la esquina inferior izquierda del codelab.

Requisitos previos

  • Debes tener conocimientos sobre la sintaxis de Kotlin.
  • Debes comprender por qué la inserción de dependencias es importante en tu aplicación.

Qué aprenderás

  • Cómo usar Hilt en tu app para Android
  • Conceptos relevantes sobre Hilt para crear una app sustentable
  • Cómo agregar varias vinculaciones al mismo tipo con calificadores
  • Cómo usar @EntryPoint para acceder a contenedores desde clases que Hilt no admite
  • Cómo usar pruebas de instrumentación y unidades para probar una aplicación que usa Hilt

Requisitos

  • Android Studio 4.0 o una versión posterior

Obtén el código

Obtén el código del codelab de GitHub:

$ git clone https://github.com/googlecodelabs/android-hilt

También tienes la opción de descargar el repositorio como archivo ZIP:

Descargar ZIP

Abre Android Studio

Este codelab requiere Android Studio 4.0 o una versión posterior. Si necesitas descargar Android Studio, puedes hacerlo aquí.

Cómo ejecutar la app de muestra

En este codelab, agregarás Hilt a una aplicación que registre las interacciones del usuario y use Room para almacenar datos en una base de datos local.

Sigue estas instrucciones para abrir la app de muestra en Android Studio:

  • Si descargaste el archivo ZIP, descomprímelo en una ubicación local.
  • Abre el proyecto en Android Studio.
  • Haz clic en el botón execute.png Ejecutar y elige un emulador o conecta tu dispositivo Android.

Como puedes ver, se crea y almacena un registro cada vez que interactúas con uno de los botones numerados. En la pantalla See All Logs, verás una lista de todas las interacciones anteriores. Para quitar los registros, presiona el botón Delete Logs.

Configuración del proyecto

El proyecto se compila en varias ramas de GitHub:

  • master es la rama que revisaste o descargaste. Este es el punto de partida del codelab.
  • solution contiene la solución para este codelab.

Te recomendamos que comiences con el código de la rama master y sigas el codelab paso a paso a tu propio ritmo.

Durante el codelab, recibirás fragmentos de código que deberás agregar al proyecto. En algunos lugares, también deberás quitar el código que se mencionará explícitamente en los comentarios de los fragmentos de código.

Para obtener la rama solution con Git, usa el siguiente comando:

$ git clone -b solution https://github.com/googlecodelabs/android-hilt

También puedes descargar el código de la solución aquí:

Descargar el código final

Preguntas frecuentes

¿Por qué elegir Hilt?

Si observas el código de inicio, puedes ver una instancia de la clase ServiceLocator almacenada en la clase LogApplication. La clase ServiceLocator crea y almacena dependencias que las clases que las necesitan obtienen a pedido. Se puede pensar como un contenedor de dependencias que se conecta con el ciclo de vida de la app, ya que se destruirá cuando esta se destruya.

Como se explica en la Orientación de DI para Android, los localizadores de servicios comienzan con poco código estándar en términos relativos, pero también escalan mal. Si deseas desarrollar una app para Android a escala, debes usar Hilt.

Hilt quita el código estándar innecesario que necesitas para usar la DI manualmente o un patrón del localizador de servicios en una aplicación para Android. Para ello, genera el código que crearías manualmente (p. ej., el código de la clase ServiceLocator).

En los siguientes pasos, usarás Hilt para reemplazar la clase ServiceLocator. Luego, agregaremos nuevas características al proyecto para explorar más funciones de Hilt.

Hilt en tu proyecto

Hilt ya está configurado en la ramamaster (código que descargaste). No es necesario que incluyas el siguiente código en el proyecto, ya que Hilt lo hizo por ti. No obstante, veamos qué se necesita para usar Hilt en una app para Android.

Además de las dependencias de la biblioteca, Hilt usa un complemento de Gradle que se configura en el proyecto. Abre el archivo raíz build.gradle y consulta la siguiente dependencia de Hilt en la ruta de clase:

buildscript {
    ...
    ext.hilt_version = '2.28-alpha'
    dependencies {
        ...
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
    }
}

Luego, para usar el complemento de Gradle en el módulo app, lo especificamos en el archivo app/build.gradle agregando el complemento en la parte superior del archivo, debajo del complemento de kotlin-kapt:

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

Por último, las dependencias de Hilt se incluyen en nuestro proyecto en el mismo archivo app/build.gradle:

...
dependencies {
    ...
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}

Todas las bibliotecas, incluso Hilt, se descargan cuando compilas y sincronizas el proyecto. ¡Comienza a usar Hilt!

De la misma manera en que se usa e inicializa la instancia de ServiceLocator en la clase LogApplication, para agregar un contenedor que esté vinculado con el ciclo de vida de la app, es necesario anotar la clase Application con @HiltAndroidApp. Abre LogApplication.kt y agrega la anotación a la clase:

@HiltAndroidApp
class LogApplication : Application() {
    ...
}

@HiltAndroidApp activa la generación de código de Hilt, incluida una clase base para tu aplicación que puede usar la inserción de dependencias. El contenedor de la aplicación es el contenedor superior de la app, lo que significa que otros contenedores pueden acceder a las dependencias que proporciona.

¡Nuestra app ya está lista para usar Hilt!

En lugar de tomar dependencias a pedido de ServiceLocator en nuestras clases, usaremos Hilt para proporcionar esas dependencias. Para comenzar, reemplacemos las llamadas a ServiceLocator desde nuestras clases.

Abre el archivo ui/LogsFragment.kt. LogsFragment propaga sus campos en onAttach. En lugar de propagar instancias de LoggerLocalDataSource y DateFormatter de forma manual con ServiceLocator, podemos usar Hilt para crear y administrar instancias de esos tipos.

Para que la clase LogsFragment use Hilt, debemos anotarla con @AndroidEntryPoint:

@AndroidEntryPoint
class LogsFragment : Fragment() {
    ...
}

La anotación de clases de Android con @AndroidEntryPoint crea un contenedor de dependencias que sigue el ciclo de vida de la clase de Android.

Con @AndroidEntryPoint, Hilt creará un contenedor de dependencias que esté vinculado con el ciclo de vida de la clase LogsFragment y podrá insertar instancias en LogsFragment. ¿Cómo podemos obtener campos insertados por Hilt?

Podemos hacer que Hilt inserte instancias de diferentes tipos con la anotación@Inject en los campos que queremos insertar (p. ej.,logger y dateFormatter):

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var dateFormatter: DateFormatter

    ...
}

Esto se denomina inserción de campos.

Dado que Hilt se encargará de propagar esos campos por nosotros, ya no necesitamos el método populateFields. Quitemos el método de la clase:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    // Remove following code from LogsFragment

    override fun onAttach(context: Context) {
        super.onAttach(context)

        populateFields(context)
    }

    private fun populateFields(context: Context) {
        logger = (context.applicationContext as LogApplication).serviceLocator.loggerLocalDataSource
        dateFormatter =
            (context.applicationContext as LogApplication).serviceLocator.provideDateFormatter()
    }

    ...
}

En un nivel profundo, Hilt propagará esos campos en el método del ciclo de vida de la clase onAttach() con instancias compiladas en el contenedor de dependencias de LogsFragment, que se generó automáticamente.

Para realizar la inserción de campos, Hilt necesita saber cómo proporcionar instancias de esas dependencias. En este caso, Hilt necesita saber cómo proporcionar instancias de LoggerLocalDataSource y DateFormatter. Sin embargo, Hilt aún no sabe cómo proporcionar esas instancias.

Indica a Hilt cómo proporcionar dependencias con @Inject

Abre el archivo ServiceLocator.kt para ver cómo se implementa la clase ServiceLocator. Puedes ver cómo al llamar a provideDateFormatter() siempre se muestra una instancia diferente del objeto DateFormatter.

Este es exactamente el comportamiento que queremos lograr con Hilt. Afortunadamente, DateFormatter no depende de otras clases, por lo que, por ahora, no tenemos que preocuparnos por las dependencias transitivas.

Para indicarle a Hilt cómo proporcionar instancias de un tipo, agrega la anotación @Inject al constructor de la clase que desees insertar.

Abre el archivo util/DateFormatter.kt y anota el constructor de la clase DateFormatter con @Inject. Recuerda que para anotar un constructor en Kotlin, también necesitas la palabra clave constructor:

class DateFormatter @Inject constructor() { ... }

De esta manera, Hilt sabe cómo proporcionar instancias de DateFormatter. Lo mismo debe hacerse con LoggerLocalDataSource. Abre el archivo data/LoggerLocalDataSource.kt y anota su constructor con @Inject:

class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

Si volvemos a abrir la clase ServiceLocator, verás que tenemos un campo LoggerLocalDataSource público. Esto significa que la clase ServiceLocator mostrará la misma instancia de LoggerLocalDataSource cada vez que se la llame. Esto es lo que se denomina "determinar el alcance de una instancia en relación con un contenedor". ¿Cómo podemos hacerlo en Hilt?

Podemos usar las anotaciones para determinar el alcance de las instancias en relación con los contenedores. Como Hilt puede producir diferentes contenedores que tienen distintos ciclos de vida, existen diferentes anotaciones con diversos alcances en relación con esos contenedores.

La anotación que determina el alcance de una instancia en relación con el contenedor de la aplicación es @Singleton. Esta anotación hará que el contenedor de la aplicación siempre proporcione la misma instancia, sin importar si el tipo se usa como una dependencia de otro tipo o si es necesario insertar campos.

La misma lógica se puede aplicar a todos los contenedores vinculados con clases de Android. Puedes encontrar la lista de todas las anotaciones de alcance en la documentación. Por ejemplo, si deseas que un contenedor de actividades siempre proporcione la misma instancia de un tipo, puedes anotar ese tipo con @ActivityScoped.

Como se mencionó anteriormente, dado que queremos que el contenedor de la aplicación siempre proporcione la misma instancia de LoggerLocalDataSource, anotamos su clase con @Singleton:

@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

Ahora, Hilt sabe cómo proporcionar instancias de la clase LoggerLocalDataSource. Sin embargo, esta vez, el tipo tiene dependencias transitivas. Para proporcionar una instancia de LoggerLocalDataSource, Hilt también necesita saber cómo proporcionar una instancia de la clase LogDao.

No obstante, dado que LogDao es una interfaz, no podemos anotar su constructor con @Inject, ya que las interfaces no tienen constructor ¿De qué manera podemos indicarle a Hilt cómo proporcionar instancias de este tipo?

Los módulos se utilizan para agregar vinculaciones a Hilt o, en otras palabras, para indicar a Hilt cómo proporcionar instancias de diferentes tipos. En los módulos de Hilt, se incluyen vinculaciones para tipos que no pueden ser constructores insertados, como interfaces o clases que no se encuentran en tu proyecto. Un ejemplo de esto es OkHttpClient: debes usar su compilador para crear una instancia.

Un módulo de Hilt es una clase anotada con @InstallIn y @Module. Al especificar un componente de Hilt, @Module le indica a Hilt que se trata de un módulo, y @InstallIn en qué contenedores están disponibles las vinculaciones. Se podría decir que un componente de Hilt es como un contenedor, y la lista completa de componentes se puede encontrar aquí.

Para cada clase de Android que se puede insertar mediante Hilt, existe un componente de Hilt asociado. Por ejemplo, el contenedor Application está asociado con ApplicationComponent y el contenedor Fragment está asociado con FragmentComponent.

Cómo crear un módulo

Creemos un módulo de Hilt en el que podamos agregar vinculaciones. Crea un paquete nuevo llamado di en el paquete hilt y crea un archivo nuevo llamado DatabaseModule.kt dentro del primero.

Debido a que se determina el alcance de LoggerLocalDataSource en relación con el contenedor de la aplicación, la vinculación de LogDao debe estar disponible en el contenedor de la aplicación. Especificamos ese requisito con la anotación @InstallIn pasando la clase del componente de Hilt asociado (es decir, ApplicationComponent:class):

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {

}

En la implementación de la clase ServiceLocator, se obtiene la instancia de LogDao mediante una llamada a logsDatabase.logDao(). Por lo tanto, para proporcionar una instancia de LogDao, tenemos una dependencia transitiva en la clase AppDatabase.

Cómo proporcionar instancias con @Provides

Podemos anotar una función con @Provides en módulos de Hilt para indicarle a Hilt cómo proporcionar tipos que no se pueden insertar mediante constructores.

Se ejecutará el cuerpo de la función anotada con @Provides cada vez que Hilt necesite proporcionar una instancia de ese tipo. El tipo de datos que se muestra de la función anotada con @Provides indica a Hilt cuál es el tipo de vinculación o cómo proporcionar instancias de ese tipo. Los parámetros de función son las dependencias del tipo.

En nuestro caso, incluiremos esta función en la clase DatabaseModule:

@Module
object DatabaseModule {

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

El código anterior indica a Hilt que se debe ejecutar database.logDao() cuando se proporciona una instancia de la clase LogDao. Como AppDatabase es una dependencia transitiva, también debemos indicarle a Hilt cómo proporcionar instancias de ese tipo.

Dado que AppDatabase es otra clase que no pertenece a nuestro proyecto porque la genera Room, también podemos proporcionarla con una función @Provides, de manera similar a como se compila la instancia de base de datos en la clase ServiceLocator:

@Module
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "logging.db"
        ).build()
    }

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

Debido a que siempre queremos que Hilt proporcione la misma instancia de base de datos, debemos anotar el método @Provides provideDatabase con @Singleton.

Cada contenedor de Hilt incluye un conjunto de vinculaciones predeterminadas que se pueden incorporar como dependencias en las vinculaciones personalizadas. Este es el caso de applicationContext: para acceder, debes anotar el campo con @ApplicationContext.

Cómo ejecutar la app

Ahora, Hilt tiene toda la información necesaria para insertar las instancias en la clase LogsFragment. Sin embargo, antes de ejecutar la app, Hilt debe tener en cuenta la Activity que aloja el Fragment para poder funcionar. Es necesario anotar MainActivity con @AndroidEntryPoint.

Abre el archivo ui/MainActivity.kt y anota la MainActivity con @AndroidEntryPoint:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }

Ahora, puedes ejecutar la app y verificar que todo funcione bien como antes.

Continuemos con la refactorización de la app para quitar las llamadas a ServiceLocator desde la MainActivity.

MainActivity obtiene una instancia del objeto AppNavigator cuando ServiceLocator llama a la función provideNavigator(activity: FragmentActivity).

Como AppNavigator es una interfaz, no podemos usar la inserción de constructores. Para indicarle a Hilt qué implementación debe usar para una interfaz, puedes usar la anotación @Binds en una función dentro de un módulo de Hilt.

@Binds debe anotar una función abstracta (ya que es abstracta, no contiene código y la clase también debe ser abstracta). El tipo de datos que se muestra de la función abstracta es la interfaz para la que queremos proporcionar una implementación (es decir, AppNavigator). Se especifica la implementación agregando un parámetro único con el tipo de implementación de la interfaz (es decir, AppNavigatorImpl).

¿Podemos agregar la información a la clase DatabaseModule que creamos antes o necesitamos un módulo nuevo? Por estos diferentes motivos debemos crear un módulo nuevo:

  • Para una mejor organización, el nombre de un módulo debe comunicar el tipo de información que proporciona. Por ejemplo, no tendría sentido incluir vinculaciones de navegación en un módulo llamado DatabaseModule.
  • Se instala el módulo DatabaseModule en el objeto ApplicationComponent, de modo que las vinculaciones estén disponibles en el contenedor de la aplicación. Nuestra nueva información de navegación (es decir, AppNavigator) necesita información específica de la actividad (ya que la clase AppNavigatorImpl tiene una Activity como dependencia). Por lo tanto, debe instalarse en el contenedor de la Activity, en lugar del contenedor de la Application, ya que allí está disponible la información acerca de la Activity.
  • Los módulos de Hilt no pueden contener métodos de vinculación no estáticos ni abstractos, por lo que no se pueden colocar las anotaciones @Binds y @Provides en la misma clase.

Crea un archivo nuevo llamado NavigationModule.kt en la carpeta di. A continuación, vamos a crear una nueva clase abstracta llamada NavigationModule, anotada con @Module y @InstallIn(ActivityComponent::class), como se explicó anteriormente:

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {

    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

Dentro del módulo, podemos agregar la vinculación para AppNavigator. Se trata de una función abstracta que muestra la interfaz sobre cual informamos a Hilt (es decir, AppNavigator) y el parámetro es la implementación de esa interfaz (es decir, AppNavigatorImpl).

Ahora, debemos indicarle a Hilt cómo proporcionar instancias de AppNavigatorImpl. Como esta clase puede tener inserción de constructores, solo anotamos su constructor con @Inject.

Abre el archivo navigator/AppNavigatorImpl.kt y haz lo siguiente:

class AppNavigatorImpl @Inject constructor(
    private val activity: FragmentActivity
) : AppNavigator {
    ...
}

La clase AppNavigatorImpl depende un objetoFragmentActivity. Como se proporciona una instancia de AppNavigator en el contenedor de Activity (también está disponible en el contenedor de Fragment y el contenedor de View, ya que el objeto NavigationModule está instalado en la clase ActivityComponent), FragmentActivity ya está disponible porque es una vinculación predefinida.

Cómo usar Hilt en Activity

Ahora, Hilt tiene toda la información necesaria para poder insertar una instancia de AppNavigator. Abre el archivo MainActivity.kt e inserta el siguiente código:

  1. Anota el campo navigator con @Inject para acceder con Hilt.
  2. Quita el modificador de visibilidad private.
  3. Quita el código de inicialización navigator de la función onCreate.

El nuevo código debería verse así:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var navigator: AppNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            navigator.navigateTo(Screens.BUTTONS)
        }
    }

    ...
}

Cómo ejecutar la app

Puedes ejecutar la app y ver si funciona según lo esperado.

Cómo finalizar la refactorización

La única clase que todavía usa el objeto ServiceLocator para tomar dependencias es ButtonsFragment. Dado que Hilt ya sabe cómo proporcionar todos los tipos que ButtonsFragment necesita, podemos realizar la inserción de campos en la clase.

Como ya lo vimos, para realizar la inserción de campos en la clase mediante Hilt, debemos hacer lo siguiente:

  1. Anotar ButtonsFragment con @AndroidEntryPoint
  2. Quitar el modificador private de los campos logger y navigator y anotarlos con @Inject
  3. Quitar el código de inicialización de campos (es decir, los métodos onAttach y populateFields)

Código para ButtonsFragment:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var navigator: AppNavigator

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_buttons, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
    }
}

Ten en cuenta que la instancia de LoggerLocalDataSource será la misma que usamos en LogsFragment, ya que el tipo tiene determinado el alcance en relación con el contenedor de la aplicación. Sin embargo, la instancia de AppNavigator será distinta de la instancia de MainActivity, ya que no le determinamos el alcance en relación con su respectivo contenedor Activity.

En este punto, la clase ServiceLocator ya no proporciona dependencias, de modo que podemos quitarla por completo del proyecto. El único uso permanece en la clase LogApplication, donde conservamos una instancia de esta. Limpiaremos esa clase porque ya no la necesitaremos.

Abre la clase LogApplication y quita el uso de ServiceLocator. El nuevo código para la clase Application es el siguiente:

@HiltAndroidApp
class LogApplication : Application()

Ahora, puedes quitar la clase ServiceLocator del proyecto por completo. Como ServiceLocator todavía se usa en pruebas, quita también sus usos de la clase AppTest.

Contenido básico incluido

Lo que acabas de aprender es suficiente para usar Hilt como herramienta de inserción de dependencias en tu aplicación para Android.

Desde ahora, agregaremos nuevas funciones a nuestra app para aprender a usar funciones de Hilt más avanzadas en diferentes situaciones.

Ahora que quitaste la clase ServiceLocator de nuestro proyecto y aprendiste los conceptos básicos de Hilt, agregarás una nueva función a la app para explorar otras características de Hilt.

En esta sección, obtendrás información sobre lo siguiente:

  • Cómo determinar el alcance en relación con el contenedor de Activity
  • Qué son los calificadores, qué problemas resuelven y cómo se utilizan

Para demostrarlo, necesitamos un comportamiento diferente en nuestra app. Cambiaremos el almacenamiento del registro de una base de datos a una lista en la memoria con la intención de obtener solo los registros durante una sesión de app.

Interfaz de LoggerDataSource

Para comenzar, abstraeremos la fuente de datos en una interfaz. Crea un archivo nuevo llamado LoggerDataSource.kt en la carpeta data, con el siguiente contenido:

package com.example.android.hilt.data

// Common interface for Logger data sources.
interface LoggerDataSource {
    fun addLog(msg: String)
    fun getAllLogs(callback: (List<Log>) -> Unit)
    fun removeLogs()
}

Se usa LoggerLocalDataSource en ambos fragmentos: ButtonsFragment y LogsFragment. Debemos refactorizarlos para utilizarlos y usar una instancia de LoggerDataSource.

Abre LogsFragment y haz que la variable del registrador sea del tipo LoggerDataSource:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

Haz lo mismo en ButtonsFragment:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

A continuación, haremos que la clase LoggerLocalDataSource implemente esta interfaz. Abre el archivo data/LoggerLocalDataSource.kt y realiza las siguientes acciones:

  1. Haz que implemente la interfaz de LoggerDataSource.
  2. Marca los métodos con override.
@Singleton
class LoggerLocalDataSource @Inject constructor(
    private val logDao: LogDao
) : LoggerDataSource {
    ...
    override fun addLog(msg: String) { ... }
    override fun getAllLogs(callback: (List<Log>) -> Unit) { ... }
    override fun removeLogs() { ... }
}

Ahora, vamos a crear otra implementación de LoggerDataSource llamada LoggerInMemoryDataSource, que mantiene los registros en la memoria. Crea un archivo nuevo llamado LoggerInMemoryDataSource.kt en la carpeta data, con el siguiente contenido:

package com.example.android.hilt.data

import java.util.LinkedList

class LoggerInMemoryDataSource : LoggerDataSource {

    private val logs = LinkedList<Log>()

    override fun addLog(msg: String) {
        logs.addFirst(Log(msg, System.currentTimeMillis()))
    }

    override fun getAllLogs(callback: (List<Log>) -> Unit) {
        callback(logs)
    }

    override fun removeLogs() {
        logs.clear()
    }
}

Cómo establecer el alcance en relación con el contenedor de la actividad

Para poder usar LoggerInMemoryDataSource como un detalle de implementación, debemos indicarle a Hilt cómo proporcionar instancias de este tipo. Como hicimos antes, anotamos el constructor de clase con @Inject:

class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

Debido a que nuestra aplicación consta de una sola Activity (también llamada actividad única ), debemos tener una instancia de la clase LoggerInMemoryDataSource en el contenedor de la Activity y reutilizar esa instancia en los diferentes objetos Fragment.

Para lograr el comportamiento de registro en la memoria, podemos establecer el alcance de LoggerInMemoryDataSource en relación con el contenedor de Activity: cada Activity que se cree tendrá su propio contenedor, una instancia diferente. En cada contenedor, se proporcionará la misma instancia de LoggerInMemoryDataSource cuando se necesite el registrador como una dependencia o para la inserción de campos. Además, se proporcionará la misma instancia en los contenedores de la parte inferior de la jerarquía de componentes.

En el caso de la documentación de alcance a los componentes, para establecer el alcance de un tipo en relación con el contenedor Activity, debemos anotar el tipo con @ActivityScoped:

@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

Por el momento, Hilt sabe cómo proporcionar instancias de LoggerInMemoryDataSource y LoggerLocalDataSource pero, ¿qué sucede con LoggerDataSource? Hilt no sabe qué implementación usar cuando se solicita LoggerDataSource.

Como aprendimos en secciones anteriores, podemos usar la anotación @Binds en un módulo para indicar a Hilt qué implementación debe usar. Sin embargo, ¿qué pasa si debemos proporcionar ambas implementaciones en el mismo proyecto? Por ejemplo, usar un objeto LoggerInMemoryDataSource mientras la app está en ejecución y LoggerLocalDataSource en un objeto Service.

Dos implementaciones para la misma interfaz

Crearemos un archivo nuevo en la carpeta di, llamado LoggingModule.kt. Dado que las diferentes implementaciones de LoggerDataSource tienen establecido el alcance en relación con contenedores diferentes, no podemos usar el mismo módulo: se limita LoggerInMemoryDataSource al contenedor Activity y LoggerLocalDataSource al contenedor Application.

Afortunadamente, podemos definir vinculaciones para ambos módulos del mismo archivo que acabamos de crear:

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

Los métodos @Binds deben tener las anotaciones de alcance si el tipo tiene establecido el alcance, por lo que las funciones anteriores se anotan con @Singleton y @ActivityScoped. Si se usan las anotaciones @Binds o @Provides como vinculaciones para un tipo, ya no se usan las anotaciones de alcance en el tipo, por lo que puedes quitarlas de las diferentes clases de implementación.

Si intentas compilar el proyecto ahora, verás el error DuplicateBindings.

error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times

Esto se debe a que se inserta el tipo LoggerDataSource en nuestros objetos Fragment, pero Hilt no sabe qué implementación de usar porque hay dos vinculaciones del mismo tipo. ¿Cómo puede Hilt saber qué implementación debe usar?

Cómo usar los calificadores

Para indicarle a Hilt cómo proporcionar diferentes implementaciones (varias vinculaciones) del mismo tipo, puedes usar calificadores.

Debemos definir un calificador por implementación, ya que cada calificador se usará para identificar una vinculación. Cuando se insertar el tipo en una clase de Android o ese tipo es una dependencia de otras clases, se debe usar la anotación del calificador para evitar ambigüedades.

Como los calificadores son solo anotaciones, podemos definirlos en el archivo LoggingModule.kt, en el que agregamos los módulos:

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

Ahora, estos calificadores deben anotar las funciones @Binds (o @Provides en caso de que sea necesario) que proporcione cada implementación. Consulta el código completo y observa el uso de los calificadores en los métodos @Binds:

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @DatabaseLogger
    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @InMemoryLogger
    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

Además, se deben usar estos calificadores en el punto de inserción con la implementación que queremos insertar. En este caso, usaremos la implementación de LoggerInMemoryDataSource en nuestros objetos Fragment.

Abre LogsFragment y usa el calificador @InMemoryLogger en el campo de registrador para indicar a Hilt que debe insertar una instancia de LoggerInMemoryDataSource:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

Realiza lo mismo en ButtonsFragment:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

Si deseas cambiar la implementación de la base de datos que deseas usar, solo debes anotar los campos insertados con @DatabaseLogger en lugar de @InMemoryLogger.

Cómo ejecutar la app

Podemos ejecutar la app y confirmar nuestra acción interactuando con los botones y observando los registros correspondientes en la pantalla "See All Logs".

Ten en cuenta que los registros ya no se guardan en la base de datos. No persisten entre las sesiones; cada vez que cierras y vuelves a abrir la app, la pantalla de registro está vacía.

Ahora que se completó la migración de la app a Hilt, también podemos migrar la prueba de instrumentación que tenemos en el proyecto. La prueba que verifica el funcionamiento de la app está en el archivo AppTest.kt de la carpeta app/androidTest. Ábrela.

Verás que no se compila porque quitamos la clase ServiceLocator de nuestro proyecto. Para quitar las referencias a la clase ServiceLocator que ya no vamos a usar, quita el método @After tearDown de la clase.

Se ejecutan las pruebas androitTest en un emulador. La prueba happyPath confirma que se registró en la base de datos que se presionó el "botón 1". Como la app usa la base de datos de la memoria, cuando finalice la prueba desaparecerán todos los registros.

Cómo probar la IU con Hilt

Hilt inserta dependencias en tu prueba de IU de la misma manera que lo haría en tu código de producción.

Las pruebas con Hilt no requieren mantenimiento, ya que se genera automáticamente un nuevo conjunto de componentes para cada prueba.

Cómo agregar las dependencias de prueba

Hilt usa una biblioteca adicional con anotaciones de prueba específicas que facilitan la prueba de tu código llamado hilt-android-testing, que se debe agregar al proyecto. Además, como Hilt necesita generar código para las clases de la carpeta androidTest, también debe poder ejecutarse allí el procesador de anotaciones. Si deseas habilitar esta función, debes incluir dos dependencias en el archivo app/build.gradle.

Para agregar estas dependencias, abre app/build.gradle y agrega esta configuración al final de la sección dependencies:

...
dependencies {

    // Hilt testing dependency
    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    // Make Hilt generate code in the androidTest folder
    kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
}

Ejecutor de pruebas personalizado

Las pruebas instrumentadas con Hilt deben ejecutarse en una Application que admita Hilt. La biblioteca ya incluye la clase HiltTestApplication, que podemos usar para ejecutar las pruebas de IU. Para especificar la Application que se debe usar en las pruebas, debes crear un nuevo ejecutor de pruebas en el proyecto.

En el mismo nivel donde está el archivo AppTest.kt en la carpeta androidTest, crea un archivo nuevo llamado CustomTestRunner. Nuestro objeto CustomTestRunner se extiende desde AndroidJUnitRunner y se implementa de la siguiente manera:

class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

A continuación, debemos indicarle al proyecto que debe usar este ejecutor de pruebas para las pruebas de instrumentación. Este se especifica en el atributo testInstrumentationRunner del archivo app/build.gradle. Abre el archivo y reemplaza el contenido testInstrumentationRunner predeterminado con lo siguiente:

...
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
    }
    ...
}
...

Ya estamos listos para usar Hilt en nuestras pruebas de IU.

Cómo ejecutar una prueba que usa Hilt

Para que una clase de prueba del emulador use Hilt, necesita lo siguiente:

  1. Tener la anotación @HiltAndroidTest, que es responsable de generar los componentes de Hilt para cada prueba
  2. Usar el objeto HiltAndroidRule, que administra el estado de los componentes y se usa para realizar la inserción en la prueba

Vamos a incluir estos dos objetos en AppTest:

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}

Ahora, si ejecutas la prueba usando el botón de reproducción que está ubicado junto a la definición de la clase o la definición del método de prueba, se iniciará un emulador (si lo tienes configurado) y se ejecutará la prueba.

Si deseas obtener más información sobre las pruebas y las funciones, como la inserción de campos o el reemplazo de vinculaciones en las pruebas, consulta la documentación.

En esta sección del codelab, aprenderemos a usar la anotación @EntryPoint, que se usa para insertar dependencias en clases no compatibles con Hilt.

Como vimos anteriormente, Hilt admite los componentes más comunes de Android. Sin embargo, es posible que debas realizar una inserción de campos en clases que no son compatibles con Hilt o que no pueden usar Hilt.

En esos casos, puedes usar la anotación @EntryPoint. Un punto de entrada es el límite donde puedes obtener objetos proporcionados por Hilt de un código que no puede usar Hilt para insertar sus dependencias. Es el punto en el que el código ingresa por primera vez en contenedores administrados por Hilt.

Caso de uso

Nuestro objetivo es poder exportar registros del proceso de aplicación. Para eso, debemos usar una clase ContentProvider. Solo permitimos que los consumidores consulten un registro específico (con un id) o todos los registros de la app mediante una clase ContentProvider. Usaremos la base de datos Room para recuperar los datos. Por lo tanto, la clase LogDao debería exponer los métodos que muestren la información solicitada mediante una base de datos Cursor. Abre el archivo LogDao.kt y agrega los siguientes métodos a la interfaz.

@Dao
interface LogDao {
    ...

    @Query("SELECT * FROM logs ORDER BY id DESC")
    fun selectAllLogsCursor(): Cursor

    @Query("SELECT * FROM logs WHERE id = :id")
    fun selectLogById(id: Long): Cursor?
}

A continuación, tenemos que crear una nueva clase ContentProvider y anular el método query para mostrar un objeto Cursor con los registros. Crea un archivo nuevo llamado LogsContentProvider.kt en un nuevo directorio contentprovider, e incluye el siguiente contenido:

package com.example.android.hilt.contentprovider

import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import com.example.android.hilt.data.LogDao
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import java.lang.UnsupportedOperationException

/** The authority of this content provider.  */
private const val LOGS_TABLE = "logs"

/** The authority of this content provider.  */
private const val AUTHORITY = "com.example.android.hilt.provider"

/** The match code for some items in the Logs table.  */
private const val CODE_LOGS_DIR = 1

/** The match code for an item in the Logs table.  */
private const val CODE_LOGS_ITEM = 2

/**
 * A ContentProvider that exposes the logs outside the application process.
 */
class LogsContentProvider: ContentProvider() {

    private val matcher: UriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(AUTHORITY, LOGS_TABLE, CODE_LOGS_DIR)
        addURI(AUTHORITY, "$LOGS_TABLE/*", CODE_LOGS_ITEM)
    }

    override fun onCreate(): Boolean {
        return true
    }

    /**
     * Queries all the logs or an individual log from the logs database.
     *
     * For the sake of this codelab, the logic has been simplified.
     */
    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        val code: Int = matcher.match(uri)
        return if (code == CODE_LOGS_DIR || code == CODE_LOGS_ITEM) {
            val appContext = context?.applicationContext ?: throw IllegalStateException()
            val logDao: LogDao = getLogDao(appContext)

            val cursor: Cursor? = if (code == CODE_LOGS_DIR) {
                logDao.selectAllLogsCursor()
            } else {
                logDao.selectLogById(ContentUris.parseId(uri))
            }
            cursor?.setNotificationUri(appContext.contentResolver, uri)
            cursor
        } else {
            throw IllegalArgumentException("Unknown URI: $uri")
        }
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun getType(uri: Uri): String? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }
}

Verás que no se compila la llamada a getLogDao(appContext). A fin de implementarla, debemos tomar la dependencia LogDao del contenedor de la aplicación de Hilt. Sin embargo, Hilt no admite que se inserte de inmediato una clase ContentProvider como se hace con Activity, por ejemplo, con la anotación @AndroidEntryPoint.

Debemos crear una nueva interfaz con la anotación @EntryPoint para acceder a ella.

@EntryPoint en acción

Un punto de entrada es una interfaz con un método de acceso para cada tipo de vinculación que necesitamos (incluido su calificador). Además, la interfaz debe anotarse con @InstallIn a fin de especificar el componente en el que se instalará el punto de entrada.

Se recomienda agregar la nueva interfaz de punto de entrada dentro de la clase que la usa. Por lo tanto, debes incluir la interfaz en el archivo LogsContentProvider.kt:

class LogsContentProvider: ContentProvider() {

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface LogsContentProviderEntryPoint {
        fun logDao(): LogDao
    }

    ...
}

Ten en cuenta que la interfaz se anota con @EntryPoint y se instala en la clase ApplicationComponent, ya que necesitamos la dependencia de una instancia del contenedor de Application. Dentro de la interfaz, exponemos métodos para las vinculaciones a las que queremos acceder, en nuestro caso, LogDao.

Para acceder a un punto de entrada, usa el método estático apropiado de EntryPointAccessors. El parámetro debería ser la instancia del componente o el objeto @AndroidEntryPoint que funciona como contenedor del componente. Asegúrate de que el componente que pasas como parámetro y el método estático EntryPointAccessors coincidan con la clase de Android en la anotación @InstallIn de la interfaz @EntryPoint:

Ahora, podemos implementar el método getLogDao que falta en el código anterior. Usemos la interfaz de punto de entrada que definimos anteriormente en nuestra clase LogsContentProviderEntryPoint:

class LogsContentProvider: ContentProvider() {
    ...

    private fun getLogDao(appContext: Context): LogDao {
        val hiltEntryPoint = EntryPointAccessors.fromApplication(
            appContext,
            LogsContentProviderEntryPoint::class.java
        )
        return hiltEntryPoint.logDao()
    }
}

Observa cómo pasamos el objeto applicationContext al método estático EntryPoints.get y la clase de la interfaz anotada con @EntryPoint.

Ahora que conoces Hilt, deberías poder agregarlo a tu aplicación para Android. En este codelab, aprendiste lo siguiente:

  • Cómo configurar Hilt en la clase de la aplicación usando la anotación @HiltAndroidApp
  • Cómo agregar contenedores de dependencia en los diferentes componentes del ciclo de vida de Android usando la anotación @AndroidEntryPoint
  • Cómo usar módulos para indicar a Hilt de qué manera debe proporcionar determinados tipos
  • Cómo usar calificadores a fin de proporcionar varias vinculaciones para determinados tipos
  • Cómo probar tu app con Hilt
  • Cuándo es útil la anotación @EntryPoint y cómo se usa