Hilt Dependency Injection Engine & Underlying Architecture
Hilt: Android's Exclusive "Central Kitchen"
When developing complex, large-scale applications, intricate dependency relationships arise between various classes. For example, a ViewModel might depend on a Repository, and that Repository might depend on a Database and a NetworkClient. If we don't use Dependency Injection (DI), our code will be littered with manual instantiations using the new keyword (or NetworkClient()). This not only leads to highly coupled code but also makes unit testing extremely difficult.
Life Metaphor: The Chef and the Central Kitchen
- No DI: Before cooking, the Chef (Activity) must personally go to the vegetable market to buy tomatoes and the butcher shop to buy beef (manually
newdependencies).- Traditional DI (Dagger 2): The company built a central kitchen, but you must draw the construction blueprints for the kitchen yourself, explicitly stipulating which door vegetables enter through and which door finished dishes exit through (writing complex Component and lifecycle binding code).
- Hilt: Google officially handed you a fully furnished central kitchen. All zones (corresponding to the lifecycles of various Android components) have already been partitioned. The Chef only needs to wear a nametag (
@AndroidEntryPoint) and stick a note (@Inject) where they need beef. When it's time to cook, the beef will automatically appear on the cutting board.
Hilt is built on top of Dagger 2. It takes the headache-inducing boilerplate code of Dagger 2, encapsulates it minimally, and provides a standard dependency injection solution specifically for the Android platform.
Core Components and Usage Paradigms
Hilt's core advantage is that it is declarative. You only need to "declare" your intentions via annotations, and all the "wiring" work is automatically completed by Hilt at compile-time.
1. Triggering Code Generation: @HiltAndroidApp
All applications using Hilt must contain an Application class annotated with @HiltAndroidApp.
@HiltAndroidApp
class MyApplication : Application()
This annotation is the "power switch" for the entire Hilt engine. During compilation, Hilt generates a base class extending Application (e.g., Hilt_MyApplication), and initializes the foundation of the entire app's Dependency Graph—the SingletonComponent—within it.
2. Injecting Android Components: @AndroidEntryPoint
By annotating Activities, Fragments, Views, Services, or BroadcastReceivers with @AndroidEntryPoint, Hilt will provide dependencies for them.
@AndroidEntryPoint
class UserActivity : AppCompatActivity() {
// Field Injection: Hilt will automatically assign a value to this field
@Inject lateinit var userRepository: UserRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Here, userRepository has already been instantiated and assigned, ready to use
userRepository.getUserInfo()
}
}
3. Telling Hilt How to Manufacture Objects: @Inject and @Module
Although Hilt is the central kitchen, it still needs you to provide "recipes".
Scenario 1: You can modify the class's constructor
Add @Inject directly to the constructor, telling Hilt: "If you need a UserRepository, call this constructor. And please also go find the api it needs."
// Constructor Injection
class UserRepository @Inject constructor(
private val api: UserApi
) { ... }
Scenario 2: Interfaces or third-party classes (Cannot modify constructor)
If it's a Retrofit instance or a concrete implementation of an interface UserApi, you cannot add @Inject to their source code. In this case, you must use @Module to inform Hilt.
@Module
@InstallIn(SingletonComponent::class) // Explicitly install this module in the global SingletonComponent
object NetworkModule {
@Provides
@Singleton // Ensure only one Retrofit instance exists across the entire app
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com")
.build()
}
@Provides
fun provideUserApi(retrofit: Retrofit): UserApi {
return retrofit.create(UserApi::class.java)
}
}
Deep Dive: What Magic Does Hilt Perform?
The most magical part of Hilt is this: we declared @Inject lateinit var userRepository: UserRepository in UserActivity, yet we didn't call any method like Hilt.inject(this). Why isn't it null inside onCreate?
This involves Hilt's two core mechanisms: Compile-Time Code Generation (APT/KSP) and Bytecode Transformation.
Bytecode Transformation: The Covert Replacement of @AndroidEntryPoint
In traditional Dagger 2, we had to manually write DaggerAppComponent.create().inject(this) at the very beginning of onCreate. To eliminate this line of code, Hilt uses a Gradle plugin (dagger.hilt.android.plugin) to play a trick of "bait and switch" at compile-time.
-
Generating Base Classes at Compile-Time: During compilation (the APT/KSP phase), Hilt scans for
UserActivityannotated with@AndroidEntryPoint, and quietly generates an abstract base classHilt_UserActivitythat inherits from your original parent classAppCompatActivity. -
Injection Logic in the Base Class: The generated base class roughly looks like this:
public abstract class Hilt_UserActivity extends AppCompatActivity { private boolean injected = false; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { inject(); // [Core] Execute injection BEFORE super.onCreate! super.onCreate(savedInstanceState); } protected void inject() { if (!injected) { injected = true; // Extract dependencies from ActivityComponent and assign them to UserActivity's fields ((UserActivity_GeneratedInjector) generatedComponent()).injectUserActivity(UnsafeCasts.unsafeCast(this)); } } } -
Parent Class Replacement at the Bytecode Level: After the code is compiled into bytecode (during the Transform phase), the Hilt Gradle plugin modifies the bytecode of
UserActivity.class, forcibly changing its parent class fromAppCompatActivityto the generatedHilt_UserActivity.
Because of this, when the system calls UserActivity.onCreate(), it first calls super.onCreate(), which in turn triggers the inject() method inside Hilt_UserActivity.onCreate(), completing the field assignment. This is the fundamental reason why Hilt can achieve "non-invasive injection".
Component Hierarchy and Lifecycle Mapping
Hilt pre-configures a Component tree structure that aligns with Android's lifecycles. Each Component corresponds to the lifecycle of a native Android component.
graph TD
S[SingletonComponent<br/>Created/Destroyed with Application] --> AR[ActivityRetainedComponent<br/>Survives configuration changes with ViewModel]
AR --> A[ActivityComponent<br/>Created/Destroyed with Activity]
AR --> V[ViewModelComponent<br/>Destroyed with ViewModel]
A --> F[FragmentComponent<br/>Created/Destroyed with Fragment]
A --> View[ViewComponent]
F --> VF[ViewWithFragmentComponent]
Component Containment Relationships (Arrows represent downward dependencies):
Child Components can acquire dependencies from parent Components, but not vice-versa. For example, objects in a Fragment can depend on global singletons (SingletonComponent), but an Application-level singleton must absolutely never depend on an object inside a Fragment, otherwise it will cause severe memory leaks.
Scope Annotations:
By default, for every @Inject request, Hilt creates a brand new instance.
If you wish to reuse the same instance within the lifecycle of a specific Component, you need to use corresponding Scope annotations:
@Singleton: Unique across the whole app.@ActivityScoped: Unique within the same Activity instance.@FragmentScoped: Unique within the same Fragment instance.
Performance Warning: Do not abuse scopes. Scope-annotated objects are cached in their corresponding Component until that lifecycle ends. Unnecessarily abusing
@Singletonor@ActivityScopedwill cause objects that could have been garbage collected to permanently reside in memory.
Industrial-Grade Best Practices: Hilt and ViewModel
In the MVVM architecture, the ViewModel is the core hub. Before Hilt emerged, because ViewModel instantiation was handled by ViewModelProvider.Factory, passing parameterized constructors to ViewModels was extremely painful.
Hilt provides @HiltViewModel, elegantly solving this problem:
@HiltViewModel
class MainViewModel @Inject constructor(
private val userRepository: UserRepository,
private val savedStateHandle: SavedStateHandle // Hilt can even automatically inject SavedStateHandle
) : ViewModel() { ... }
Principle Analysis:
When you get a ViewModel using by viewModels(), Hilt internally uses a custom HiltViewModelFactory to intercept the creation of the ViewModel. It looks up the corresponding dependency graph via the ViewModelComponent, instantiates UserRepository, assembles the MainViewModel, and ultimately hands it back to the Android ViewModel framework to manage.
Architectural Trade-offs: Hilt vs Koin
In the Android ecosystem, another highly popular dependency injection framework is the Kotlin-exclusive Koin. They represent two distinct design philosophies:
| Comparison Dimension | Hilt (Backed by Dagger 2) | Koin |
|---|---|---|
| Underlying Principle | Compile-Time Dependency Graph Generation (APT/KSP) | Runtime Service Locator (Service Locator pattern + Lazy Init) |
| Error Discovery Timing | Compile-Time: Missing dependencies cause compilation failure (Fail Fast); injection bugs never reach production. | Run-Time: If a dependency isn't provided, compilation passes, but the app crashes (NoBeanDefFoundException) only when hitting that specific page at runtime. |
| Runtime Performance | Extremely High: Generated code is equivalent to hand-written injection, pure hardcoding without reflection. | Medium: Lookups via Maps incur a minuscule runtime overhead. |
| Build Speed | Slower: Every change involving dependencies requires KSP to re-analyze and generate a lot of intermediate code. | Extremely Fast: Almost no impact on build speed because it doesn't generate code. |
Why do large-scale industrial projects favor Hilt? For large Apps, dependency chains might be dozens of layers deep. If using Koin, it's nearly impossible for developers to verify by eye whether all dependencies are correctly configured; a small omission might only be exposed when QA tests a specific deep-layer workflow. Hilt's topological sorting and comprehensive checks at compile-time give the architecture incredible certainty and safety. Sacrificing a portion of compile time in exchange for absolute runtime safety is a worthwhile trade in enterprise-grade engineering.
Conclusion
The essence of Hilt is that it rigidly mapped Android's complex lifecycle tree to Dagger's Component tree, and smoothed out the framework's intrusiveness using Gradle bytecode transformation techniques. It allows us to enjoy Dagger's powerful compile-time dependency checking and pristine runtime performance at an extremely low barrier to entry, making it an indispensable underlying infrastructure for modern, industrial-grade Android architectures.