Mastering Dependency Injection in Android with Hilt: A Senior Developer’s Guide

Why Dependency Injection Matters in Android

Before we jump into Hilt, let’s revisit what Dependency Injection is and why it’s a game-changer for Android apps.

Dependency Injection is a design pattern that allows a class to receive its dependencies from an external source rather than creating them itself. In simpler terms, instead of a class instantiating its own objects (like a database or network service), those objects are “injected” into it. This promotes loose coupling, making your code more modular and easier to test.

In Android, apps are full of dependencies: ViewModels, Repositories, Databases (like Room), Network clients (like Retrofit), and more. Without DI, you might end up with tightly coupled code, where changing one part breaks everything else. Imagine hardcoding a Retrofit instance in every Activity—nightmare for unit testing!

Benefits of DI in Android:

  • Testability: Swap real dependencies with mocks during tests.
  • Reusability: Share instances across the app without duplication.
  • Scalability: Easier to manage as your app grows.
  • Lifecycle Awareness: Handle Android’s complex lifecycles (Activities, Fragments) without leaks.

Enter Hilt: Google’s recommended DI library for Android, built on top of Dagger. Dagger is powerful but verbose—Hilt simplifies it with annotations and pre-configured components tailored for Android.

Getting Started with Hilt: Setup and Basics

To use Hilt, you need to integrate it into your project. Let’s walk through the setup step by step.

Step 1: Add Dependencies

In your build.gradle.kts (app module), add these:

Kotlin

plugins {
    id("com.google.dagger.hilt.android")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.48")
    ksp("com.google.dagger:hilt-compiler:2.48")  // For annotation processing
}

And in the root build.gradle.kts:

Kotlin

plugins {
    id("com.google.dagger.hilt.android") version "2.48" apply false
}

Sync your project. Hilt uses Kotlin Symbol Processing (KSP) for faster compilation.

Step 2: Annotate Your Application Class

Create or modify your Application class:

Kotlin

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

@HiltAndroidApp
class MyApplication : Application() {
    // Your app initialization here
}

Update your AndroidManifest.xml to use this:

XML

<application
    android:name=".MyApplication"
    ...>
</application>

Step 3: Basic Injection

Hilt provides built-in components like @Singleton for app-wide scopes. Let’s inject a simple dependency.

First, define a module (a class that tells Hilt how to provide dependencies):

Kotlin

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)  // App-wide scope
object AppModule {

    @Provides
    @Singleton
    fun provideLogger(): Logger {
        return Logger()  // Assume Logger is a custom class
    }
}

Now, inject it into an Activity:

Kotlin

import android.os.Bundle
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var logger: Logger

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        logger.log("Activity created!")
    }
}

Boom! Hilt handles the instantiation and injection automatically. No more manual wiring.

Hilt’s magic comes from its annotations:

  • @HiltAndroidApp: Triggers Hilt code generation.
  • @AndroidEntryPoint: Marks Activities, Fragments, etc., for injection.
  • @Module and @Provides: Define how to create dependencies.
  • @InstallIn: Specifies the component (scope) for the module.

Deep Dive into Hilt Components and Scopes

Hilt organizes dependencies into “components,” which are essentially containers with defined lifecycles. Unlike Dagger, Hilt provides pre-built Android-specific components:

  • SingletonComponent: Lives as long as the Application. Ideal for app-wide singletons like databases.
  • ActivityComponent: Scoped to an Activity’s lifecycle.
  • FragmentComponent: Scoped to a Fragment’s lifecycle.
  • ViewModelComponent: For ViewModels.
  • And more, like ServiceComponent.

Scopes control how long an instance lives and when it’s recreated. Use @Singleton for app-wide, @ActivityScoped for activity-specific, etc.

Why scopes? Without them, every injection could create a new instance, wasting resources. Scopes ensure shared instances where needed.

Use Case 1: Application Scope with Hilt

Application scope is perfect for dependencies that should be singletons across the entire app. Think: Database instances, API clients, or shared preferences managers. These survive configuration changes and are shared between Activities/Fragments.

Example: Injecting a Room Database

