Utiliser Hilt dans une application Android

Dans cet atelier de programmation, vous allez découvrir l'importance de l'injection de dépendances (DI) pour créer une application robuste et extensible qui s'adapte aux projets de grande taille. Nous utiliserons Hilt comme outil DI pour la gestion des dépendances.

L'injection de dépendances est une technique couramment utilisée en programmation et parfaitement adaptée au développement sur Android. En suivant les principes d'injection de dépendances, vous pourrez jeter les bases d'une architecture d'application de qualité.

L'implémentation de cette technique vous garantit les avantages suivants :

  • Code réutilisable
  • Facilité de refactorisation
  • Facilité de test

Hilt est une bibliothèque d'injection de dépendances "orientée" pour Android qui permet de réduire la tâche répétitive d'injection manuelle de dépendances dans votre projet. Pour effectuer une injection de dépendances manuelle, vous devez construire manuellement chaque classe et ses dépendances, et employer des conteneurs pour réutiliser et gérer ces dépendances.

Hilt offre une méthode standard pour effectuer l'injection des dépendances dans votre application en fournissant des conteneurs à chaque composant Android de votre projet et en gérant automatiquement le cycle de vie des conteneurs. Pour ce faire, la bibliothèque DI suivante est utilisée : Dagger.

Si vous rencontrez des problèmes (bugs de code, erreurs grammaticales, formulation peu claire, etc.) au cours de cet atelier de programmation, veuillez les signaler via le lien Signaler une erreur situé dans l'angle inférieur gauche de l'atelier de programmation.

Conditions préalables

  • Vous connaissez la syntaxe du langage Kotlin.
  • Vous comprenez l'importance de l'injection de dépendances dans votre application.

Points abordés

  • Comment utiliser Hilt dans votre application Android
  • Concepts Hilt pertinents pour la création d'une application durable
  • Comment ajouter plusieurs liaisons au même type avec des qualificatifs
  • Comment utiliser @EntryPoint pour accéder à des conteneurs à partir de classes non compatibles avec Hilt
  • Comment utiliser les tests unitaires et d'instrumentation pour tester une application utilisant Hilt

Prérequis

  • Android Studio version 4.0 ou ultérieure

Obtenir le code

Obtenez le code de l'atelier de programmation sur GitHub :

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

Vous pouvez également télécharger le dépôt sous la forme d'un fichier ZIP :

Télécharger le fichier ZIP

Ouvrez Android Studio.

Cet atelier de programmation nécessite Android Studio 4.0 ou version ultérieure. Si vous devez télécharger Android Studio, cliquez ici.

Exécuter l'exemple d'application

Dans cet atelier de programmation, vous allez ajouter Hilt à une application qui journalise les interactions des utilisateurs et utilise Room pour stocker les données dans une base de données locale.

Suivez les instructions ci-dessous pour ouvrir l'exemple d'application dans Android Studio :

  • Si vous avez téléchargé l'archive ZIP, décompressez le fichier en local.
  • Ouvrez le projet dans Android Studio.
  • Cliquez sur le bouton Exécuter execute.png, puis sélectionnez un émulateur ou connectez votre appareil Android.

Comme vous pouvez le voir, un journal est créé et stocké lors de chaque interaction avec l'un des boutons numérotés. L'écran See All Logs (Afficher tous les journaux) contient la liste de toutes les interactions précédentes. Pour supprimer les journaux, appuyez sur le bouton Delete Logs (Supprimer les journaux).

Configuration du projet

Le projet est construit dans plusieurs branches GitHub :

  • master est la branche que vous avez extraite ou téléchargée. Il s'agit du point de départ de l'atelier de programmation.
  • solution contient la solution à cet atelier de programmation.

Nous vous recommandons de commencer par le code de la branche master, puis de suivre l'atelier étape par étape, à votre propre rythme.

Au cours de cet atelier de programmation, vous découvrirez des extraits de code que vous devrez ajouter au projet. À certains endroits, vous devrez également supprimer le code qui est explicitement mentionné dans les commentaires sur les extraits de code.

Pour obtenir la branche solution à l'aide de git, exécutez la commande suivante :

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

Vous pouvez également télécharger le code de la solution en cliquant sur le bouton suivant :

Télécharger le code final

Questions fréquentes

Pourquoi utiliser Hilt ?

Si vous observez le code de démarrage, vous constaterez qu'une instance de la classe ServiceLocator est stockée dans la classe LogApplication. ServiceLocator crée et stocke les dépendances obtenues à la demande par les classes qui en ont besoin. Vous pouvez comparer cette classe à un conteneur de dépendances qui est associé au cycle de vie de l'application, en ce sens qu'il est détruit en même temps que celle-ci.

