iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🕵️‍♂️

Understanding the Fundamentals of Dagger Hilt Code Generation

に公開

With the advent of Dagger Hilt, dependency injection (DI) in Android has become much easier to define, with significantly less boilerplate code. As a developer, I am very happy about this and have been using it in my personal projects and production apps.
I decided to go on a journey to understand, at least a little bit, how this amazing "black magic" performs DI.

Target Audience

  • Those who have a general understanding of Dagger
  • Those who have used or researched Hilt
  • Those who are curious about what Dagger Hilt is doing under the hood

Prerequisites

The environment used is as follows:

  • Android Studio 4.2 Beta 3
  • Dagger Hilt 2.31.2-alpha

In this article, I will name the class that inherits from the Android Application class HiltStudyApp.

The source code is available on GitHub, so please check it out as well. Each step has its own branch.

Step 1. Applying Hilt to the Application.

I will skip the installation procedure.

First, let's observe what happens by just adding the @HiltAndroidApp annotation.

HiltStudyApp.kt
package com.rmakiyama.daggerhiltstudy

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class HiltStudyApp : Application()

When you build this, the following files are automatically generated:

  • Hilt_HiltStudyApp
  • DaggerHiltStudyApp_HiltComponents_SingletonC
  • HiltStudyApp_GeneratedInjector
  • HiltStudyApp_HiltComponents
  • com_rmakiyama_daggerhiltstudy_HiltStudyApp_GeneratedInjectorModuleDeps

Let's look at each of them.

Hilt_HiltStudyApp

It seems that a class named Hilt_${application_name} is generated. It inherits from the Android Application class.

Looking at the class, Hilt_HiltStudyApp holds an instance of ApplicationComponentManager, which is a class provided by the Hilt library. It looks like it manages the Application's component, doesn't it?

Hilt_HiltStudyApp.java
  ...
  private final ApplicationComponentManager componentManager = new ApplicationComponentManager(new ComponentSupplier() {
    @Override
    public Object get() {
      return DaggerHiltStudyApp_HiltComponents_SingletonC.builder()
          .applicationContextModule(new ApplicationContextModule(Hilt_HiltStudyApp.this))
          .build();
    }
  });
  ...

It seems to be generating the component using ComponentSupplier. ComponentSupplier is just an interface with a get method.
The generated DaggerHiltStudyApp_HiltComponents_SingletonC is a class generated via annotations. For those who have used Dagger 2, this syntax might look familiar. This appears to be the application-level component.
It seems that something called ApplicationContextModule is also specified for this component. This is an internal Hilt class, and it seems to register the ApplicationContext in the object graph.

After that, it overrides the Application's onCreate method and calls injectHiltStudyApp so that HiltStudyApp can access the component.

Hilt_HiltStudyApp.java
  ...
  @CallSuper
  @Override
  public void onCreate() {
    // This is a known unsafe cast, but is safe in the only correct use case:
    // HiltStudyApp extends Hilt_HiltStudyApp
    ((HiltStudyApp_GeneratedInjector) generatedComponent()).injectHiltStudyApp(UnsafeCasts.<HiltStudyApp>unsafeCast(this));
    super.onCreate();
  }
  ...

The HiltStudyApp_GeneratedInjector that appeared here is an interface generated by the annotation. It is implemented by DaggerHiltStudyApp_HiltComponents_SingletonC.

generatedComponent is implemented by Hilt_HiltStudyApp. It is implemented to return the component inside the ApplicationComponentManager, which is DaggerHiltStudyApp_HiltComponents_SingletonC.

DaggerHiltStudyApp_HiltComponents_SingletonC

Next is this. As mentioned above, it is the component held internally by the ApplicationComponentManager of the generated Hilt_HiltStudyApp.

This class defines its own builder class, ViewModel-level components and their builders, and service-level components and their builders internally.

DaggerHiltStudyApp_HiltComponents_SingletonC.java
public final class DaggerHiltStudyApp_HiltComponents_SingletonC extends HiltStudyApp_HiltComponents.SingletonC {
  ...
  public static final class Builder { ... }
  private final class ActivityRetainedCBuilder ...
  private final class ActivityRetainedCImpl ...
  private final class ServiceCBuilder ...
  private final class ServiceCImpl ...
}

