跪拜 Guibai
← Back to the summary

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:

And at least these two dependency libraries:

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.

Screenshot 2026-08-02 143656.png

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)ActivityRetainedComponentActivityComponent

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

Screenshot 2026-08-02 145041.png

Basic Usage

Hilt usage is generally divided into four parts:

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.

@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. Screenshot 2026-08-02 120956.png 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. Screenshot 2026-08-02 121338.png

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:

Screenshot 2026-08-02 124217.png [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:

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.

MVVM Hilt Dependency Injection:

View UI

Compose UI