Tomáš Repčík - 25. 5. 2023

Dependency injection with Hilt in Android development

The hilt is a dependency injection (DI) framework based on Dagger. So, anyone who used Dagger in the past will have an easy time learning to use Hilt for Android development.

Photo by Alina Grubnyak on Unsplash

For an introduction to DI and other concepts, I recommend reading my previous article about it here.

The primary purpose of Hilt, Dagger, Koin and other DI frameworks is to do the heavy lifting of manual DI for you. If you do not use one, you will have to resolve all the dependencies on your own. It can get tedious and fragile if the complexity of the project grows. The dependencies are determined during the compilation time. If something is wrong, you will know about it before the run time of the actual app. The dependency frameworks can even help you with testing by placing the correct mock or fake in the right place for a unit or UI test.

If the DI framework is implemented, it tries to figure out how all the classes are interdependent and if the proposed solution makes sense. It tries to create a graph which provides necessary dependencies to appropriate classes. If there is something wrong, the error will occur during the compilation. It is a benefit of the DI framework as the error can be fixed before releasing the app to other people, where it would occur during runtime and crash the app.

Set-Up

The current version of the Hilt can be found here.

For the project-level build.gradle put this inside:

plugins {  
  id 'com.google.dagger.hilt.android' version '2.46.1' apply false  
}

For the app-level  `build.gradle`  put this inside respectively to sections:

plugins {  
  // other plugins - order is important  
  id 'kotlin-kapt'  
  id 'com.google.dagger.hilt.android'  
}  
  
android {  
  compileOptions {  
    sourceCompatibility JavaVersion.VERSION_1_8  
    targetCompatibility JavaVersion.VERSION_1_8  
  }  
}  
  
dependencies {  
  // For hilt Implementation  
  implementation 'com.google.dagger:hilt-android:2.46.1'  
  kapt 'com.google.dagger:hilt-compiler:2.46.1'  
  
  // For instrumentation tests  
  androidTestImplementation "com.google.dagger:hilt-android-testing:2.46.1"  
  kaptAndroidTest "com.google.dagger:hilt-android-compiler:2.46.1"  
  
  // For local unit tests  
  testImplementation 'com.google.dagger:hilt-android-testing:2.46.1'  
  kaptTest 'com.google.dagger:hilt-compiler:2.46.1'  
}  
  
kapt {  
  correctErrorTypes true  
}

Every app must contain a class, which inherits the Application Android class, which is annotated by @HiltAndroidApp . This class is used by Hilt’s code generator, which makes all the components lifecycle aware.

For example, create MyApp.kt and add:

@HiltAndroidApp  
class MyApp: Application()

From this point, the Hilt is fully integrated into our app and we can start using it for DI.

Constructor and Field Injection

If the dependency is passed to the class, it can be done in two ways. With constructor and field injection.

Constructor injection

The constructor injection is shown in the following code sample, where the Preferences is passed to the AppSettings class.

/// abstraction of any preferences  
interface Preferences { ... }  
  
/// specified class of preferences with underlaying functions  
/// the @Inject annotation is needed, because the hilt sees only classes with annotation  
class PreferencesImp @Inject constructor(): Preferences { ... }  
  
/// app settings, which can receive the preferences via DI  
class AppSettings @Inject constructor(private val preferences: Preferences)

All of the code seems as usual from the Kotlin perspective. The class contains constructor preferences input, which becomes the field of the class. A class instance is directly passed to the class’s constructor to create a new instance. This is called constructor injection. The @Inject keyword comes from the Hilt, which enables the framework to find this constructor and inject it with appropriate class instances, which will be covered later.

The benefit of the constructor injection is that it can be bypassed anytime by manual DI. You can recreate the class during runtime and ignore the DI framework, which can be useful during the testing if you want to create the class in an isolated environment.

Field injection — Android classes

The field injection is handy during class inheritance, which we do not possess from Android like Activity, Service, Fragment, View and others.

@AndroidEntryPoint // it labels the class for hilt, that is should look into it  
class MainActivity : AppCompatActivity() {   
  
  @Inject   
  lateinit var appSettings: AppSettings  
  
}

The Android activity is initialized within the system, but Hilt can provide all necessary dependencies. The control of the injection is lost due to the DI framework. It can be done only with its initialisation too.

The field injection can be done by the @Inject keyword, which lets the Hilt fill the required field with the proper class instance.

Now we know what is field injection and constructor injection. But, how does Hilt know where the dependency comes from? How do we define, which dependency will go where? The answer is Hilt modules and bindings.

Hilt modules

The modules are sources of the required class instances for classes which depend on them. Sometimes, you miss ownership of the class, or the third party library gives you an interface to inherit, but no specific implementation, or the creation of the instance does not have an empty constructor. The modules create space for objects to construct instances and pass them to Hilt to figure out where to put them without much of the code. There are two variants: module and binding.

