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:
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 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í:
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 objetoApplicationComponent
, 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 claseAppNavigatorImpl
tiene unaActivity
como dependencia). Por lo tanto, debe instalarse en el contenedor de laActivity
, en lugar del contenedor de laApplication
, ya que allí está disponible la información acerca de laActivity
. - 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:
- Anota el campo
navigator
con@Inject
para acceder con Hilt. - Quita el modificador de visibilidad
private
. - Quita el código de inicialización
navigator
de la funciónonCreate
.
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:
- Anotar
ButtonsFragment
con@AndroidEntryPoint
- Quitar el modificador private de los campos
logger
ynavigator
y anotarlos con@Inject
- Quitar el código de inicialización de campos (es decir, los métodos
onAttach
ypopulateFields
)
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:
- Haz que implemente la interfaz de
LoggerDataSource
. - 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:
- Tener la anotación
@HiltAndroidTest
, que es responsable de generar los componentes de Hilt para cada prueba - 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