Hilt: Googles neue JetPack-Library für Dependency Injection

Dagger ist aktuell der Quasi-Standard für Dependency Injection in Android. Dennoch gibt es viele Entwickler, die unzufrieden sind mit der Menge an Boilerplate-Code und der Lernkurve, die Dagger 2 mit sich bringt, denn es ist schwierig und langwierig, Dagger gut genug zu verstehen, um es richtig verwenden zu können.

Googles neue Bibliothek “Hilt” widmet sich nun diesen Problemen und bietet Android-Entwicklern eine vorkonfigurierte Dagger-Umgebung, die gut an die Bedürfnisse einer Android-App angepasst ist.

Einleitung: was ist Dependency Injection?

Doch zunächst ein paar Worte zur Motivation: Was ist Dependency Injection eigentlich und welche Vorteile bringt diese Technik? Dependency Injection ermöglicht es, bestimmte Klassen, die in einer App an verschiedenen Stellen Verwendung finden, auszutauschen und damit ihre Funktion zu erweitern oder zu verändern. Das ist insbesondere nützlich für Tests, in denen manche Funktionen weggemockt werden sollen, oder ein Entwickler verifizieren möchte, dass bestimmte Funktionen aufgerufen werden. Es kann allerdings auch wichtig sein, um Unterschiede in verschiedenen Build Types oder Build Flavors abzubilden. Dependency Injection adressiert dabei ein Problem, das es so nur in objektorientierten Sprachen gibt: Wie gelangen Instanzen von Klassen eigentlich an die Stellen, an denen sie benötigt werden und wo sollten diese Instanzen erzeugt werden?

Versucht man dieses Problem ohne Dependency Injection (oder verwandte Techniken, z.B. das Service-Locator Pattern) anzugehen, dann gerät man in großen Applikationen schnell in die Lage, dass man Instanzen von Klassen über mehrere Schichten hinweg herumreicht, nur um sie an einer bestimmten Stelle zur Verfügung zu haben. Die Klassen, die eine solche Instanz weiterreichen, benötigen diese Instanz oft gar nicht und es erscheint daher unnötig, dass sie die Instanz überhaupt zu Gesicht bekommen. Muss man viele solcher Instanzen herumreichen, bläht man damit die Signaturen von Methoden auf, was zu unnötigem Coupling führt.

Dependency Injection ist eine Technik, die es ermöglicht, besagte Instanzen gezielt an die Orte zu “transportieren”, wo sie benötigt werden. Dort werden sie automatisch eingesetzt, entweder indem Felder der abhängigen Klasse befüllt werden (Field Injection), oder Setter-Methoden für diese Felder aufgerufen werden (Setter Injection aka Method Injection), oder aber, indem sie schon als Konstruktor-Parameter übergeben werden (Constructor Injection). Letzteres hat den Vorteil, dass man in Unit Tests gar keine Injection mehr benötigt, man kann einfach den Konstruktor direkt mit Mocks aufrufen. Außerdem stehen in der abhängigen Klasse die Abhängigkeiten direkt zur Verfügung, was unter Umständen den Code vereinfachen kann und NullpointerExceptions vermeidet.

Abhängigkeiten bereitstellen und aufräumen mit Hilt

Leider ist Constructor Injection in Android nicht überall möglich, da die Android Plattform gewisse Klassen (insbesondere Activities, Fragments, Views, Services und Broadcast Receivers) automatisch erzeugt. Diese Aufgabe kann nicht ohne weiteres an das Dependency Injection Framework abgegeben werden. Das Problem ist Google bekannt; für Views lässt es sich durch eine eigene LayoutInflatorFactory umgehen. Für Activities und Fragments bietet die AppComponentFactory eine Lösung, die aber erst ab Android 9.0 Pie zur Verfügung steht.

