Dependency Injection with Hilt

Android Development โ€” Architecture & Data
Course 2 ยท Chapter 5 ยท Dependency Injection with Hilt

๐Ÿ’‰ Dependency Injection with Hilt

Chapters 3 and 4 built a database singleton and a Retrofit singleton by hand, each with its own manual companion-object wiring. Hilt automates exactly that kind of setup โ€” classes declare what they need, and Hilt constructs and hands it to them, rather than each class constructing its own dependencies. This chapter covers why that matters, and Hilt's four core building blocks.

๐Ÿค” The Problem, Concretely

Chapter 4's UsersViewModel needed an ApiService โ€” but where does that instance actually come from? Without DI, every class needing it either constructs its own (duplicating the Retrofit setup everywhere) or reaches into some global singleton object directly:

// Without DI โ€” the ViewModel reaches out and grabs its own dependency class UsersViewModel : ViewModel() { private val api = NetworkModule.retrofit.create(ApiService::class.java) // ... }

This works, but it hardcodes exactly which ApiService implementation UsersViewModel uses โ€” there's no way to substitute a fake one for testing, and every class that needs an ApiService repeats this same construction line. Dependency injection flips the direction: a class declares what it needs as a constructor parameter, and something else โ€” here, Hilt โ€” is responsible for actually providing a real instance.

// With DI โ€” the dependency is handed in, not fetched @HiltViewModel class UsersViewModel @Inject constructor( private val api: ApiService ) : ViewModel() { /* ... */ }

UsersViewModel no longer knows or cares how ApiService is built โ€” a real one in production, a fake one in tests, both work identically as far as this class is concerned.

๐Ÿ Hilt Setup โ€” Two Annotations to Start

// A custom Application class โ€” required for Hilt @HiltAndroidApp class MyApplication : Application()
// Every Activity that uses Hilt-injected things needs this @AndroidEntryPoint class MainActivity : ComponentActivity() { /* ... */ }

@HiltAndroidApp generates Hilt's base dependency container for the whole app โ€” it must go on a custom Application subclass, registered in AndroidManifest.xml's <application android:name=".MyApplication">. @AndroidEntryPoint marks an Activity (or Fragment) as able to receive Hilt-provided dependencies โ€” without it, Hilt won't wire anything into that class at all.

๐Ÿ“ฆ @Module + @Provides โ€” For Things You Don't Own

Hilt can't automatically construct classes it doesn't control the source of โ€” Retrofit and RoomDatabase come from external libraries, so a module tells Hilt explicitly how to build them:

@Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideRetrofit(): Retrofit { return Retrofit.Builder() .baseUrl("https://jsonplaceholder.typicode.com/") .addConverterFactory(MoshiConverterFactory.create()) .build() } @Provides @Singleton fun provideApiService(retrofit: Retrofit): ApiService { return retrofit.create(ApiService::class.java) } }

@InstallIn(SingletonComponent::class) scopes these provisions to the whole app's lifetime; @Singleton on each function ensures only one instance is ever created and reused โ€” this is precisely the manual companion object singleton pattern from Chapter 3, now handled declaratively. Notice provideApiService takes a retrofit: Retrofit parameter โ€” Hilt automatically supplies it from provideRetrofit above, chaining dependencies together without any manual ordering.

๐Ÿท๏ธ @Inject Constructor โ€” For Things You Do Own

For a class you wrote yourself (like a repository wrapping the DAO and API calls together), no module is needed at all โ€” just annotate the constructor:

class UserRepository @Inject constructor( private val api: ApiService, private val dao: TaskDao ) { suspend fun refreshUsers() { /* ... */ } }

Hilt sees @Inject constructor, recognizes it can build a UserRepository by supplying an ApiService (from NetworkModule) and a TaskDao (assuming a similar DatabaseModule providing it), and does so automatically wherever a UserRepository is itself requested โ€” no explicit "here's how to build a UserRepository" module is required, since Hilt can already see the whole recipe directly in the constructor.

๐Ÿ”Œ Injecting Into a ViewModel

@HiltViewModel class UsersViewModel @Inject constructor( private val repository: UserRepository ) : ViewModel() { /* ... */ }
// In a composable โ€” replaces Chapter 1's viewModel() with hiltViewModel() @Composable fun UsersScreen(viewModel: UsersViewModel = hiltViewModel()) { // ... }

@HiltViewModel plus hiltViewModel() (instead of Chapter 1's plain viewModel()) is the only change needed at the call site โ€” everything about how the ViewModel survives rotation, scopes to the screen, and exposes StateFlow works identically to Chapter 2, just with its dependencies supplied automatically instead of hardcoded inside the class.

Manual Wiring vs Hilt โ€” What Actually Changed

Manual (Chapters 3-4)Hilt
Database singletonHand-written companion object + synchronized block@Provides @Singleton function
ViewModel constructionPassed a dao/api manually via a ViewModelFactory@Inject constructor โ€” Hilt supplies it
Swapping a real dependency for a test fakeRequires editing the class itselfSwap the @Module's binding โ€” the class is unchanged

๐Ÿ’ป Coding Challenges

Challenge 1: Application and Activity Setup

Add a custom Application class annotated @HiltAndroidApp, register it in AndroidManifest.xml, and annotate MainActivity with @AndroidEntryPoint. Add a comment explaining what would go wrong if @AndroidEntryPoint were forgotten on an Activity that needs a Hilt-injected ViewModel.

Goal: Practice the minimum Hilt setup every project needs.

โ†’ Solution

Challenge 2: A Module for Room

Write a DatabaseModule (@Module, @InstallIn(SingletonComponent::class)) with @Provides @Singleton functions providing an AppDatabase (using Room.databaseBuilder with an applicationContext parameter) and a TaskDao (from that database's taskDao() function).

Goal: Practice writing a @Module for a dependency Hilt can't construct automatically, following the chapter's NetworkModule pattern for a different kind of dependency.

โ†’ Solution

Challenge 3: A Repository and Injected ViewModel

Write a TaskRepository with an @Inject constructor taking a TaskDao, exposing a getAllTasks(): Flow<List<Task>> function that delegates to the DAO. Write a @HiltViewModel TaskViewModel with an @Inject constructor taking the repository, exposing its tasks as a StateFlow via stateIn (Chapter 3's pattern).

Goal: Practice the full chain: DAO โ†’ Repository โ†’ ViewModel, all wired by Hilt with no manual construction anywhere.

โ†’ Solution

๐Ÿ’ก The Payoff Is Mostly Invisible Until Testing

DI's benefit doesn't show up much in a single-screen toy app โ€” the manual approach from Chapters 3-4 works fine at that scale. It pays off as an app grows (many classes sharing the same dependencies, without each repeating construction logic) and especially once automated testing enters the picture, where swapping a real network/database dependency for a fake one is exactly what DI is designed to make painless.

๐ŸŽฏ What's Next

Next chapter: Coroutines in Android โ€” viewModelScope, lifecycleScope, and choosing between the IO and Main dispatchers in an Android-specific context.