Do not forget to generalise you classes into abstract classes or interfaces, so you can mock classes or freely interchange multiple instances of the same class — so called inversion of control. See the linked article to learn more about it here.

Provides

The Provides is more straightforward to use for the first time if you use Hilt and you can get all of the work done within the Module. Let’s start with the example:

@Module // creation of hilt module   
// components and scopes will be described later  
@InstallIn(SingletonComponent::class)   
object PreferencesDI { // kotlin object is needed  
  
  // dependencies, which we own  
  @Provides  
  fun providePreferences(@ApplicationContext context: Context): Preferences {  
    return PreferencesImp(context)  
  }  
  
  // third party database  
  @Provides  
  fun provideDatabase(@ApplicationContext context: Context): Database {  
    return DatabaseImp(context)  
  }  
    
}

The module is constructed from @module annotation, which symbolises the module itself. The InstallIn(...) decides about when the dependencies are initialized. It will be described in the next part of the article.

Kotlin object then contains all the functions with annotation @Provides , which resolves the needed instances. The code sample demonstrates the process, which provides the Preferences as a generalised class with a specific implementation of PreferencesImp .

With the definition of the module Hilt knows, if the class @Inject needs the Preferences , it knows it should go here into this module and grab a new instance of it.

The modules are suitable for all of the third parties solutions as they can provide any class, which is accessible to the project. The project does not need to own whole dependency.

Need of Android Context ? Pass the input of the function with the @ApplicationContext as-in code sample below. In the same way, you can pass other dependencies from the application, which come from other modules.

@Provides  
fun provideAppSettings(@ApplicationContext context: Context,   
                                           prefrences: Preferences): Preferences {  
  return AppSettings(context, preferences)  
}

Binds

@Binds has more limited usage and is a bit less intuitive, but brings some advantages over the @Provides like less code to define it and more efficiency during the compilation of the code = faster build times.

The @Binds is used with abstract classes and interfaces as an output type of abstract function, which takes in input in a required instance.

@Module  
@InstallIn(SingletonComponent::class)  
abstract class PreferencesDi { // abstract class with abstract function  
  
    @Binds // the implementation is passed as input with expected interface as output  
    abstract fun providePreferences(preference: PreferencesImp): Preferences  
  
}

With this sample of code, the result is the same with the @Provides. That is why if you build some simple small application, the usage of either the @Provides or @Binds is not that decisive.

If you own the implementation or you have interface, which cannot use constructor injection, go with @Binds . Otherwise, pick the @Module .

Android components and scopes

Component lifetime

Component lifetime is determined by the @InstallIn keyword from the Hilt. It declares at which point the dependency will become available in the app.

You can imagine it as layers activated in the queue and how the app becomes alive. If the app is started, all the singletons in SingletonComponent are initialized. If the activity is created, all the components installed in the ActivityComponent are initialized. Components activated sooner are available to other components but not vice versa. If the dependency is initialized, it is initialized with new instances of needed dependencies. The order in which the dependencies are initialized is in the table below.

Lay down the modules so that the dependencies will become available only if they are needed. There is no need to install everything into a singleton component. It will increase robustness of the solution and speed of the app.

Component Scope

As it was mentioned, if the class is constructed at the component, it gets new instances of all dependencies. This means every component is unscoped by default.

To change that, you can add scopes to every module in the following way.

@Module   
@InstallIn(SingletonComponent::class)   
object PreferencesDI {  
  
  @Provides  
  @Singleton // declares scoped singleton  
  fun providePreferences(): Preferences {  
    return PreferencesImp()  
  }  
}

Scoping the dependencies means creating one instance of the dependency and passing it to all the classes requiring it. The instance is relieved with the component, to which it is scoped. So, if we want to make preferences shared across the app, we can declare singleton scope on top of the method, which provides it. Scoping annotations can be found below.

Scoping is expensive and should be avoided, if it is possible, because it takes memory by preserving all the instances. Mainly Singleton statements should be avoided as they stay for full existence of the app.

Full example in Jetpack compose

Setup

Firstly, the project needs to contain the required dependencies at the beginning of the article.

Secondly, create class which inherits the Application class and adds the @HiltAndroidApp . Here is one example:

@HiltAndroidApp  
class AppHiltExample : Application()

After initialisation of the Hilt, if the main activity interacts with ViewModels or any other registered components — add the @AndroidEntryPoint at top of the Activity.

@AndroidEntryPoint  
@ExperimentalAnimationApi  
class MainActivity : ComponentActivity() {  
  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        setContent {  
            val vm: IntroViewModel = hiltViewModel()  
            val isOnboardedState = vm.isOnboarded.collectAsState()  
            MainCompose(isOnboardedState = isOnboardedState.value)  
        }  
    }  
}

If this tag is missing, you can get error which describes that some ViewModel was called with empty constructor, even if there are some input variables required.