Um Abhängigkeiten in solchen Klassen bereit zu stellen, bietet Hilt die Annotation @AndroidEntryPoint. Versieht man beispielsweise eine Activity mit dieser Annotation, so generiert Hilt automatisch Source Code für eine Superklasse, die zur Laufzeit verwendet wird und in ihrer onCreate Methode alle Felder befüllt, die eine @Inject Annotation besitzen. Dies passiert komplett über generierte Methoden; Reflection kommt dabei nicht zum Einsatz. Das wirkt sich positiv auf die Performance zur Laufzeit aus.

Um nicht mehr benötigte Abhängigkeiten auch wieder aufräumen zu können, definiert Hilt sogenannte Scopes, die an den Lifecycle unterschiedlicher Objekte zur Laufzeit geknüpft sind. Auf erster Ebene gibt es beispielsweise den Singleton Scope, der durch den ganzen Lebenszyklus des Application Objektes hindurch bestehen bleibt und nicht abgeräumt wird. Der Activity Scope hingegen wird automatisch abgeräumt, sobald die zugehörige Activity das Ende ihres Lebenszyklus erreicht. Abhängigkeiten, die durch Constructor Injection erzeugt werden, können durch Annotationen an einen dieser Scopes gebunden werden. Das vermeidet Memory Leaks.

Jetzt möglich: Injection OHNE Module und Komponenten

Bei Dependency Injection werden Abhängigkeiten üblicherweise über ihren Typ identifiziert. In Dagger können sie allerdings auch durch zusätzliche Qualifier-Annotationen differenziert werden, falls mehrere Abhängigkeiten desselben Typs unterschieden werden müssen. Eine solche Abhängigkeit muss jedoch zwangsläufig in einem eigenen Dagger Modul zur Verfügung gestellt werden. Ebenso verhält es sich mit Abhängigkeiten, deren Typ ein Interface oder eine abstrakte Klasse ist. Dagger kann in diesem Fall nicht wissen, welche konkrete Klasse es zur Erfüllung dieser Abhängigkeit instanziieren müsste, daher ist eine Provider Methode in einem Dagger-Modul zwingend erforderlich. Wenn man jedoch alle Abhängigkeiten innerhalb der App in Form von konkreten instanzierbaren Klassen definiert, kann man mit Hilt im Prinzip eine Konfiguration erstellen, die komplett ohne Dagger-Module auskommt. Man benötigt dann nur noch die bekannten @Inject und @Singleton Annotationen, sowie die Hilt-spezifischen Annotationen zur Definition des Scopes (z.B. @ActivityContext für den Activity Scope) oder die Markierung von Klassen, die über Field Injection befüllt werden mittels @AndroidEntryPoint.
Somit ist der große Kritikpunk “zu viel Boilerplate", der oft an Dagger geübt wird, hinfällig.

Injection in automatisierten Tests

Hilt bietet eigene Test-Libraries an, mit denen Abhängigkeiten leicht durch Mocks ersetzt werden können. Da Hilt es nicht erlaubt, in Modulen definierte Provider zu überschreiben, müssen dafür unter Umständen Module über die Annotation @UninstallModules entfernt werden. Dies wirkt auf den ersten Blick vielleicht umständlich, hat jedoch den großen Vorteil, dass Abhängigkeiten nicht zur Laufzeit, sondern nur zur Kompilierzeit ausgetauscht werden können. Andere Dependency Injection Frameworks wie beispielsweise Koin haben diesen Schutzmechanismus nicht. Dort kann es aber unserer Erfahrung nach vorkommen, dass zur Laufzeit mehrere verschiedene Implementierungen derselben Abhängigkeit verwendet werden, was unerwünscht ist und die Fehlersuche extrem erschweren kann. Verzichtet man, wie oben skizziert, weitestgehend auf Module zur Definition der konkreten Implementierungen, so benötigt man auch keine @UninstallModules Annotationen in seinen Tests.

Nachteile von Hilt

