This is the third article in the MAD Skills series on Hilt. We will delve into how Hilt works. For the first two articles in this series, see:
- Hilt is introduced
- Hilt testing best practices
If you prefer to see this in video, check it out here.
Topics involved
- The way multiple Hilt annotations work together and generate code.
- How the Hilt Gradle plugin works behind the scenes to improve the overall experience when Hilt is used with Gradle.
The way multiple Hilt annotations work together and generate code
Hilt uses an annotation handler to generate code. Processing of annotations occurs during the compiler’s conversion of the source file to Java bytecode. As the name implies, annotation handlers act on annotations in source files. Annotation processors typically examine annotations and perform different tasks, such as code checking or generating new files, depending on the annotation type.
In Hilt, the three most important annotations are @AndroidEntryPoint, @InstallIn, and @HiltAndroidApp.
@AndroidEntryPoint
AndroidEntryPoint enables field injection in your Android classes, such as activities, Fragments, Views, and Services.
As shown in the following example, MusicPlayer can be easily injected into our Activity by adding the AndroidEntryPoint annotation to our PlayActivity.
@AndroidEntryPoint
class PlayActivity : AppCompatActivity() {
@Inject lateinit var player: MusicPlayer
// ...
}
Copy the code
If you use Gradle, you are probably familiar with the simplified syntax described above. But this is not real syntax, but syntax sugar provided by the Hilt Gradle plug-in. Before we talk more about the Gradle plugin, let’s take a look at what this example would look like without syntactic sugar.
@AndroidEntryPoint(AppCompatActivity::class)
class PlayActivity : Hilt_PlayActivity() {
@Inject lateinit var player: MusicPlayer
// ...
}
Copy the code
Now we see that the primitive AppCompatActivity base class is the actual input parameter to the AndroidEntryPoint annotation. PlayActivity actually inherits the generated class Hilt_PlayActivity, which is generated by the Hilt annotation handler and contains all the logic needed to perform the injection operation. An example of code simplification for the base class generated for the above is as follows:
@Generated("dagger.hilt.AndroidEntryPointProcessor")
class Hilt_PlayActivity : AppCompatActivity {
override fun onCreate(a) {
inject()
super.onCreate()
}
private fun inject(a) {
EntryPoints.get(this, PlayActivity_Injector::class).inject(this asPlayActivity); }}Copy the code
In the example, the generated class inherits from AppCompatActivity. However, it is common for the generated classes to inherit the classes passed into AndroidEntryPoint annotations. This allows injection operations to be performed in any base class you need.
The main purpose of generating classes is to handle injection operations. To avoid fields being accidentally accessed before injection, it is necessary to perform injection as early as possible. Therefore, for an Activity, the injection operation is performed in onCreate.
In the Inject method, we first need an instance of the injector — PlayActivity_Injector. In Hilt, an injector is an entry point for an Activity, and we can use the EntryPoints utility class to obtain an instance of the injector.
As you might expect, PlayActivity_Injector was also generated by the Hilt annotation processor. The format is as follows:
@Generated("dagger.hilt.AndroidEntryPointProcessor")
@EntryPoint
@InstallIn(ActivityComponent::class)
interface PlayActivity_Injector {
fun inject(activity: PlayActivity)
}
Copy the code
The generated injector is a Hilt entry point loaded into the ActivityComponent. It just contains a method that lets us inject the PlayActivity instance. If you have ever used Dagger in an Android application (without Hilt), you may be familiar with these injection methods written directly on the component.
@InstallIn
InstallIn is used to indicate which component a module or entry point should be loaded into. In the following example, we load MusicDataBaseModule into SingletonComponent:
@Module
@InstallIn(SingletonComponent::class)
object MusicDatabaseModule {
// ...
}
Copy the code
With InstallIn, modules and entry points can be provided within any delivery dependency in your application. However, in some cases we need to collect all the content provided by InstallIn annotations to capture the complete modules and entry points for each component.
Hilt generates metadata annotations under specific packages to make it easier to collect and discover the content provided by InstallIn annotations. The generated annotation format is as follows:
package hilt_metadata
@Generated("dagger.hilt.InstallInProcessor")
@Metadata(my.database.MusicDatabaseModule::class)
class MusicDatabaseModule_Metadata {}
Copy the code
By putting the metadata into a specific package, the Hilt annotation processor can easily find the generated metadata among all the delivery dependencies in your application. At this point, we can use the information contained in the metadata annotations to find our own references to the content provided by the InstallIn annotations. In this case, MusicDatabaseModule.
HiltAndroidApp
Finally, HiltAndroidApp annotations enable injection for your Android Application classes. Here, you can think of it as exactly the same as AndroidEntryPoint annotations. As a first step, developers simply add the @hiltAndroidApp annotation to the Application class.
@HiltAndroidApp
class MusicApp : Application {
@Inject lateinit var store: MusicStore
}
Copy the code
However, HiltAndroidApp has another important function — generating the Dagger component.
When the Hilt annotation processor encounters the @HILtAndroidApp annotation, it generates a list of components in a wrapper class that has the same name as the Application class and is prefixed with HiltComponents_. If you have used Dagger before, these components are classes with @Component and @subComponent annotations, which you usually need to write manually within the Dagger.
To generate these components, Hilt looks for all the classes in the meta-package that have been annotated with @Installin. Modules with @installin annotations are placed in the module list of the corresponding component declaration. The entry point with the @Installin annotation is placed where the parent type of the component is declared.
From here, the Dagger handler takes over and generates the concrete implementation of the Component based on the @Component and @SubComponent annotations. If you have ever used Dagger (not through Hilt), chances are you have already handled these classes directly. However, Hilt hides this complexity from developers.
This is an article about Hilt and we won’t go into the details of the Dagger generation code. If you are interested, please refer to:
- Ron Shapiro and David Baker.
- Dagger CodeGen 101 cheat sheet.
The Hilt Gradle plug-in
Now that you know how code generation works in Hilt, let’s take a look at the Hilt Gradle plug-in. The Hilt Gradle plug-in performs many useful tasks, including bytecode rewriting and classpath aggregation.
Bytecode rewriting
As the name implies, bytecode rewriting is the process of rewriting bytecode. Unlike annotation processing, which can only generate new code, bytecode rewriting can modify existing code. This is a very powerful feature if used sparingly.
To illustrate why we use bytecode rewriting in Hilt, let’s go back to @AndroidentryPoint.
@AndroidEntryPoint(AppCompatActivity::class)
class PlayActivity : Hilt_PlayActivity {
override fun onCreate(...). {
val welcome = findViewById(R.id.welcome)
}
}
Copy the code
While inheriting the Hilt_PlayActivity base class works in practice, it can cause IDE errors. Because the generated classes do not exist until after you successfully compile the code, you will often see red wavy lines in the IDE. In addition, you won’t be able to enjoy auto-completion capabilities such as method overloading, and you won’t be able to access methods in the base class.
Not only does losing these features slow down your coding speed, but those red wavy lines can be a huge distraction.
The Hilt Android plug-in initiates bytecode rewriting by adding AndroidEntryPoint annotations to your classes. With the Hilt Android plug-in enabled, all you need to do is add the @AndroidEntryPoint annotation to the class, and you can make it inherit from the normal base class.
@AndroidEntryPoint
class PlayActivity : AppCompatActivity { // <Override fun onCreate(...) { val welcome = findViewById(R.id.welcome) } }Copy the code
Because this syntax does not reference the generated base class, it does not cause IDE errors. During bytecode rewriting, the Hilt Gradle plug-in replaces your base class with Hilt_PlayActivity. Because this procedure manipulates bytecode directly, it is not visible to the developer.
However, bytecode rewriting still has some disadvantages:
- The plug-in must modify the underlying bytecode, not the source code, which is error-prone.
- Because the bytecode is already compiled at the time of the overwrite operation, problems usually occur at run time rather than compile time.
- Overwrite operations complicate debugging because the source file may not represent the bytecode currently being executed when a problem occurs.
For these reasons, Hilt tries to rely as little on bytecode rewriting as possible.
Classpath aggregation
Finally, let’s look at another useful feature of the Hilt Gradle plug-in: classpath aggregation. To understand what classpath aggregation is and why it is needed, let’s look at another example.
In this example, app relies on a separate Gradle module :database. :app and :database both provide modules annotated by InstallIn.
As you can see, Hilt generates metadata under a specific Hilt_metadata package, which is used to find all modules with @installin annotations when building components.
Processing without classpath aggregation still works fine for single-layer dependencies. Now let’s see what happens when another Gradle module :cache is added as a dependency for :database.
Although it generates metadata when :cache is compiled, it is not available when :app is compiled because it is a pass-through dependency. As a result, Hilt has no way of knowing about CacheModule, which is accidentally excluded from the generated components.
Of course, you could technically solve this problem by using the API instead of the Implementation declaration :cache dependency, but this is not recommended. Using apis not only makes incremental builds worse, but also makes maintenance a nightmare.
This is where the Hilt Gradle plugin comes in.
Even with implementation, the Hilt Gradle plugin can automatically aggregate all classes from the :app pass dependency.
In addition, the Hilt Gradle plug-in has many advantages over using the API directly.
First, classpath aggregation is less error-prone and requires less maintenance than manually using API dependencies throughout the application. You can simply use implementation as usual, and the Hilt Gradle plug-in will handle the rest.
Second, the Hilt Gradle plug-in aggregates classes only at the application level, so unlike using apis, the compilation of libraries in a project is not affected.
Finally, classpath aggregation provides better encapsulation for your dependencies, because it is impossible to accidentally reference these classes in source files, and they do not appear in code completion prompts.
conclusion
In this article we’ve shown how various Hilt annotations work together to generate code. We also looked at the Hilt Gradle plug-in and saw how it uses bytecode rewriting and classpath aggregation behind the scenes to make Hilt safer and easier to use.
That’s all for this article, but stay tuned for more MAD Skills articles soon.
Please click here to submit your feedback to us, or share your favorite content or questions. Your feedback is very important to us, thank you for your support!