Furthermore, inside the ViewModel-level component class, the activity-level component and its builder are defined. And so on... the code was generated following the component hierarchy defined by Hilt. Interesting.

Each level of component appears to inherit from an abstract class defined in HiltStudyApp_HiltComponents. I will explain this simply later as it is easy to read.

HiltStudyApp_GeneratedInjector

As mentioned above, it is an interface implemented by DaggerHiltStudyApp_HiltComponents_SingletonC. Only injectHiltStudyApp is defined here.

By having the Application class call this, HiltStudyApp becomes able to access the application-level component. (This is done automatically by Hilt_HiltStudyApp.)

HiltStudyApp_HiltComponents

The Hilt component definitions for the application are summarized here. It seems that the component hierarchy is standardized in this file.

The only class that has the @Component annotation is HiltStudyApp_HiltComponents.SingletonC.

HiltStudyApp_HiltComponents.java
  ...
  @Component(
      modules = {
          ApplicationContextModule.class,
          ActivityRetainedCBuilderModule.class,
          ServiceCBuilderModule.class
      }
  )
  @Singleton
  public abstract static class SingletonC implements HiltStudyApp_GeneratedInjector,
      HiltWrapper_ActivityRetainedComponentManager_ActivityRetainedComponentBuilderEntryPoint,
      ServiceComponentManager.ServiceComponentBuilderEntryPoint,
      SingletonComponent,
      GeneratedComponent {
  }
  ...

In addition to the ApplicationContextModule that provides the Application (which is the default binding for SingletonComponent), ActivityRetainedCBuilderModule/ServiceCBuilderModule are specified for the component according to the component hierarchy.

Other components are defined as subcomponents, each marked with the @Subcomponent annotation. Modules providing default bindings and modules following the component hierarchy are specified. The following is an example of ActivityComponent.

HiltStudyApp_HiltComponents.java
  @Subcomponent(
      modules = {
          FragmentCBuilderModule.class,
          ViewCBuilderModule.class,
          HiltWrapper_ActivityModule.class,
          HiltWrapper_DefaultViewModelFactories_ActivityModule.class
      }
  )
  @ActivityScoped
  public abstract static class ActivityC implements ActivityComponent,
      DefaultViewModelFactories.ActivityEntryPoint,
      FragmentComponentManager.FragmentComponentBuilderEntryPoint,
      ViewComponentManager.ViewComponentBuilderEntryPoint,
      GeneratedComponent {
    @Subcomponent.Builder
    abstract interface Builder extends ActivityComponentBuilder {
    }
  }

Thus, we can see that in Hilt, there is only one activity component, rather than one per activity.

com_rmakiyama_daggerhiltstudy_HiltStudyApp_GeneratedInjectorModuleDeps

At the stage where I simply defined the @HiltAndroidApp annotation in the Application class, the following code was generated.

com_rmakiyama_daggerhiltstudy_HiltStudyApp_GeneratedInjectorModuleDeps.java
/**
 * Generated class to pass information through multiple javac runs.
 */
@AggregatedDeps(
    components = "dagger.hilt.components.SingletonComponent",
    entryPoints = "com.rmakiyama.daggerhiltstudy.HiltStudyApp_GeneratedInjector"
)
class com_rmakiyama_daggerhiltstudy_HiltStudyApp_GeneratedInjectorModuleDeps {
}

As the comment says, I only understand that it is likely a class to inform the compiler about the path of the generated class... I will put this on hold for now.

Step 2. Registering Bindings in the Object Graph.

Next, let's try registering a binding in the object graph.

Here is the registration using @Inject.

Hoge.kt
class Hoge @Inject constructor()

Here is the registration using @Inject with a @Singleton scope applied.

Fuga.kt
@Singleton
class Fuga @Inject constructor()

Here, we define an interface.

Piyo.kt
interface Piyo

class PiyoImpl : Piyo

And we use a module to register it with the SingletonComponent.

HiltStudyAppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object HiltStudyAppModule {

    @Provides
    fun providePiyo(): Piyo {
        return PiyoImpl()
    }
}

Furthermore, let's inject these into HiltStudyApp as member variables.

HiltStudyApp.kt
@HiltAndroidApp
class HiltStudyApp : Application() {
    @Inject lateinit var hoge: Hoge
    @Inject lateinit var fuga: Fuga
    @Inject lateinit var piyo: Piyo
}