Comme expliqué dans l'article Android DI guidance (Conseils en matière d'injection de dépendances sur Android), les localisateurs de services commencent par un code réutilisable relativement petit, mais leur évolutivité est faible. Pour développer une application Android à grande échelle, vous devez utiliser Hilt.

Hilt supprime le code réutilisable inutile dont vous avez besoin pour utiliser la technique DI manuelle ou un modèle de localisation de services dans une application Android en générant le code que vous auriez créé manuellement (le code de la classe ServiceLocator, par exemple).

Au cours des prochaines étapes, vous utiliserez Hilt pour remplacer la classe ServiceLocator. Nous ajouterons ensuite de nouvelles fonctionnalités au projet afin de découvrir d'autres fonctions de Hilt.

Hilt dans votre projet

Hilt est déjà configuré dans la branche master (le code que vous avez téléchargé). Il n'est pas nécessaire d'inclure le code suivant dans le projet, car cela a déjà été fait. Passons toutefois en revue les éléments nécessaires pour utiliser Hilt dans une application Android.

Outre les dépendances de bibliothèque, Hilt utilise un plug-in Gradle qui est configuré dans le projet. Ouvrez le fichier build.gradle racine et observez la dépendance Hilt suivante dans le chemin de la classe :

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

Ensuite, pour utiliser le plug-in gradle dans le module app, nous allons le spécifier dans le fichier app/build.gradle en ajoutant le plug-in en haut du fichier, sous kotlin-kapt :

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

android {
    ...
}

Enfin, les dépendances Hilt sont incluses dans le projet, dans le même fichier app/build.gradle :

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

Toutes les bibliothèques, y compris Hilt, sont téléchargées lorsque vous créez et synchronisez le projet. Commençons maintenant à utiliser Hilt !

De la même manière que l'instance de ServiceLocator est utilisée et initialisée dans la classe LogApplication, pour ajouter un conteneur associé au cycle de vie de l'application, la classe Application doit être annotée avec @HiltAndroidApp. Ouvrez LogApplication.kt et ajoutez l'annotation à la classe :

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

@HiltAndroidApp déclenche la génération du code de Hilt, y compris une classe de base pour votre application pouvant utiliser l'injection de dépendances. Le conteneur d'applications est le conteneur parent de l'application, ce qui signifie que d'autres conteneurs peuvent accéder aux dépendances qu'il fournit.

Notre application est maintenant prête à utiliser Hilt.

Au lieu d'extraire des dépendances à la demande à partir de ServiceLocator dans nos classes, nous allons utiliser Hilt pour fournir ces dépendances. Commençons par remplacer les appels vers ServiceLocator à partir de nos classes.

Ouvrez le fichier ui/LogsFragment.kt. LogsFragment renseigne ses champs dans onAttach. Au lieu de renseigner manuellement les instances de LoggerLocalDataSource et DateFormatter à l'aide de ServiceLocator, nous pouvons utiliser Hilt pour créer et gérer ces types d'instances.

Pour que LogsFragment utilise Hilt, nous devons l'annoter avec @AndroidEntryPoint :

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

L'annotation de classes Android avec @AndroidEntryPoint crée un conteneur de dépendances qui suit le cycle de vie de la classe Android.

Avec @AndroidEntryPoint, Hilt crée un conteneur de dépendances associé au cycle de vie de LogsFragment et peut injecter des instances dans LogsFragment. Comment faire en sorte que Hilt injecte des champs ?

Hilt peut être configuré de manière à injecter des instances de différents types avec l'annotation @Inject sur les champs à injecter (c'est-à-dire logger et dateFormatter) :

@AndroidEntryPoint
class LogsFragment : Fragment() {

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

    ...
}

C'est ce qu'on appelle l'injection de champs.

Hilt étant chargé de renseigner ces champs, la méthode populateFields n'est plus nécessaire. Nous allons donc la supprimer de la classe :

@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 arrière-plan, Hilt renseigne ces champs dans la méthode de cycle de vie onAttach() avec des instances compilées dans le conteneur de dépendances du LogsFragment généré automatiquement.

Pour exécuter l'injection de champs, Hilt doit savoir comment fournir des instances de ces dépendances. Dans le cas présent, il s'agit des instances de LoggerLocalDataSource et DateFormatter. Cependant, à ce stade, Hilt ne sait pas encore comment procéder.

Indiquer à Hilt comment fournir des dépendances avec @Inject