Let’s build a real-world example: An app that manages user notes with a Room database. We’ll provide the database at application scope.

First, add Room dependencies:

Kotlin

implementation("androidx.room:room-runtime:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")

Define your entities and DAO:

Kotlin

import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val content: String
)

@Dao
interface NoteDao {
    @Insert
    suspend fun insert(note: Note)

    @Query("SELECT * FROM notes")
    fun getAllNotes(): List<Note>
}

Now, the Database class:

Kotlin

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = [Note::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
}

Provide it in a Hilt module:

Kotlin

import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app_database"
        ).build()
    }

    @Provides
    @Singleton
    fun provideNoteDao(database: AppDatabase): NoteDao {
        return database.noteDao()
    }
}

Here, @ApplicationContext qualifier injects the app context. The database is a singleton—created once and shared.

Now, a Repository to abstract data access:

Kotlin

import javax.inject.Inject

class NoteRepository @Inject constructor(
    private val noteDao: NoteDao
) {
    suspend fun addNote(note: Note) {
        noteDao.insert(note)
    }

    fun getAllNotes(): List<Note> {
        return noteDao.getAllNotes()
    }
}

Provide the repo in the module:

Kotlin

// Add to DatabaseModule
@Provides
@Singleton
fun provideNoteRepository(noteDao: NoteDao): NoteRepository {
    return NoteRepository(noteDao)
}

Inject into a ViewModel (we’ll cover ViewModels later, but for now):

Kotlin

import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class NotesViewModel @Inject constructor(
    private val repository: NoteRepository
) : ViewModel() {
    // ViewModel logic here
}

This setup ensures the database is initialized once at app start and reused everywhere. Use case: In a note-taking app, multiple Activities can access the same DB instance without recreating it, saving resources and ensuring data consistency.

Pros of application scope:

  • Efficient for heavy objects like databases.
  • Survives process death (with proper persistence).

Cons: Be careful with memory—don’t hold UI-related stuff here.

Use Case 2: Activity Scope with Hilt

Activity scope is for dependencies tied to an Activity’s lifecycle. These are created when the Activity starts and destroyed when it finishes. Ideal for things like Activity-specific analytics trackers or temporary caches.

Hilt provides @ActivityScoped annotation.

Example: Activity-Scoped Analytics Tracker

Suppose you want an analytics service that tracks events only within a specific Activity, resetting on Activity recreation (e.g., rotation).

Define the tracker:

Kotlin

class ActivityAnalyticsTracker {
    private val events = mutableListOf<String>()

    fun trackEvent(event: String) {
        events.add(event)
        // Simulate sending to server
        println("Tracked: $event")
    }

    fun getEvents(): List<String> = events
}

Module for Activity scope:

Kotlin

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped

@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {

    @Provides
    @ActivityScoped
    fun provideAnalyticsTracker(): ActivityAnalyticsTracker {
        return ActivityAnalyticsTracker()
    }
}

Inject into the Activity:

Kotlin

@AndroidEntryPoint
class AnalyticsActivity : ComponentActivity() {

    @Inject
    lateinit var tracker: ActivityAnalyticsTracker

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        tracker.trackEvent("Activity Created")
    }

    // In some button click
    fun onButtonClick() {
        tracker.trackEvent("Button Clicked")
    }
}

On rotation, a new tracker is created, resetting events. This prevents leaks and keeps data Activity-bound.

Use case: In a multi-screen app, each Activity might have its own session tracker for user behavior analysis, without polluting the global scope.

Use Case 3: Fragment Scope with Hilt

Fragments have their own lifecycle, often nested in Activities. @FragmentScoped ensures dependencies live as long as the Fragment.

Example: Fragment-Scoped Data Fetcher

Imagine a Fragment that fetches user profile data, scoped to avoid recreating on Fragment transactions (like backstack).

Define a fetcher:

Kotlin

class ProfileFetcher {
    fun fetchProfile(userId: String): String {
        // Simulate API call
        return "Profile for $userId: Bio here"
    }
}

Module:

Kotlin

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.FragmentComponent
import dagger.hilt.android.scopes.FragmentScoped