Building this results in the generation of the following classes:

  • Hoge_Factory
  • Fuga_Factory
  • HiltStudyAppModule_ProvidePiyoFactory
  • HiltStudyApp_MembersInjector

Additionally, code was added to the following classes:

  • DaggerHiltStudyApp_HiltComponents_SingletonC
  • HiltStudyApp_HiltComponents

Each Factory Class

Factories were generated for each class registered in the object graph. Each factory class implements the get method of Dagger's Provider<T> to generate instances of the target class.

Since Hoge_Factory and Fuga_Factory do not take any arguments, they are simply generated by calling the default constructor.

HiltStudyAppModule_ProvidePiyoFactory generates an instance using the function defined with @Provides in HiltStudyAppModule.

HiltStudyApp_MembersInjector

As the name suggests, this is a class for injecting each instance into member variables. It holds providers for Hoge, Fuga, and Piyo respectively. These providers seem to be generated internally by Dagger via the create function.

Also, methods injectHoge, injectFuga, and injectPiyo are defined to actually perform the injection into the member variables. For example, injectHoge is defined as follows:

HiltStudyApp_MembersInjector.java
  public static void injectHoge(HiltStudyApp instance, Hoge hoge) {
    instance.hoge = hoge;
  }

The instance to be injected into and the instance of the class being injected are passed as arguments. These were called from DaggerHiltStudyApp_HiltComponents_SingletonC.

Changes in DaggerHiltStudyApp_HiltComponents_SingletonC

As explained in Step 1, Hilt_HiltStudyApp passes the instance requesting injection (HiltStudyApp) to the application-level component via the injectHiltStudyApp() method.

Up to Step 1, this method was empty, but now code is added to it as follows:

DaggerHiltStudyApp_HiltComponents_SingletonC.java
  ...
  @Override
  public void injectHiltStudyApp(HiltStudyApp hiltStudyApp) {
    injectHiltStudyApp2(hiltStudyApp);
  }

  private HiltStudyApp injectHiltStudyApp2(HiltStudyApp instance) {
    HiltStudyApp_MembersInjector.injectHoge(instance, new Hoge());
    HiltStudyApp_MembersInjector.injectFuga(instance, fuga());
    HiltStudyApp_MembersInjector.injectPiyo(instance, HiltStudyAppModule_ProvidePiyoFactory.providePiyo());
    return instance;
  }
  ...

You can see that the injectHiltStudyApp2() method is generated, calling the methods of HiltStudyApp_MembersInjector to inject the instances into the member variables.

Here, you might notice that the instance of the Fuga class, which was defined with the @Singleton scope, is obtained via the fuga() method. This method was added separately as follows:

DaggerHiltStudyApp_HiltComponents_SingletonC.java
  ...
  private volatile Object fuga = new MemoizedSentinel();
  ...
  private Fuga fuga() {
    Object local = fuga;
    if (local instanceof MemoizedSentinel) {
      synchronized (local) {
        local = fuga;
        if (local instanceof MemoizedSentinel) {
          local = new Fuga();
          fuga = DoubleCheck.reentrantCheck(fuga, local);
        }
      }
    }
    return (Fuga) local;
  }
  ...

Without going into too much detail, you can see that it reuses the instance once it has been generated. A singleton instance is injected as specified by the scope.

Changes in HiltStudyApp_HiltComponents

Only one line was added to HiltStudyApp_HiltComponents.

HiltStudyApp_HiltComponents.java
  ...
  @Component(
      modules = {
          ApplicationContextModule.class,
+         HiltStudyAppModule.class,
          ActivityRetainedCBuilderModule.class,
          ServiceCBuilderModule.class
      }
  )
  @Singleton
  public abstract static class SingletonC implements HiltStudyApp_GeneratedInjector,
      HiltWrapper_ActivityRetainedComponentManager_ActivityRetainedComponentBuilderEntryPoint,
      ServiceComponentManager.ServiceComponentBuilderEntryPoint,
      SingletonComponent,
      GeneratedComponent {
  }
  ...

Those who have used Dagger 2 will recall that every time a module was added, it had to be specified in the component. Hilt does this automatically, which is convenient.

This solves the mystery of how adding a module and applying the @InstallIn annotation registers the binding in the specified component's object graph.

Others

The com_rmakiyama_daggerhiltstudy_HiltStudyAppModuleModuleDeps class was also generated, but I will omit it. Like in Step 1, is it a class for the compiler?