Ouvrez le fichier ServiceLocator.kt pour voir comment ServiceLocator est implémenté. Comme vous pouvez le constater, chaque appel de provideDateFormatter() renvoie une instance différente de DateFormatter.

C'est exactement le comportement que nous souhaitons obtenir avec Hilt. Heureusement, DateFormatter ne dépend pas d'autres classes. Nous ne devons donc pas nous soucier des dépendances transitives pour le moment.

Pour indiquer à Hilt comment fournir des instances d'un certain type, ajoutez l'annotation @Inject au constructeur de la classe qui doit être injectée.

Ouvrez le fichier util/DateFormatter.kt et annotez le constructeur de DateFormatter avec @Inject. N'oubliez pas que pour annoter un constructeur en langage Kotlin, vous avez également besoin du mot clé constructor :

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

Hilt sait maintenant comment fournir des instances de DateFormatter. Vous devez procéder de la même façon avec LoggerLocalDataSource. Ouvrez le fichier data/LoggerLocalDataSource.kt et annotez son constructeur avec @Inject :

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

Si vous ouvrez à nouveau la classe ServiceLocator, vous voyez qu'il existe un champ LoggerLocalDataSource public. Cela signifie que ServiceLocator renvoie toujours la même instance de LoggerLocalDataSource à chaque appel. On parle alors de limitation d'une instance à un conteneur. Comment parvenir à ce résultat dans Hilt ?

Vous pouvez utiliser des annotations pour limiter les instances à des conteneurs. Comme Hilt peut produire plusieurs conteneurs ayant des cycles de vie différents, plusieurs annotations peuvent être utilisées à cette fin.

L'annotation qui limite une instance au conteneur d'applications est @Singleton. Avec cette annotation, le conteneur d'applications fournit toujours la même instance, que le type soit utilisé en tant que dépendance d'un autre type ou qu'il doive faire l'objet d'une injection de champs.

La même logique peut être appliquée à tous les conteneurs associés aux classes Android. Vous trouverez la liste de toutes les annotations de limitation dans la documentation. Par exemple, si vous souhaitez qu'un conteneur d'activités fournisse toujours la même instance d'un type, vous pouvez annoter ce type avec @ActivityScoped.

Comme indiqué ci-dessus, puisque le conteneur d'applications doit toujours fournir la même instance de LoggerLocalDataSource, sa classe est annotée avec @Singleton :

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

Hilt sait maintenant comment fournir des instances de LoggerLocalDataSource. Cependant, cette fois, le type comporte des dépendances transitives. Pour fournir une instance de LoggerLocalDataSource, Hilt doit également savoir comment fournir une instance de LogDao.

Toutefois, comme LogDao est une interface, vous ne pouvez pas annoter son constructeur avec @Inject, car les interfaces n'en ont pas. Comment indiquer à Hilt de quelle façon fournir des instances de ce type ?

Les modules sont utilisés pour ajouter des liaisons à Hilt ou, autrement dit, lui indiquer comment fournir des instances de différents types. Dans les modules Hilt, vous devez inclure des liaisons pour les types qui ne peuvent pas être injectés avec un constructeur, comme les interfaces ou les classes qui ne figurent pas dans votre projet. OkHttpClient en est un bon exemple. Vous devez utiliser son compilateur pour créer une instance.

Un module Hilt est une classe annotée avec @Module et @InstallIn. @Module indique à Hilt qu'il s'agit d'un module, tandis que @InstallIn précise les conteneurs dans lesquels les liaisons sont disponibles en spécifiant un composant Hilt. Vous pouvez considérer un composant Hilt comme un conteneur. Pour consulter la liste complète des composants, cliquez ici.

Un composant Hilt est associé à chaque classe Android pouvant être injectée par Hilt. Par exemple, le conteneur Application est associé à ApplicationComponent et le conteneur Fragment est associé à FragmentComponent.

Créer un module

Nous allons créer un module Hilt dans lequel nous pouvons ajouter des liaisons. Créez un package nommé di sous le package hilt, ainsi qu'un fichier intitulé DatabaseModule.kt dans ce package.

La classe LoggerLocalDataSource étant limitée au conteneur d'applications, la liaison LogDao doit être disponible dans ce conteneur. Cette exigence est indiquée à l'aide de l'annotation @InstallIn en transmettant la classe du composant Hilt qui y est associé (c'est-à-dire ApplicationComponent:class) :

package com.example.android.hilt.di

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

}

Dans l'implémentation de la classe ServiceLocator, l'instance de LogDao est obtenue en appelant logsDatabase.logDao(). Par conséquent, pour fournir une instance de LogDao, une dépendance transitive est présente sur la classe AppDatabase.

Fournir des instances avec @Provides

