Android DI : De Dagger vers Koin

L’injection de dépendances (DI) est un pattern dans lequel vous laissez une tierce partie (ie, un framework) fournir les implémentations. Cela présente de grands avantages :
réduction du couplage dans vos apps
facilitation du testing
cela force une meilleure organisation et structuration du code, en éliminant du boilerplate et en créant une couche DI

Il n’y a pas de méthode officielle pour implémenter ce pattern sur Android. Pourtant, Google tend à favoriser un de leur frameworks : Dagger. Malgré une grande versatilité et une grande efficience, la courbe d’apprentissage de cet outil est plutôt élevée. De plus, il requiert la création de nombreux fichiers, même pour un petit projet.

Le but de cet article est de vous présenter un plus petit, plus simple mais puissant framework de DI : Koin. On ira plus loin en faisant le refactoring d’un projet Android développé avec :
Plusieurs Android Architecture Components
Dagger 2

Le projet

Coinsentinel est une application Android hébergée sur GitHub ici. Elle affiche la valeur courante et le rang des principales cryptomonnaies. Pour avoir des informations à jour, elle interroge l’API libre d’accès de coinmarket. Elle fonctionne aussi hors ligne pour assurer une expérience utilisateur optimale, peu importe si elle a un accès internet ou non. L’interface est simple : c’est une liste triée de cryptomonnaies. Un swipe haut-bas déclenche une tentative de rafraîchissement des données.

Voici à quoi elle ressemble :

coinsentinel

Dagger

On utilise Dagger pour injecter :

  • Une instance de Gson instance, pour désérialiser les réponses http
  • Un deserializer métier, qui map le JSON vers un POKO (Plain Old Kotlin Object)
  • Une représentation de notre base de données SQLite
  • L’unique DAO utilisé dans cette base de code

Avant d’aller plus loin, regardons de plus près comment Dagger construit son arbre de dépendances :

dagger-injection

source

Comme vous l’avez peut-être déjà deviné, l’arbre est construit en déclarant des composants et des modules. Typiquement, un composant est composé de plusieurs modules. Comme la documentation l’indique : “Dagger n’utilise pas de réflection ni de génération de code au runtime, mais fait son analyse à la compilation, et génère du code source Java.”. Cela signifie que vous allez avoir beaucoup de fichiers qui seront générés lors de la compilation, dans le but de générer le graph de dépendances. Cela est supposé permettre au build de passer à l’échelle lorsque l’application grossit MAIS, cela se fait au prix d’un certain overhead, même pour de petites applications.

Bien qu’étant plutôt simple, Coinsentinel a 3 fichiers qui sont uniquement dédiés à la DI :

  • AppComponent.kt
  • AppModule.kt
  • MainActivityModule.kt

Je pense que la syntaxe de Dagger n’est pas parfaite dans une app native Kotlin (utilisation d’annotations là où en Kotlin on utilisera plutôt des DSL par exemple). C’est tout simplement parce que ce framework est écrit en Java. Pour résumer, Dagger est très versatile, mais nous forme à beaucoup développer même si le projet est petit. Cela donne l’impression de faire de l’over-engineering.

Koin

koin-2.0

source

C’est là qu’intervient Koin. C’est “un framework de DI léger et pragmatique pour Kotlin https://insert-koin.io/”. En bonus : il est 100% “Écrit en pur Kotlin, en utilisant seulement de la résolution fonctionnelle : pas de proxy, pas de génération de code, pas de réflexion.”.
En fait, c’est “juste” un DSL qui fournit une syntaxe agréable et concise. Bien sûr, cela contribue à développer dans les règles de l’art de Kotlin. L’outil s’intègre aussi vraiment bien avec les Android Architecture components et les librairies AndroidX.

Refactoring

Etape 0 : Mise à jour de la version de androidX appcompat

implementation 'androidx.appcompat:appcompat:1.1.0-rc01'

Cette nouvelle version fait qu’AppCompatActivity implémente l’interface LifecycleOwner. Celle-ci est nécessaire quand on développe avec les architecture components.