Hilt ist aktuell nur als alpha-Version verfügbar, in unserem Experiment hat sich aber gezeigt, dass es schon recht stabil ist. Es gab ein paar kleinere Probleme mit der Verwendung von @Inject Annotationen in Broadcast Receivern. Hierfür gibt es jedoch einen Workaround. In Unit Tests und UI Tests ist man dazu angehalten, eine junit4 Rule zu verwenden um Abhängigkeiten zu erzeugen. Außerdem sollte man eine eigene Application-Klasse verwenden. Beides ist etwas lästig und wirkt noch nicht ganz ausgegoren. Leider können unit tests, die Hilt verwenden aktuell noch nicht direkt aus Android Studio heraus gestartet werden, sondern nur über die Kommandozeile durch Aufruf des entsprechenden Gradle-Befehls. Dieses Problem ist bekannt und Google arbeitet an einer Lösung. Wir haben in unserem Experiment einen Workaround gefunden, bei dem wir von Hilt instrumentierte Klassen über eine Gradle-Konfiguration in unserem build-Verzeichnis herumkopieren mussten. Dieser Ansatz funktionierte soweit zuverlässig, wirkte aber auf uns sehr unsauber.

Das größte Problem stellten für uns jedoch die langen Build-Zeiten dar, die Dagger ohnehin mit sich bringt und die durch Hilt noch einmal verschlechtert werden. Code-Generierung durch Annotation Processing ist einfach ein sehr CPU-intensives Unterfangen. In unserem mittelgroßen Projekt mit etwa 150.000 Zeilen Code, das Java und Kotlin verwendet, wurden hierdurch teilweise ein bis zwei Minuten Overhead erzeugt. Im Allgemeinen scheint das Annotation Processing mittels kapt, das wir für unsere Kotlinklassen einsetzen mussten, sehr zeitinintensiv zu sein.

Doch Reflection?

Letztendlich haben wir uns trotz der durchweg positiven Auswirkungen von Hilt auf unsere Codebasis dazu entschlossen, es nicht weiter einzusetzen. Wir haben stattdessen ein eigenes schlankes Dependency Injection Framework entwickelt, das im Wesentlichen aus zwei Klassen (zusammen etwa 500 Zeilen Code) und einigen eigenen Annotationen besteht. Dieses Framework erfüllt alle unsere Anforderungen und die Konfiguration ist im Grunde genauso schlank wie die von Hilt. Es basiert allerdings auf Reflection. Wir sind uns darüber im Klaren, dass die Android Community schon vor geraumer Zeit den Einsatz von Reflection für Dependency Injection Frameworks aus Performancegründen zur Laufzeit verworfen hat. Wir konnten allerdings auf einigermaßen zeitgemäßen Endgeräten keine Performanceprobleme feststellen, die diese Entscheidung wirklich rechtfertigen würden. Das Auffinden von annotierten Feldern und Methoden mittels Reflection kostet zugegebenermaßen etwas Zeit. Durch geschicktes Caching kann man jedoch verhindern, dass das Laufzeitverhalten zu sehr darunter leidet. Man sollte sich auch vor Augen halten, dass in Android jeder View, jede Activity, jedes Fragment, jeder Broadcast Receiver und jeder Service über Reflection erzeugt werden.

Fazit

Unser Fazit zu Hilt lautet daher: Gute Library, die genau an den Schmerzpunkten von Dagger 2 ansetzt. Kann für kleinere und mittlere Projekte wahrscheinlich bald bedenkenlos eingesetzt werden. Bei großen Projekten ist der Performance-Overhead doch gewaltig und man sollte sich überlegen, ob man mit einer anderen Lösung nicht besser fährt. Ist im Projekt primär Kotlin im Einsatz, stellen neuere Libraries wie Koin oder Kodein eine gute Alternative dar, auch wenn diese eigentlich keine Dependency Injection zur Verfügung stellen, sondern eher dem Service Locator Pattern folgen. Ein Nachteil aller Ansätze, bei denen der Abhängigkeitsgraph erst zur Laufzeit aufgebaut wird, ist, dass Probleme in der Abhängigkeitsstruktur (z.B. zyklische oder fehlende Abhängigkeiten) auch erst zur Laufzeit auffallen und dort zu Abstürzen führen können. Solche Risiken lassen sich durch eine gute Abdeckung in der Testautomatisierung kontrollieren.