Il est possible d'annoter une fonction avec @Provides dans les modules Hilt afin d'indiquer à Hilt comment fournir des types qui ne peuvent pas être injectés avec un constructeur.

Le corps de la fonction annotée @Provides est exécuté chaque fois que Hilt doit fournir une instance de ce type. Le type renvoyé de la fonction annotée @Provides indique à Hilt le type de la liaison ou comment fournir des instances de ce type. Les paramètres de fonction sont les dépendances du type.

Dans le cas présent, nous allons inclure cette fonction dans la classe DatabaseModule :

@Module
object DatabaseModule {

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

Le code ci-dessus indique à Hilt que database.logDao() doit être exécuté lors de la fourniture d'une instance de LogDao. Puisque AppDatabase est une dépendance transitive, nous devons également indiquer à Hilt comment fournir des instances de ce type.

AppDatabase est une autre classe qui n'appartient pas à notre projet, car elle est générée par Room. Par conséquent, nous pouvons également la fournir à l'aide d'une fonction @Provides semblable à celle utilisée pour créer l'instance de base de données dans la classe 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()
    }
}

Étant donné que Hilt doit toujours fournir la même instance de base de données, nous allons annoter la méthode @Provides provideDatabase avec @Singleton.

Chaque conteneur Hilt s'accompagne d'un ensemble de liaisons par défaut qui peuvent être injectées en tant que dépendances dans vos liaisons personnalisées. C'est le cas de applicationContext : pour y accéder, vous devez annoter le champ avec @ApplicationContext.

Exécuter l'application

Hilt dispose à présent de toutes les informations nécessaires pour injecter les instances dans LogsFragment. Toutefois, avant d'exécuter l'application, Hilt doit connaître le type Activity qui héberge le Fragment. MainActivity doit être annoté avec @AndroidEntryPoint.

Ouvrez le fichier ui/MainActivity.kt et annotez MainActivity avec @AndroidEntryPoint :

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

Vous pouvez maintenant exécuter l'application et vérifier que tout fonctionne correctement.

Nous allons continuer à refactoriser l'application pour supprimer les appels ServiceLocator depuis MainActivity.

MainActivity obtient une instance de AppNavigator à partir de ServiceLocator, qui appelle la fonction provideNavigator(activity: FragmentActivity).

AppNavigator étant une interface, nous ne pouvons pas utiliser l'injection avec un constructeur. Pour indiquer à Hilt l'implémentation à utiliser pour une interface, vous pouvez utiliser l'annotation @Binds sur une fonction à l'intérieur d'un module Hilt.

@Binds doit annoter une fonction abstraite (étant donné qu'elle est abstraite, elle ne contient aucun code, et la classe doit également être abstraite). Le type renvoyé de la fonction abstraite est l'interface pour laquelle nous souhaitons fournir une implémentation (c'est-à-dire AppNavigator). L'implémentation est spécifiée en ajoutant un paramètre unique avec le type d'implémentation de l'interface (c'est-à-dire AppNavigatorImpl).

Peut-on ajouter les informations à la classe DatabaseModule qui a été créée précédemment ou un nouveau module est-il nécessaire ? Il est recommandé de créer un module pour plusieurs raisons :

  • Pour une meilleure organisation, le nom du module doit transmettre le type d'informations qu'il fournit. Par exemple, inclure des liaisons de navigation dans un module nommé DatabaseModule n'aurait aucun sens.
  • Le module DatabaseModule est installé dans ApplicationComponent afin que les liaisons soient disponibles dans le conteneur d'applications. Nos nouvelles informations de navigation (c'est-à-dire AppNavigator) ont besoin de données spécifiques provenant de l'activité (étant donné que AppNavigatorImpl a un type Activity comme dépendance). Par conséquent, il doit être installé dans le conteneur Activity au lieu de Application, car c'est là que les informations sur le type Activity sont disponibles.
  • Les modules Hilt ne peuvent pas contenir à la fois des méthodes de liaison non statiques et abstraites. Vous ne pouvez donc pas placer des annotations @Binds et @Provides dans la même classe.

Créez un fichier nommé NavigationModule.kt dans le dossier di. Vous allez ensuite créer une classe abstraite intitulée NavigationModule, annotée avec @Module et @InstallIn(ActivityComponent::class), comme expliqué ci-dessus :

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

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

À l'intérieur du module, vous pouvez ajouter la liaison pour AppNavigator. Il s'agit d'une fonction abstraite qui renvoie l'interface au sujet de laquelle nous transmettons des informations à Hilt (c'est-à-dire AppNavigator). Le paramètre correspond à l'implémentation de cette interface (c'est-à-dire AppNavigatorImpl).

Nous devons maintenant indiquer à Hilt comment fournir des instances de AppNavigatorImpl. Étant donné que cette classe peut être injectée avec un constructeur, nous allons simplement annoter son constructeur avec @Inject.

Ouvrez le fichier navigator/AppNavigatorImpl.kt et procédez comme suit :

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

AppNavigatorImpl dépend d'un FragmentActivity. Une instance AppNavigator est fournie dans le conteneur Activity (elle est également disponible dans un conteneur Fragment et un conteneur View, étant donné que le NavigationModule est installé dans ActivityComponent). Par conséquent, FragmentActivity est déjà disponible, car il se présente sous la forme d'une liaison prédéfinie.

Utiliser Hilt dans "Activity"

Hilt dispose à présent de toutes les informations nécessaires pour injecter une instance AppNavigator. Ouvrez le fichier MainActivity.kt et procédez comme suit :

  1. Annotez le champ navigator avec @Inject que Hilt doit injecter.
  2. Supprimez le modificateur de visibilité private.
  3. Supprimez le code d'initialisation navigator dans la fonction onCreate.

Le nouveau code doit se présenter comme suit :

@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)
        }
    }

    ...
}

