Hilt Dependency Injection on Android: Setup, Scopes, and Multi-Binding for View and Compose
It was so difficult, so difficult. I stepped into a bunch of pitfalls, reinstalled Android Studio several times, cleared the Gradle cache back and forth multiple times, and tinkered for a whole day before finally setting up a minimal Hilt Demo. I really shouldn't have relied on AI; I should have consulted the official documentation earlier: Implement dependency injection with Hilt
Dependency Introduction
To use Hilt, an Android project must apply at least two plugins:
com.google.dagger.hilt.androidcom.google.devtools.ksp(the olderkaptis no longer officially recommended)
And at least these two dependency libraries:
com.google.dagger:hilt-androidcom.google.dagger:hilt-android-compiler(specifically for the KSP plugin)
Introduce in project/build.gradle.kts:
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.hilt.android) apply false //Plugin 1: com.google.dagger.hilt.android 2.57
alias(libs.plugins.ksp) apply false //Plugin 2: com.google.devtools.ksp 2.0.21-1.0.28
}
Introduce in app/build.gradle.kts:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt.android) //Plugin 1: com.google.dagger.hilt.android 2.57
alias(libs.plugins.ksp) //Plugin 2: com.google.devtools.ksp 2.0.21-1.0.28
}
...
dependences{
implementation(libs.hilt.android) //Dependency library 1
ksp(libs.hilt.android.compiler) //Dependency library 2: Introduces the Hilt-specific KSP annotation processor, which automatically generates the auxiliary classes required for dependency injection at compile time. This is a necessary configuration for Hilt to work properly.
}
Different kotlin versions have strict compatibility requirements with Hilt and ksp. This is a major pitfall; mismatches will cause all sorts of strange errors. After syncing, run the app once to ensure the configuration is correct.
[versions]
agp = "8.9.3"
kotlin = "2.0.21"
# Plugin versions
ksp = "2.0.21-1.0.28"
hilt = "2.57"
# Dependency libraries: Library 1 and Library 2 must strictly maintain the same version
hiltAndroid = "2.57"
[libraries]
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" }
[plugins]
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Personally tested, this combination has no compatibility issues. For reference with older versions of Android Studio, mine is Android Studio Meerkat 2024.3.1.
Concepts
Component
The content inside the parentheses of @InstallIn declared in a Module corresponds to the owning Component. Different Modules can be installed and bound to the same Component (many-to-one). Different @Provides have default scopes, but you cannot assign a scope to an injected object that differs from its container's scope.
Comparing the three that are relatively similar:
| Component | Scope Annotation | Lifecycle Scope |
|---|---|---|
| SingletonComponent | @Singleton | The entire App process; destroyed only when the App is completely killed. |
| ActivityRetainedComponent | @ActivityRetainedScoped | Attached to Activity; not destroyed on screen rotation, destroyed when Activity finishes. |
| ActivityComponent | @ActivityScoped | Attached to Activity; directly destroyed and recreated on screen rotation. |
Hierarchy:
SingletonComponent(Parent) → ActivityRetainedComponent → ActivityComponent
- A child component can obtain instances from its parent component (an
Activitycan inject a globalSingletonOkHttpinstance); - A parent component absolutely cannot obtain instances from a child component (a global
Singletoncannot inject anActivity-levelService; this results in a compilation error).
Component Scope
Scope acts on @Provides functions. Within different Components, only fixed Scope annotations can be used. The Scope annotation must match the level of the Component to which the current Module belongs; you cannot cross-scope to upper or lower-level Components.
| Module Install Location (InstallIn) | Allowed Scope for @Provides function |
|---|---|
| SingletonComponent | @Singleton only |
| ActivityRetainedComponent | @ActivityRetainedScoped only |
| ActivityComponent | @ActivityScoped only |
| FragmentComponent | @FragmentScoped only |
Note: If you do not add the corresponding Component annotation to a @Provides function, a new object will be created and injected each time dependency injection occurs. A single @Provides can only have one Scope annotation; they cannot be stacked.
Scope corresponding to each annotation
Basic Usage
Hilt usage is generally divided into four parts:
- Hilt Application Class: Marked (
@HiltAndroidApp) - Injected Class/Interface: The object type that needs to be injected by the container. (
@Inject@Binds) - Entry Point: The place that does not create but needs to use the injected class, commonly an Activity. (
@AndroidEntryPoint/@Inject) - Module Container (Optional): Provides methods for injecting objects, responsible for object creation and management. (
@Module@InstallIn@Provides)
Pay special attention to the packages of the above annotations, especially that @Inject refers to the one in the javax library, while the others are all from dagger. Importing the wrong package is very frustrating:
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
Non-Annotated Constructor Injection
Non-annotated constructor injection is the simplest application method; it does not require using a `Module` to manage the injected object.
```kotlin
class DemoService @Inject constructor(private val demo: Demo) {
fun getString(): String {
return "I am DemoService"
}
}
class Demo @Inject constructor() {}
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var service: DemoService
private lateinit var textView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
textView = findViewById(R.id.hello_tv)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
textView.text = service.getString()
}
}
DemoService is injected; its constructor and the constructor of its dependent parameter object type Demo must also be annotated with @Inject for Hilt to perform injection normally.
- What if the injected object is an interface object? You can't expect Hilt to write an implementation class and provide it to you.
- What if the injected object belongs to a third-party library class? You want to modify its constructor, but you are not allowed to. How can injection be achieved?
@Binds Interface Instance Injection
Interface instance injection must use a Module. The entry point usage is the same as the non-annotated constructor injection method, with no special requirements.
@Module
@InstallIn(ActivityRetainedComponent::class)
abstract class AppModule {
@Binds
abstract fun bindService(service: DemoServiceImpl): DemoService
}
interface DemoService {
fun getString(): String
}
class DemoServiceImpl @Inject constructor() :DemoService {
override fun getString(): String {
return "I am DemoServiceImpl"
}
}
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var service: DemoService
private lateinit var textView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
textView = findViewById(R.id.hello_tv)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
textView.text = service.getString()
}
}
@Provider Third-Party Instance Injection
@Provider is the solution specifically designed to solve the problem of injecting class objects from third-party libraries where constructors cannot be modified. The usage approach is the same as @Binds, as long as you explicitly declare how to provide the class object.
Application
@HiltAndroidApp
class MyApp: Application() {
override fun onCreate() {
super.onCreate()
}
}
Module Container
@Module
@InstallIn(ActivityRetainedComponent::class)
class AppModule {
@Provides
fun providerService():DemoService {
return DemoService()
}
}
Injected Class
import javax.inject.Inject
class DemoService {
fun getString(): String {
return "I am DemoService"
}
}
Activity
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var service: DemoService
private lateinit var textView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
textView = findViewById(R.id.hello_tv)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
textView.text = service.getString()
}
}
Here, although the Activity has a service member, it is not manually instantiated, yet its member function service.getString() can be called directly. If dependency injection had not been performed, a null pointer crash would definitely occur at this point, and compilation might not even pass. The Activity outsources the management of its dependency objects to the Module, reducing coupling.
Note:
Fields injected by Hilt cannot be private fields. Attempting to use Hilt to inject private fields will result in a compilation error. This is indeed the case.
Additionally, Hilt does not allow the same Module to use both @Provider and @Binds to provide interface objects and class objects respectively; it will cause an error:
[ksp] ...AppModule.kt:13: A @Module may not contain both non-static and abstract binding methods
The class is declared as abstract precisely because injecting an interface object must use an abstract method, but Hilt's Module non-static methods and abstract methods cannot coexist. Although at the syntax level an abstract class can have non-abstract methods, and the static syntax check can pass, compilation will fail. In general practical scenarios, ordinary class injection and interface injection must be separated into different Modules.
@Provider belongs to manually controlled object provision. As long as the object is created and returned within the function body, the injected class no longer needs the automatic constructor injection @Inject constructor().
Single Class Multi-Binding
In many scenarios, we need multi-instance injection for a single class. This is where the multi-binding annotation @Qualifier comes into play.
Core Usage: First, define multiple annotations corresponding to different instances, then use these annotations to mark different @Provides functions. When using them, also use these annotations to mark different objects.
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DemoA
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DemoB
@Module
@InstallIn(ActivityRetainedComponent::class)
class AppModule {
@DemoA
@Provides
fun providerServiceA():DemoService {
return DemoService("DemoServiceA")
}
@DemoB
@Provides
fun providerServiceB():DemoService {
return DemoService("DemoServiceB")
}
}
class DemoService (private val name:String) {
fun getString(): String {
return "I am $name"
}
}
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@DemoA
@Inject
lateinit var service1: DemoService
@DemoB
@Inject
lateinit var service2: DemoService
private lateinit var textViewA: TextView
private lateinit var textViewB: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
textViewA = findViewById(R.id.helloA_tv)
textViewB = findViewById(R.id.helloB_tv)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
textViewA.text = service1.getString()
textViewB.text = service2.getString()
}
}
Multi-Binding Annotation Marking Function Parameters
Suppose you also need a Client class instance that relies on dependency injection, and its parameter includes a DemoService class, but different Clients require different DemoService instances to be constructed. In this case, custom annotations can still be used on function parameters.
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DemoA
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DemoB
@Module
@InstallIn(ActivityRetainedComponent::class)
class AppModule {
@DemoA
@Provides
fun providerServiceA():DemoService {
return DemoService("DemoServiceA")
}
@DemoB
@Provides
fun providerServiceB():DemoService {
return DemoService("DemoServiceB")
}
@Provides
fun providerClient(@DemoA service: DemoService): Client {
return Client(service)
}
}
class Client(private val demo:DemoService){
fun getString(): String {
return "Client:"+demo.getString()
}
}
Predefined Qualifiers
Suppose an AnalyticsAdapter object that needs dependency injection itself requires a Context as a constructor parameter. How do you pass the project context to it within a @Provides function that needs to construct and return such an object?
Predefined context annotations:
@ApplicationContext: Application context.@ActivityContext: Activity context.
class AnalyticsAdapter @Inject constructor(
@ActivityContext private val context: Context,
private val service: AnalyticsService
) { ... }
Scope Usage
//Application-level singleton
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Singleton
@Provides
fun providerService():DemoService {
return DemoService("DemoServiceSingleton:")
}
}
//ActivityRetain-level singleton, not lost on screen rotation
@Module
@InstallIn(ActivityRetainedComponent::class)
class ActivityModule {
@ActivityRetainedScoped
@Provides
fun providerService():DemoService {
return DemoService("DemoServiceActivityRetainedScoped:")
}
}
//Activity-level singleton, lost on screen rotation
@Module
@InstallIn(ActivityComponent::class)
class ActivityModule {
@ActivityScoped
@Provides
fun providerService():DemoService {
return DemoService("DemoServiceActivityRetainedScoped:")
}
}
class DemoService (private val name:String) {
fun getString(): String {
return "$name=${hashCode()}"
}
}
MainActivity uses dependency injection for demoService and prints the hashcode. Even within the same MainActivity, you will find that the hashcode for the Activity strategy changes upon screen rotation, while the ActivityRetained strategy does not.
Furthermore, if a SecondActivity is launched from MainActivity, you will find that the hashCode for the Singleton strategy test results remains consistent before and after, while the hashCode for the ActivityRetained strategy changes when navigating between different Activitys.
When the screen rotates, ActivityRetainedComponent compared to ActivityComponent is similar to adding a ViewModel to temporarily cache page data; SingletonComponent compared to ActivityRetainedComponent is a form of global singleton. Of course, if @Singleton is removed from SingletonComponent, then the two pages will create different instances, behaving identically to ActivityRetainedComponent.