Step 3. Adding the AndroidEntryPoint Annotation to an Activity.

First, let's observe what happens by simply adding the @AndroidEntryPoint annotation to MainActivity.

MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

Building in this state results in the automatic generation of the following files:

  • Hilt_MainActivity
  • MainActivity_GeneratedInjector

Additionally, code was added to the following classes:

  • DaggerHiltStudyApp_HiltComponents_SingletonC
  • HiltStudyApp_HiltComponents

These names look familiar, don't they? Let's take a quick look at each one.

Hilt_MainActivity

Hilt_MainActivity holds an internal ActivityComponentManager, which is a class provided by the Hilt library. The implementation pattern is generally the same as in Hilt_HiltStudyApp.

Unlike ApplicationComponentManager, the component retrieved by the generatedComponent() method in ActivityComponentManager is a singleton. As mentioned earlier, this is because Hilt uses a single activity component to perform injection for all activities.

Hilt_MainActivity also defines a method for retrieving the ViewModelProvider.Factory.

MainActivity_GeneratedInjector

This defines the injectMainActivity() method as an interface, allowing the activity component to access MainActivity. This follows the same flow as the application-level case.

Changes in DaggerHiltStudyApp_HiltComponents_SingletonC

Only a method to allow the activity component to access MainActivity was added.

DaggerHiltStudyApp_HiltComponents_SingletonC.java
+     @Override
+     public void injectMainActivity(MainActivity mainActivity) {
+     }

Changes in HiltStudyApp_HiltComponents

An addition was made to have the activity component implement MainActivity_GeneratedInjector.

HiltStudyApp_HiltComponents.java
  @Subcomponent(
      modules = {
          FragmentCBuilderModule.class,
          ViewCBuilderModule.class,
          HiltWrapper_ActivityModule.class,
          HiltWrapper_DefaultViewModelFactories_ActivityModule.class
      }
  )
  @ActivityScoped
+ public abstract static class ActivityC implements MainActivity_GeneratedInjector,
+     ActivityComponent,
      DefaultViewModelFactories.ActivityEntryPoint,
      FragmentComponentManager.FragmentComponentBuilderEntryPoint,
      ViewComponentManager.ViewComponentBuilderEntryPoint,
      GeneratedComponent {
    @Subcomponent.Builder
    abstract interface Builder extends ActivityComponentBuilder {
    }
  }

Step 4. Injecting Instances into an Activity.

Next, let's try registering more bindings in the object graph.

Here is a registration using @Inject.

HogeHoge.kt
class HogeHoge @Inject constructor(
    private val hoge: Hoge,
)

Here is a registration using @Inject with an @ActivityScoped scope applied.

FugaFuga.kt
@ActivityScoped
class FugaFuga @Inject constructor(
    private val fuga: Fuga,
)

Here, we define an interface.

PiyoPiyo.kt
interface PiyoPiyo

class PiyoPiyoImpl @Inject constructor(
    private val piyo: Piyo,
) : PiyoPiyo

Then, we use a module to register it with the ActivityComponent.

MainActivityModule.kt
@Module
@InstallIn(ActivityComponent::class)
object MainActivityModule {

    @Provides
    fun providePiyoPiyo(
        piyo: Piyo,
    ): PiyoPiyo {
        return PiyoPiyoImpl(piyo)
    }
}

Furthermore, let's inject these into MainActivity as member variables. While we're at it, let's inject Hoge and Fuga as well.

MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var hoge: Hoge
    @Inject lateinit var hogeHoge: HogeHoge
    @Inject lateinit var fuga: Fuga
    @Inject lateinit var fugaFuga: FugaFuga
    @Inject lateinit var piyo: Piyo
    @Inject lateinit var piyoPiyo: PiyoPiyo
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

Building this result, just like last time, generated factory classes and member injectors for the added classes. Code was also added to DaggerHiltStudyApp_HiltComponents_SingletonC and HiltStudyApp_HiltComponents.

By now, you might have some intuition about the code Hilt generates! I encourage you to visualize it in your head and check the source code.

Summary

We looked at the code generated by Hilt when performing simple integration and member variable injection. Understanding the generated code is quite satisfying. I feel like I understand Hilt a bit better now, and I think I can even get along with error messages better in the future.

References

Discussion