Exécuter l'application

Vous pouvez exécuter l'application et vérifier qu'elle fonctionne comme prévu.

Terminer la refactorisation

La seule classe qui utilise encore ServiceLocator pour extraire des dépendances est ButtonsFragment. Hilt sait déjà comment fournir tous les types dont ButtonsFragment a besoin. Il suffit donc d'effectuer une injection de champs dans la classe.

Comme nous l'avons vu précédemment, plusieurs opérations sont nécessaires pour que l'injection de champs soit possible :

  1. Annoter le fichier ButtonsFragment avec @AndroidEntryPoint.
  2. Supprimer le modificateur privé des champs logger et navigator, puis les annoter avec @Inject.
  3. Supprimer le code d'initialisation des champs (méthodes onAttach et populateFields).

Code pour 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?) {
        ...
    }
}

Notez que l'instance de LoggerLocalDataSource sera la même que celle utilisée dans LogsFragment, car le type est limité au conteneur d'applications. Cependant, l'instance de AppNavigator sera différente de celle qui figure dans MainActivity, car elle n'a pas été limitée au conteneur Activity correspondant.

À ce stade, la classe ServiceLocator ne fournit plus de dépendances. Nous pouvons donc la supprimer complètement du projet. Elle continue seulement à être utilisée dans la classe LogApplication, où nous en avons conservé une instance. Nous allons maintenant nettoyer cette classe, car nous n'en avons plus besoin.

Ouvrez la classe LogApplication et supprimez l'utilisation de ServiceLocator. Le nouveau code de la classe Application est le suivant :

@HiltAndroidApp
class LogApplication : Application()

N'hésitez pas à supprimer complètement la classe ServiceLocator du projet. Étant donné que la classe ServiceLocator est toujours utilisée lors des tests, supprimez également son utilisation de la classe AppTest.

Acquisition des compétences de base

Ce que vous venez d'apprendre devrait être suffisant pour vous permettre d'utiliser Hilt comme outil d'injection de dépendances dans votre application Android.

Nous allons maintenant ajouter d'autres fonctions à l'application afin d'apprendre à utiliser des fonctionnalités Hilt plus avancées dans différentes situations.

La classe ServiceLocator a été supprimée du projet, et vous connaissez maintenant les principes de base de Hilt. Le moment est donc venu d'ajouter des fonctions à l'application pour découvrir d'autres fonctionnalités de Hilt.

Thèmes abordés dans cette section :

  • Comment limiter une instance au conteneur d'activités.
  • Que sont les qualificatifs, quels problèmes permettent-ils de résoudre et comment les utiliser.

Pour illustrer ces thèmes, un comportement différent doit être défini dans l'application. Nous allons définir le stockage des journaux dans une liste en mémoire, au lieu d'une base de données, dans le but de n'enregistrer les journaux que lors d'une session d'application.

Interface LoggerDataSource

Nous allons commencer par extraire la source de données dans une interface. Créez un fichier nommé LoggerDataSource.kt dans le dossier data avec le contenu suivant :

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()
}

LoggerLocalDataSource est utilisé dans les deux fragments : ButtonsFragment et LogsFragment. Nous devons les refactoriser pour qu'ils utilisent plutôt une instance de LoggerDataSource.

Ouvrez LogsFragment et définissez la variable d'enregistreur sur le type LoggerDataSource :

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