Étape 1 : dépendances gradle de Koin

Nous allons utiliser Koin 2. Cette dernière version qui vient de sortir est riche en fonctionnalités et activement maintenue. Dans app/build.gradle, ajoutez les lignes suivantes pour importer Koin :

// Koin
def koin_version = '2.0.1'
// Koin for Kotlin
implementation "org.koin:koin-core:$koin_version"
// Koin for Unit tests
testImplementation "org.koin:koin-test:$koin_version"
// AndroidX (based on koin-android)
// Koin AndroidX Scope feature
implementation "org.koin:koin-androidx-scope:$koin_version"
// Koin AndroidX ViewModel feature
implementation "org.koin:koin-androidx-viewmodel:$koin_version"

Ensuite, supprimez celles relatives à Dagger :

// Dagger
def dagger_version = "2.16"
implementation "com.google.dagger:dagger:$dagger_version"
implementation "com.google.dagger:dagger-android:$dagger_version"
implementation "com.google.dagger:dagger-android-support:$dagger_version"
kapt "com.google.dagger:dagger-compiler:$dagger_version"
kapt "com.google.dagger:dagger-android-processor:$dagger_version"

Maintenant vous avez tout ce qu’il vous faut pour utiliser Koin.

Étape 2 : Refactoring du package DI

Tout d’abord, supprimez AppComponent et MainActivityModule puisque nous allons factoriser le code de AppModule. Puis, remplacez le code de AppModule par :


package fr.ippon.androidaacsample.coinsentinel.di

import androidx.room.Room
import com.google.gson.GsonBuilder
import fr.ippon.androidaacsample.coinsentinel.api.CoinResultDeserializer
import fr.ippon.androidaacsample.coinsentinel.api.CoinTypeAdapter
import fr.ippon.androidaacsample.coinsentinel.db.AppDatabase
import fr.ippon.androidaacsample.coinsentinel.db.Coin
import org.koin.dsl.module
import java.lang.reflect.Modifier

private const val DATABASE_NAME = "COIN_DB"

val appModule = module {
   single {
       GsonBuilder()
           .excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC)
           .serializeNulls()
           .registerTypeAdapter(Coin::class.java, CoinTypeAdapter())
           .excludeFieldsWithoutExposeAnnotation()
           .create()
   }
   single { CoinResultDeserializer(get()) }
   single {
       Room.databaseBuilder(get(), AppDatabase::class.java, DATABASE_NAME)
           .build()
   }
   single {
       get<AppDatabase>().coinDao()
   }
   single { CoinRepository(get(), get()) }
   viewModel { CoinViewModel(get()) }
}

Ici :

  • Nous déclarons un unique module appModule qui est en read only
  • Nous remplaçons les méthodes annotées avec @Singleton par les déclarations Kotlin DSL équivalentes single.
  • Le get() résout la dépendance d’un composant. Nous pouvons aussi l’utiliser pour récupérer la dépendance et appeler une méthode sur celle-ci, comme nous l’avons fait pour get().coinDao()
  • Nous avons ajouté CoinRepository à la définition du module, puisqu’il était précédemment injecté
  • Koin embarque une notation viewModel pour définir notre ViewModel. Comme vous pouvez vous en douter, cette librairie simplifie l’utilisation de cet architecture component. Nous verrons pourquoi dans une prochaine section.