@Module
@InstallIn(FragmentComponent::class)
object FragmentModule {

    @Provides
    @FragmentScoped
    fun provideProfileFetcher(): ProfileFetcher {
        return ProfileFetcher()
    }
}

Inject into Fragment:

Kotlin

import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class ProfileFragment : Fragment() {

    @Inject
    lateinit var fetcher: ProfileFetcher

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val profile = fetcher.fetchProfile("user123")
        // Display profile
    }
}

If the Fragment is added to backstack and popped, the fetcher is destroyed. Use case: In a navigation graph, Fragments can have scoped network requests or UI state holders that don’t outlive the Fragment.

Combining Scopes: ViewModels and Qualifiers

ViewModels are a common use case bridging scopes. Hilt’s @HiltViewModel auto-scopes them to the Activity or Fragment they’re attached to.

Example: Scoped ViewModel with Repository

From earlier, our NotesViewModel is injected with the app-scoped repository. This combines scopes: Repository (singleton) injected into ViewModel (activity/fragment scoped).

To differentiate dependencies, use qualifiers. Suppose two databases:

Kotlin

import dagger.hilt.android.qualifiers.ActivityContext
import javax.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalDatabase

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RemoteDatabase

// In module
@Provides
@LocalDatabase
fun provideLocalDb(...): AppDatabase { ... }

Inject with qualifier:

Kotlin

@Inject @LocalDatabase lateinit var localDb: AppDatabase

This avoids ambiguity.

Advanced Use Cases: Network Integration with Retrofit

Let’s tie it all together with a full app-scope network client.

Add Retrofit:

Kotlin

implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

Define API:

Kotlin

import retrofit2.http.GET

interface ApiService {
    @GET("posts")
    suspend fun getPosts(): List<Post>  // Assume Post data class
}

Module:

Kotlin

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder().build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://jsonplaceholder.typicode.com/")
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

Repository using it:

Kotlin

class PostRepository @Inject constructor(
    private val apiService: ApiService
) {
    suspend fun getPosts(): List<Post> {
        return apiService.getPosts()
    }
}

ViewModel:

Kotlin

@HiltViewModel
class PostsViewModel @Inject constructor(
    private val repository: PostRepository
) : ViewModel() {
    // LiveData or StateFlow for posts
}

Activity/Fragment scope example: Suppose a Fragment-specific cache for posts.

Add to FragmentModule:

Kotlin

@Provides
@FragmentScoped
fun providePostCache(): MutableMap<String, Post> {
    return mutableMapOf()
}

Inject and use in Fragment.

Testing with Hilt

Hilt shines in testing. For unit tests, use @HiltAndroidTest and fake modules.

Example test:

Kotlin

import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject

@HiltAndroidTest
class NotesViewModelTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var viewModel: NotesViewModel

    @Test
    fun testAddNote() {
        hiltRule.inject()
        // Test logic
    }
}

For custom fakes, use @UninstallModules and provide test modules.

Best Practices and Common Pitfalls

  • Avoid Over-Scoping: Don’t make everything @Singleton; use the narrowest scope possible.
  • Handle Constructors: For classes without @Inject, use @Provides in modules.
  • Qualifiers for Clarity: When multiple similar dependencies, always qualify.
  • Entry Points: Only annotate classes that need injection with @AndroidEntryPoint.
  • Performance: Hilt is efficient, but large modules can slow compilation—split them.
  • Migration from Dagger: Hilt is backward-compatible; start with Hilt for new projects.
  • Pitfall: Circular Dependencies: Hilt detects them at compile-time—fix by redesigning.
  • Kotlin-Specific: Use constructor injection with @Inject for immutability.

In large apps, combine with Clean Architecture: Use cases in app scope, presenters in activity scope.

Conclusion

We’ve covered a ton: From DI basics to Hilt setup, scopes, and real use cases with code. Application scope for shared resources like DBs and networks; activity/fragment scopes for lifecycle-bound objects. With Hilt, Android DI is straightforward yet powerful.

Leave a Reply

Your email address will not be published. Required fields are marked *