HiltViewModel in the Jetpack Compose can be instantiated via factory hiltViewModel(). If you define type of the value, the Hilt will give the instance, which you need. The best practise is to decouple the actual composables from the ViewModels as it is done in example. The composable should take only current state object on which it depends, not whole ViewModel.

App introduction

Let’s have a look at what we are going to build. It is a simple app with a serial introduction launched at the first start. After the intro, the user can use the app and can be navigated via the navigation drawer. On the home screen, there is a button, which will open the empty mail.

The workflow is orchestrated by the AppViewModel, which will navigate the user if he finishes the onboarding.

Navigation in the sample app

ViewModel definition

@HiltViewModel  
class AppViewModel @Inject constructor(  
    private val settingsRepo: SettingsRepo, private val mailClient: MailClient  
) : ViewModel() {  
  
    private val _isOnboarded: MutableStateFlow<AppState> = MutableStateFlow(AppState.NotOnboarded)  
    var isOnboarded = _isOnboarded.asStateFlow()  
  
    init {  
        _isOnboarded.value =  
            if (settingsRepo.isOnboarded()) AppState.Onboarded else AppState.NotOnboarded  
    }  
  
    fun onEvent(appEvent: AppEvent) = when (appEvent) {  
        AppEvent.FinishOnboarding -> saveUserOnboarding()  
        AppEvent.SendMail -> sendMail()  
    }  
  
    private fun saveUserOnboarding() {  
        _isOnboarded.value = AppState.Onboarded  
        settingsRepo.saveOnboardingState(true)  
    }  
  
    private fun sendMail() = mailClient.sendMail()  
  
}

Hilt comes with special annotation for the ViewModels @HiltViewModel, which makes handling ViewModels easy. The ViewModel contains one state, which holds information about the state of onboarding. The state can be loaded in from app settings or changed via its method. Based on the state, the app changes the navigation graph. Moreover, it contains mail client, which will open your mail on the phone.

If you are interested in nested navigation in JetPack Compose, read my other article here

Defining the dependency

Firstly, the Settings and Mail client repository areabstracted by the interface.

// template for behaviour of the settings  
interface SettingsRepo {  
    fun isOnboarded(): Boolean  
    fun saveOnboardingState(isOnboarded: Boolean)  
}  
  
// template to send mail  
interface MailClient  {  
    fun sendMail()  
}

Secondly, let’s define implementation for such settings and mail client in the app. For tutorial sake, the example is rather simpler. In a real implementation, the app would contain a proper database, shared preferences and user real mail information, but now the app will work only with variable.

// do not forget to add @Inject. Otherwise the Hilt will miss this dependency in @Binds  
class SettingsRepoImp @Inject constructor(): SettingsRepo {  
  
    // rest of the simple implementation to change the onboarding state  
    private var isOnboardedParam: Boolean = false  
  
    override fun isOnboarded(): Boolean = isOnboardedParam  
  
    override fun saveOnboardingState(isOnboarded: Boolean) {  
        Log.i(TAG, "saveOnboardingState: $isOnboarded")  
        isOnboardedParam = isOnboarded  
    }  
  
    companion object {  
        const val TAG = "SettingsRepoImp"  
    }  
}  

// this dependency requires context, so the app will use @Provides and @Inject is not needed  
class MailClientImp constructor(private val context: Context): MailClient {  
  
    override fun sendMail() {  
        Intent(Intent.ACTION_SENDTO).also {  
            it.data = Uri.parse("mailto:")  
            it.flags = Intent.FLAG_ACTIVITY_NEW_TASK  
            context.startActivity(it)  
        }  
    }  
  
}

Thirdly, let’s define the module to provide our app settings. The app has ownership of the class, so the Binds annotation is the right way to go. The app needs to create a module, which is installed into the ViewModel component, as it is enough that the settings are available for the ViewModel. They would be redundant sooner.

@Module  
@InstallIn(ViewModelComponent::class) // the dependencies are passed to viewmodel  
abstract class SettingsDi {  
  
    @Binds // passing implementation of the class, which inherits the interface  
    abstract fun provideAppSettings(appSettings: SettingsRepoImp): SettingsRepo  
  
}  
  
@Module  
@InstallIn(ViewModelComponent::class)  
object MailDi {  
  
    @Provides // the instance needs to be initialized by providing context  
    fun provideEmailClient(@ApplicationContext context: Context): MailClient =  
        MailClientImp(context)  
  
}

If everything has been done right, you can click build and the app should run. The onboarding should can be passed and afterwards, the home screen is shown with the button and drawer.

The advantage is that the UI is fully decoupled from the implementation and the ViewModel does not now specifically, what is hidden under the repositories. So changes in the repositories, will not influence the ViewModel and UI. Moreover, unit testing of the structure is now much more simple.

Here is wrap-up, of what is happening from Hilt’s perspective.

For the full code, go here

Resources

Developer Android documentation

Subscribe for more
LinkedIn GitHub Medium Threads X Bluesky