Koin nous permet aussi de définir des factory beans, submodules, bindings etc. Vous pouvez trouver plus de documentation (ici)[https://insert-koin.io/docs/2.0/quick-references/modules-definitions/].

Étape 3 : Refactoring de la classe Application

Ici encore, c’est assez rapide : nous enlevons le boilerplate et le remplaçons avec une simple déclaration Kotlin DSL. Dans CoinSentinelApp, supprimez la variable dispatching android injector :

@Inject
lateinit var dispatchingAndroidActivityInjector: DispatchingAndroidInjector<Activity>

Ensuite, supprimez l’implémentation de l’interface HasActivityInjector :

class CoinSentinelApp : Application(), HasActivityInjector {

Supprimez aussi la fonction qui lui est liée :

override fun activityInjector(): DispatchingAndroidInjector<Activity> {
   return dispatchingAndroidActivityInjector
}

Finalement, dans onCreate, changez l’initialisation de la DI initialization en remplaçant :

DaggerAppComponent.builder()
       .application(this)
       .build()
       .inject(this)

par

startKoin {
   androidContext(this@CoinSentinelApp)
   modules(appModule)
}

C’est aussi possible de configurer des loggers, des android contexts, des propriétés etc. Plus de documentatoon ici.

Étape 4 : Refactoring du ViewModel et du Repository

Dans cette étape rapide, vous avez juste à supprimez les annotations @Singleton et @Inject dans les fichiers CoinViewModel et CoinRepository.

Étape 5 : Refactoring de la MainActivity

Dernière étape du refactoring. Remplacez dans la MainActivity

@Inject
lateinit var coinViewModel: CoinViewModel

par

private val coinViewModel: CoinViewModel by viewModel()

Vous devez aussi supprimer la référence à AndroidInjection dans onCreate() :

AndroidInjection.inject(this)

Et… c’est fini. Vous pouvez voir une notation intéressante : by viewModel(). C’est un mot clé DSL qui permet d’injecter une instance de ViewModel (déclarée dans notre module). Si ce ViewModel a besoin d’être partagé entre plusieurs composants, utilisez juste à la place by sharedViewModel().

De la même manière, si nous avions voulu injecter le serializer ou le repository, nous aurions utilisé by inject(). Pour plus d’explications, je vous invite à consulter la documentation.

Il y a un autre avantage à utiliser Koin : on n’a plus besoin d’utiliser les déclarations lateinit var. À la place, on peut déclarer des private val. Cela veut dire que nos variables injectées :

  • sont readonly (var vers val)
  • ont une meilleure visibilité, plus liée à leur contexte (private)
  • ne vont pas causer de lateinit exceptions. Le mot clé lateinit indique au compilateur que la variable va être nulle à son initialisation, mais ne le sera plus une fois qu’on tentera d’y accéder. Si on y accède alors qu’elle n’est pas initialisée, on obtient une exception runtime de ce genre :
kotlin.UninitializedPropertyAccessException: lateinit property X has not been initialized

C’est vraiment douloureux à débugger dans un contexte Android. Avec Dagger, on a eu plusieurs problèmes pendant du “monkey testing”, i.e., taper partout dans l’application. En interne, ce genre de tests créait intensivement des fragments. Parfois, de manière imprédictible, les variables n’étaient pas injectées suffisamment tôt, ce qui faisait tomber l’application.

[Non reliée à Dagger ni à Koin]
C’est aussi pourquoi, si vous regardez le refactoring sur GitHub, vous verrez qu’une autre variable a été changée. J’ai enlevé l’initialisation de coinAdapter de la fonction init(). À la place, la variable est initialisée by lazy :

De

private lateinit var coinAdapter: CoinAdapter

vers

private val coinAdapter: CoinAdapter by lazy {
  CoinAdapter(coins, this)
}

L’initialisation by lazy indique que l’objet va être créé quand coinAdapter sera accédé pour la première fois. Grâce à cela :

  • nous pouvons utiliser val à la place de var
  • c’est plus efficace puisqu’aucune mémoire ne sera consommée tant que nous n’utilisons pas coinAdapter.

Conclusion

Koin est vraiment une librairie géniale à utiliser. Elle simplifie beaucoup la gestion des dépendances et offre de nombreuses fonctionnalités. D’ailleurs, certaines sont dédiées à Android et aux architecture components, pour vous aider d’autant plus dans votre développement. J’ai volontairement omis de parler de certains de ses concepts comme les scopes, ou son utilisation dans le framework web officiel de JetBrain’s, Ktor. C’est pour ça que je vous recommande de consulter le repo github officiel et le site (plus bas dans les sources).

Sources