MAD Skills series: Hilt under the hood
If you prefer to consume this content in a video format, check it out here:
- How the various Hilt annotations work together to generate code.
- How the Hilt Gradle Plugin works behind the scenes to improve the overall experience when using Hilt with Gradle.
How the various Hilt annotations work together to generate code
Hilt uses annotation processors to generate code. Annotation processing occurs within the compiler when converting your source files into java bytecode. As the name suggests, annotation processors are triggered on the annotations within your source files. An annotation processor typically inspects the annotation and types to perform various tasks, like validation or generating new sources.
AndroidEntryPoint enables field injection in your Android classes such as Activities, Fragments, Views, and Services.
In the following example, simply annotating
AndroidEntryPoint allows us to inject the MusicPlayer into our activity.
If you’re using Gradle, you’re probably familiar with the simplified syntax shown above. However, this syntax is actually just syntactic sugar provided to you by the Hilt Gradle plugin. More about the Hilt Gradle plugin below, but for now let’s look at how this example would look without the syntactic sugar.
Now we see that the original base class,
AppCompatActivity, is really an input to the
AndroidEntryPoint annotation. The
PlayActivity, itself, actually extends the generated class
Hilt_PlayActivity. This class is generated by Hilt’s annotation processors, and contains all of the logic necessary to perform injection. Here’s a simplified example of the code in that generated base class:
In this example, the generated class extends
AppCompatActivity. However, in general it will extend whichever class was passed into the
AndroidEntryPoint annotation. This allows injection to work with any base class you want.
The main purpose of this generated class is to handle injection. It’s important to perform injection as early as possible to prevent accidental access to a field before it’s been injected. Thus, for activities, injection occurs during the
In the inject method, we first need an instance of its injector,
PlayActivity_Injector. In Hilt, the injector for an activity is an entry point, and we can get an instance of the injector using the
EntryPoints utility class.
As you may have guessed,
PlayActivity_Injector is also generated by Hilt’s annotation processors. It will have the following format:
The generated injector is a Hilt entry point that is installed into the
ActivityComponent. It contains a single method that allows us to inject an instance of the
PlayActivity. If you’ve ever used Dagger in an Android application without Hilt, you’re likely familiar with writing these inject methods directly on the component.
InstallIn, a module or entry point can be contributed from anywhere within the transitive dependencies of your application. However, at some point we’ll need to collect all of the
InstallIn contributions to get the full set of modules and entry points for each component.
Hilt generates a metadata annotation in a fixed package to make collecting and discovering these
InstallIn contributions easier. Generated annotations will have the following format:
By putting the metadata into a fixed package, Hilt’s processors can easily find the metadata generated in all transitive dependencies of your application. From there, we can use the information contained in the metadata annotation to find a reference to the
InstallIn contribution itself — in this case, the
HiltAndroidApp annotation enables injection in your Android application class. In this regard, you can think of it exactly like the
AndroidEntryPoint annotation. To start, a user just needs to annotate their application class with
HiltAndroidApp has one other important function — generating Dagger components.
When the Hilt annotation processor encounters
@HiltAndroidApp, it generates a set of components inside of a wrapper class that has the same name as the application class, prefixed with
HiltComponents_. If you’ve ever used Dagger before, these components are the
@Subcomponent annotated classes that you normally would write by hand.
To generate these components, Hilt looks in the metadata package described above to find all of the
@InstallIn-annotated classes. The
@InstallIn modules are placed in the
modules list of the corresponding component declaration. The
@InstallIn entry points are placed as super types of the corresponding component declaration.
From here, the Dagger processor takes over and generates the component implementation from the
@Subcomponent annotations. If you’ve ever used Dagger without Hilt, you’ve likely interacted with these classes directly. However, Hilt hides this complexity from the user.
As this blog post is about Hilt, we won’t go into further details about the Dagger generated code. However, if you’re interested, you can check out this presentation by Ron Shapiro and David Baker and they’ll walk you through the details. In addition, you can check out the cheat sheet for Dagger codegen 101.
Hilt Gradle plugin
Now that you’ve seen how code generation works in Hilt, let’s now take a look at the Hilt Gradle plugin. The Hilt Gradle plugin performs a lot of useful tasks, including bytecode rewriting and classpath aggregation.
As the name suggests, bytecode rewriting is the process of rewriting bytecode. Unlike annotation processing, which can only generate new code, bytecode rewriting can rewrite existing code. When used sparingly, this feature can be very powerful.
To see why we use bytecode rewriting in Hilt, let’s return to
While extending the
Hilt_PlayActivity base class works in practice, it can cause issues with your IDE. Since the generated class does not exist until after you’ve successfully compiled your code, you’ll often see red squiggles in your IDE. In addition, you won’t have access to auto-complete for things like overriding methods, and you won’t be able to access methods from the base class.
Not only can the loss of these features slow down your coding velocity, but all of these red squiggles can also make it extremely difficult to focus.
The Hilt Android plugin comes to the rescue by enabling bytecode rewriting on your
AndroidEntryPoint classes. With the Hilt Android plugin enabled, all that’s required is to annotate your class with
@AndroidEntryPoint and you can extend your normal base class.
Since this syntax no longer references the generated base class, there are no issues with the IDE. During bytecode rewriting, the Hilt Gradle plugin will swap your base class with the generated
Hilt_PlayActivity. Since this process happens directly in the bytecode, it’s not visible to the user.
However, there are some disadvantages to bytecode rewriting.
- The plugin must modify low-level bytecode, rather than source code. This is somewhat error prone.
- Because the bytecode has already been compiled by the time rewriting occurs, any issues generally show up at runtime rather than compile time.
- Rewriting complicates debugging, since when something does go wrong the source files may not represent the bytecode that’s being executed.
For these reasons, Hilt tries to rely on bytecode rewriting as little as possible.
Finally, let’s look at another useful feature of the Hilt Gradle plugin: classpath aggregation. To understand what classpath aggregation is and why it’s needed, let’s look at another example.
In this example,
:app depends on a single Gradle module,
:database, where both
As we’ve already seen, Hilt will generate metadata into the fixed
hilt_metadata package which will be used to find all
@InstallIn-annotated modules when generating the component.
While this works fine for a single level of dependencies, let’s see what happens when we add another Gradle module,
:cache, as a dependency of
:cache is compiled, although it will generate metadata, that metadata will not be available when compiling
:app because it is a transitive dependency. Thus, Hilt has no way of knowing about the
CacheModule and it will be accidentally excluded from the generated component.
While you could technically fix this issue by declaring the
:cache dependency as
api rather than
implementation, it’s not recommended. Not only is using
api worse for incremental builds, it’s a nightmare to maintain.
This is where the Hilt Gradle plugin comes to the rescue.
The Hilt Gradle plugin automatically aggregates all classes from the transitive dependencies of
:app, even if
implementation is used.
In addition, the Hilt Gradle plugin also has a number of benefits over using
First, classpath aggregation is less error-prone and requires no maintenance compared to manually using
api for dependencies throughout your app. You can simply use
implementation as you normally would, and the Hilt Gradle plugin will take care of the rest.
Second, the Hilt Gradle plugin only aggregates classes at the application level, so unlike when using
api, compilation of the libraries in your project are not affected.
Finally, classpath aggregation provides better encapsulation of your dependencies because it’s impossible to accidentally reference these classes in your source files, and they won’t show up as suggestions in code-completion.
In this episode, we’ve uncovered how the various Hilt annotations work together to generate code. We also looked at the Hilt Gradle plugin and saw how it works behind the scenes using bytecode rewriting and classpath aggregation to make using Hilt safer and easier.
Thanks for reading, and keep an eye out for more MAD skills episodes to come!