Procédez de la même manière dans ButtonsFragment :

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

Nous allons maintenant faire en sorte que LoggerLocalDataSource implémente cette interface. Ouvrez le fichier data/LoggerLocalDataSource.kt et effectuez les opérations suivantes :

  1. Faites en sorte qu'il implémente l'interface LoggerDataSource.
  2. Marquez ses méthodes avec 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() { ... }
}

Nous allons maintenant créer une implémentation de LoggerDataSource, appelée LoggerInMemoryDataSource, qui conserve les journaux en mémoire. Créez un fichier nommé LoggerInMemoryDataSource.kt dans le dossier data avec le contenu suivant :

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()
    }
}

Limiter une instance au conteneur d'activités

Pour pouvoir utiliser LoggerInMemoryDataSource comme détail d'implémentation, nous devons indiquer à Hilt comment fournir des instances de ce type. Nous allons à nouveau annoter le constructeur de la classe avec @Inject :

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

Étant donné que notre application ne comporte qu'une seule activité (on parle également d'application à activité unique), nous devons disposer d'une instance de LoggerInMemoryDataSource dans le conteneur Activity et réutiliser cette instance sur des Fragments.

Pour activer la journalisation en mémoire, nous pouvons limiter LoggerInMemoryDataSource au conteneur Activity. Chaque Activity créée aura alors son propre conteneur, une instance différente. La même instance de LoggerInMemoryDataSource est fournie sur chaque conteneur lorsque l'enregistreur est requis en tant que dépendance ou pour l'injection de champs. Elle l'est également dans les conteneurs situés dans la hiérarchie des composants.

Conformément à la documentation sur la limitation d'instances à des composants, pour limiter un type au conteneur Activity, il doit être annoté avec @ActivityScoped :

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

Pour le moment, Hilt sait comment fournir des instances de LoggerInMemoryDataSource et LoggerLocalDataSource, mais qu'en est-il de LoggerDataSource ? Hilt ne sait pas quelle implémentation utiliser lorsque le type LoggerDataSource est demandé.

Comme nous l'avons déjà évoqué dans les sections précédentes, l'annotation @Binds peut être utilisée dans un module pour indiquer à Hilt l'implémentation à utiliser. Cependant, que se passe-t-il si les deux implémentations doivent être fournies dans le même projet ? Par exemple : LoggerInMemoryDataSource lorsque l'application est en cours d'exécution et LoggerLocalDataSource dans un Service.

Deux implémentations pour la même interface

Nous allons créer un fichier nommé LoggingModule.kt dans le dossier di. Étant donné que les différentes implémentations de LoggerDataSource sont limitées à des conteneurs différents, nous ne pouvons pas utiliser le même module : LoggerInMemoryDataSource est limité au conteneur Activity et LoggerLocalDataSource, au conteneur Application.

Nous pouvons heureusement définir des liaisons pour les deux modules dans le même fichier que nous venons de créer :

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
}

Si le type est limité, les annotations de limitation doivent être associées aux méthodes @Binds. C'est pourquoi les fonctions ci-dessus sont annotées avec @Singleton et @ActivityScoped. Si @Binds ou @Provides sont utilisés en tant que liaison pour un type, les annotations de limitation de ce type ne sont plus utilisées. Vous pouvez donc les supprimer des différentes classes d'implémentation.

Si vous essayez de créer le projet maintenant, une erreur DuplicateBindings est renvoyée.

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

En effet, le type LoggerDataSource est injecté dans nos Fragments, mais Hilt ne sait pas quelle implémentation utiliser, car il existe deux liaisons du même type. Comment peut-il savoir laquelle utiliser ?

Utiliser des qualificatifs

Pour indiquer à Hilt comment fournir différentes implémentations (liaisons multiples) du même type, vous pouvez utiliser des qualificatifs.

Vous devez définir un qualificatif par implémentation, car chaque qualificatif est utilisé pour identifier une liaison. Lorsque le type est injecté dans une classe Android ou qu'il s'agit d'une dépendance d'autres classes, un qualificatif doit être utilisé pour éviter toute ambiguïté.

Un qualificatif n'est rien de plus qu'une annotation. Nous pouvons donc le définir dans le fichier LoggingModule.kt dans lequel nous avons ajouté les modules :

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

À présent, ces qualificatifs doivent annoter les fonctions @Binds (ou @Provides si nécessaire) qui fournissent chaque implémentation. Passez en revue le code complet et notez l'utilisation des qualificatifs dans les méthodes @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
}

Ces qualificatifs doivent également être utilisés au niveau du point d'injection avec l'implémentation à injecter. Dans le cas présent, nous utiliserons l'implémentation LoggerInMemoryDataSource dans nos Fragments.

Ouvrez LogsFragment et utilisez le qualificatif @InMemoryLogger dans le champ d'enregistreur pour indiquer à Hilt d'injecter une instance de LoggerInMemoryDataSource :

@AndroidEntryPoint
class LogsFragment : Fragment() {

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

Faites la même chose pour ButtonsFragment :

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

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

Si vous souhaitez modifier l'implémentation de la base de données à utiliser, il vous suffit d'annoter les champs injectés avec @DatabaseLogger au lieu de @InMemoryLogger.

Exécuter l'application

Nous pouvons exécuter l'application et vérifier les opérations effectuées en utilisant les boutons et en observant les journaux appropriés sur l'écran "See all logs" (Afficher tous les journaux).

Notez que les journaux ne sont plus enregistrés dans la base de données. Ils ne sont pas conservés entre les sessions. Aussi, lorsque vous fermez et rouvrez l'application, l'écran de journalisation est vide.

Maintenant que l'application a entièrement migré vers Hilt, nous pouvons également migrer le test d'instrumentation qui se trouve dans le projet. Le test qui vérifie le fonctionnement de l'application se trouve dans le fichier AppTest.kt, dans le dossier app/androidTest. Ouvrez-le.

Comme vous pouvez le constater, aucune compilation n'est effectuée, car nous avons supprimé la classe ServiceLocator du projet. Supprimez les références à la classe ServiceLocator qui n'est plus utilisée en supprimant la méthode @After tearDown.

Les tests androitTest sont exécutés sur un émulateur. Le test happyPath confirme que l'appui sur "Button 1" (Bouton 1) a été enregistré dans la base de données. Étant donné que l'application utilise la base de données en mémoire, tous les journaux sont effacés une fois le test terminé.

Test de l'interface utilisateur avec Hilt

Hilt injecte des dépendances dans votre test d'interface utilisateur, comme il le ferait dans votre code de production.

Effectuer des tests avec Hilt ne nécessite aucune maintenance, dans la mesure où un nouvel ensemble de composants est généré automatiquement pour chaque test.

Ajouter les dépendances de test

Hilt utilise une bibliothèque supplémentaire, hilt-android-testing, avec des annotations spécifiques aux tests. Cette bibliothèque, qui simplifie le test de votre code, doit être ajoutée au projet. De plus, étant donné que Hilt doit générer du code pour les classes du dossier androidTest, il faut également que son processeur d'annotations puisse s'exécuter à cet emplacement. Pour ce faire, vous devez inclure deux dépendances dans le fichier app/build.gradle.

Pour ajouter ces dépendances, ouvrez app/build.gradle et ajoutez cette configuration en bas de la section 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"
}

Lanceur de test personnalisé

Les tests instrumentés à l'aide de Hilt doivent être exécutés dans une classe Application compatible. La bibliothèque intègre déjà HiltTestApplication, que nous pouvons utiliser pour exécuter les tests d'interface utilisateur. Pour spécifier la classe Application à utiliser dans les tests, nous devons créer un lanceur de test dans le projet.

Dans le dossier androidTest, au même niveau que le fichier AppTest.kt, créez un fichier nommé CustomTestRunner. Notre fichier CustomTestRunner s'étend à partir de AndroidJUnitRunner et est implémenté comme suit :

class CustomTestRunner : AndroidJUnitRunner() {

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

Nous devons ensuite indiquer au projet d'utiliser ce lanceur de test pour les tests d'instrumentation. Cela est spécifié dans l'attribut testInstrumentationRunner du fichier app/build.gradle. Ouvrez le fichier et remplacez le contenu testInstrumentationRunner par défaut par ce qui suit :

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

Nous sommes maintenant prêts à utiliser Hilt dans nos tests d'interface utilisateur.

Exécuter un test qui utilise Hilt

Pour qu'une classe de test d'émulateur utilise Hilt, elle doit :

  1. être annotée avec @HiltAndroidTest, un type chargé de générer les composants Hilt pour chaque test ;
  2. utiliser le HiltAndroidRule qui gère l'état des composants et permet d'effectuer une injection sur votre test.

Nous allons les inclure dans AppTest :

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

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}

Désormais, lorsque vous exécuterez le test à l'aide du bouton de lecture situé à côté de la définition de classe ou de la définition de la méthode de test, un émulateur démarrera (si vous avez configuré ce comportement), et le test réussira.

Pour en savoir plus sur les tests et les fonctionnalités, tels que l'injection de champs ou le remplacement de liaisons dans les tests, consultez la documentation.

Dans cette section de l'atelier de programmation, vous apprendrez à utiliser l'annotation @EntryPoint qui permet d'injecter des dépendances dans des classes non compatibles avec Hilt.

Comme nous l'avons vu précédemment, Hilt est compatible avec les composants Android les plus courants. Toutefois, vous devrez peut-être effectuer une injection de champs dans des classes qui ne sont pas directement compatibles avec Hilt ou ne peuvent pas utiliser cette bibliothèque.

Dans ce cas, vous pouvez utiliser @EntryPoint. On désigne sous le nom de point d'entrée l'emplacement où vous pouvez récupérer des objets fournis par Hilt à partir de code ne pouvant pas utiliser cet outil pour injecter ses dépendances. Il s'agit du point au niveau duquel le code pénètre dans les conteneurs gérés par Hilt.

Cas d'utilisation

Notre objectif est de pouvoir exporter nos journaux en dehors du processus d'application. Pour cela, nous devons utiliser un ContentProvider. Nous autorisons uniquement les consommateurs à interroger un journal spécifique (avec un id) ou tous les journaux de l'application à l'aide d'un ContentProvider. Nous utiliserons la base de données Room pour récupérer les données. Par conséquent, la classe LogDao doit exposer les méthodes qui renvoient les informations requises à l'aide d'une base de données Cursor. Ouvrez le fichier LogDao.kt et ajoutez les méthodes suivantes à l'interface.

@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?
}

Nous devons ensuite créer une classe ContentProvider et remplacer la méthode query pour qu'un Cursor soit renvoyé avec les journaux. Créez un fichier nommé LogsContentProvider.kt dans un nouveau répertoire contentprovider avec le contenu suivant :

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")
    }
}

Comme vous pouvez le constater, l'appel getLogDao(appContext) n'est pas compilé. Nous devons l'implémenter en extrayant la dépendance LogDao du conteneur d'applications Hilt. Par défaut cependant, Hilt ne prend pas en charge l'injection vers ContentProvider avec @AndroidEntryPoint, comme c'est le cas, par exemple, avec "Activity".

Pour y accéder, nous devons créer une interface annotée avec @EntryPoint.

@EntryPoint en action

Un point d'entrée est une interface comprenant une méthode d'accesseur pour chaque type de liaison (y compris son qualificatif). L'interface doit, en outre, être annotée avec @InstallIn pour spécifier le composant dans lequel installer le point d'entrée.

Il est recommandé d'ajouter la nouvelle interface de point d'entrée à l'intérieur de la classe qui l'utilise. Vous allez donc inclure l'interface dans le fichier LogsContentProvider.kt :

class LogsContentProvider: ContentProvider() {

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

    ...
}

Notez que l'interface est annotée avec @EntryPoint et qu'elle est installée dans ApplicationComponent, car nous voulons que la dépendance provienne d'une instance du conteneur Application. Dans l'interface, les méthodes sont exposées pour les liaisons auxquelles nous souhaitons accéder (LogDao dans le cas présent).

Pour accéder à un point d'entrée, utilisez la méthode statique appropriée depuis EntryPointAccessors. Le paramètre doit correspondre soit à l'instance du composant, soit à l'objet @AndroidEntryPoint qui fait office de conteneur de composants. Assurez-vous que le composant transmis en tant que paramètre et la méthode statique EntryPointAccessors correspondent tous deux à la classe Android dans l'annotation @InstallIn sur l'interface @EntryPoint :

Nous pouvons maintenant implémenter la méthode getLogDao qui ne figure pas dans le code ci-dessus. Nous allons utiliser l'interface de point d'entrée définie ci-dessus dans notre classe LogsContentProviderEntryPoint :

class LogsContentProvider: ContentProvider() {
    ...

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

Notez la méthode utilisée pour transmettre applicationContext à la méthode statique EntryPoints.get et la classe de l'interface qui est annotée avec @EntryPoint.

Vous maîtrisez maintenant Hilt et vous devriez être en mesure d'ajouter cet outil à votre application Android. Les sujets suivants ont été traités au cours de cet atelier de programmation :

  • Configurer Hilt dans votre classe "Application" à l'aide de @HiltAndroidApp.
  • Ajouter des conteneurs de dépendances aux différents composants du cycle de vie Android à l'aide de @AndroidEntryPoint.
  • Utiliser des modules pour indiquer à Hilt comment fournir certains types.
  • Utiliser des qualificatifs pour fournir plusieurs liaisons pour certains types.
  • Tester votre application à l'aide de Hilt.
  • Cas d'utilisation de l'annotation @EntryPoint.