DEV Community

Cover image for Code clean up using dependency injection with Hilt in Android
Tristan Elliott
Tristan Elliott

Posted on • Updated on

Code clean up using dependency injection with Hilt in Android

Table of contents

  1. What we are doing
  2. The mental model
  3. The Application class
  4. The Hilt components
  5. Hilt bindings
  6. Hilt modules
  7. @Binds
  8. @Provides
  9. Scoping
  10. Resources

The code

Introduction

  • I have embarked on my next app, a Twitch client app. This series will be all my notes and problems faced when creating this app.

Getting started

  • I wont spend any time on setting up the dependencies for Hilt. You can find that in the documentation, HERE

What we are doing

  • Through the power of dependency injection with Hilt, we will take our code from looking like this:
class DataStoreViewModel(
    application:Application,
):AndroidViewModel(application) {

    private val tokenDataStore:TokenDataStore = TokenDataStore(application)
    val twitchRepoImpl: TwitchRepo = TwitchRepoImpl()

}

Enter fullscreen mode Exit fullscreen mode
  • To this:
@HiltViewModel
class DataStoreViewModel @Inject constructor(
    private val twitchRepoImpl: TwitchRepo,
    private val tokenDataStore:TokenDataStore
): ViewModel() {
}

Enter fullscreen mode Exit fullscreen mode
  • Not only does our code look cleaner but it will also be easier to test.

The mental model

  • If you are unfamiliar with dependency injections, then it might be hard to actually grasp what Hilt is doing. Which is why I like to us this mental model:

diagram of Hilt dependency injection

  • Essentially Hilt will create components and anytime our code needs a dependency, we can tell it to get that dependency from a Hilt component.

Application class

  • Assuming we both now have the proper dependencies, the next step is to annotate a class with @HiltAndroidApp. As it is stated in the documentation:

All apps that use Hilt must contain an Application class that is annotated with @HiltAndroidApp. @HiltAndroidApp triggers Hilt's code generation, including a base class for your application that serves as the application-level dependency container.

  • We can create this class like so:
@HiltAndroidApp
class HiltApplication:Application() {

}

Enter fullscreen mode Exit fullscreen mode
  • Just make sure that it is declared inside the AndroidManifest.xml file:
<application
        android:name=".di.HiltApplication"

Enter fullscreen mode Exit fullscreen mode

Android Entry points

  • To allow Hilt to inject dependencies into our code, we must annotate our classes with specific annotations. A full list of annotations can be found HERE. But since we want to inject code into a ViewModel we need to annotation said ViewModel with @HiltViewModel.

  • IMPORTANT : it is important to note that when you annotate a class with a Hilt annotation, you must also annotate all the classes that rely on it with the appropriate annotation. Which means if we annotate a ViewModel with @HiltViewModel, then we must annotate the Fragment with @AndroidEntryPoint and the surrounding activity with @AndroidEntryPoint.

The components

  • Annotating our classes with @HiltViewModel and @AndroidEntryPoint generates an individual Hilt component for each annotated Android class. These components will have a lifecycle tied directly to the class they are annotating. A detailed diagram explaining the lifecycles can be found HERE
  • So to create a Hilt component we simply apply the annotation:
@HiltViewModel
class DataStoreViewModel(
    application:Application,
):AndroidViewModel(application) {

    private val tokenDataStore:TokenDataStore = TokenDataStore(application)
    val twitchRepoImpl: TwitchRepo = TwitchRepoImpl()

}

Enter fullscreen mode Exit fullscreen mode

Hilt bindings

  • Binding is a term used a lot through out the documentation. So lets define it, we can think of a binding as an object that informs Hilt how it should create our dependencies. We tell hilt to use a binding by adding @Inject constructor to the primary constructor.
  • We can now move tokenDataStore and twitchRepoImpl into the primary constructor:
@HiltViewModel
class DataStoreViewModel @Inject constructor(
    private val twitchRepoImpl: TwitchRepo,
    private val tokenDataStore:TokenDataStore
): ViewModel() {
}

Enter fullscreen mode Exit fullscreen mode
  • This might look nice but it does not work yet and that is because we have not defined any bindings to tell Hilt how to create these dependencies.

  • depending on your needs simply adding Hilt and adding the @Inject constructor annotation may be all you need. Try running your app and see if it crashes or not. If your app crashes then you need to create more specific bindings and we can do so through Hilt modules

Modules

  • As previously mentioned Modules are used to create more specific bindings, which Hilt will use to create our dependencies. To create a module we need to create a new class and annotate it with 2 specific annotations, @Module and @InstallIn:
@Module //defines class as a module
@InstallIn(ViewModelComponent::class)
abstract class ViewModelModule {

}
Enter fullscreen mode Exit fullscreen mode
  • The @InstallIn annotation is used to define which Hilt component our module is installed in. These modules will then provide Hilt with information on how to create bindings. For our ViewModelComponent::class states this module will be stored in the ViewModel Component.

Inject interface instances with @Binds

  • The whole point of a module is to give Hilt the appropriate information so that it can create bindings which is then used to create an instance of the appropriate dependency. For my code I want Hilt to inject an interface,TwitchRepo. We can inject interfaces with @Binds, like so:
@Module
@InstallIn(ViewModelComponent::class)
abstract class ViewModelModule {

    @Binds
    abstract fun bindsTwitchRepo(
        twitchRepoImpl: TwitchRepoImpl
    ):TwitchRepo

}

Enter fullscreen mode Exit fullscreen mode
  • With the @Binds annotation we create an abstract function and give it two pieces of information:

1)The function return type : This tells Hilt what interface our function provides instances of.

2)The function parameter : This tells Hilt which implementation to provide.

  • With this new dependency that we have created inside our module we have told Hilt that anytime a ViewModel needs an instance of TwitchRepo it should instantiate TwitchRepoImpl and pass it to our code. Notice How I stated, That anytime a ViewModel, this dependency is only available in a ViewModel due to the Component hierarchy
  • Now that we have the basics we can get a little more complicated

Inject instances with @Provides

  • Along with @Binds we can also use another annotation called @Provides inside of our modules. Generally we will use the provides over binds annotation for two reasons. 1) we do not own the class we want Hilt to instantiate or 2) we want to provide more details to Hilt. Ultimately the code will look like this:
@Module
@InstallIn(SingletonComponent::class)
object SingletonModule {

    @Singleton
    @Provides
    fun providesTwitchClient(): TwitchClient {
        return Retrofit.Builder()
            .baseUrl("https://api.twitch.tv/helix/")
            .addConverterFactory(GsonConverterFactory.create())
            .build().create(TwitchClient::class.java)
    }

    @Singleton
    @Provides
    fun providesTokenDataStore(
        @ApplicationContext appContext: Context
    ): TokenDataStore {
        return TokenDataStore(appContext)
    }
}

Enter fullscreen mode Exit fullscreen mode
  • As we have mentioned earlier @InstallIn(SingletonComponent::class) means that all of these dependencies will be stored in the SingletonComponent. As per the component hierarchy this means our these dependencies will be available to all of our code. Now we need to talk a little about how this is scoped

Scoping

  • In documentation and blog posts you will constantly see the quote: By default, all bindings in Hilt are unscoped. This means that each time your app requests the binding, Hilt creates a new instance of the needed type. So even in our SingletonModule every time Hilt creates an instance TwitchClient or TokenDataStore it will be a new instance. Which is not what we want. In order to fix this, we need to scope our dependencies to the SingletonComponent.This is done with the @Singleton annotation. This tells hilt to only create TwitchClient and TokenDataStore once and reuse the same instance.

  • It is worth pointing out that the @Singleton annotation should only be used if it is necessary for your code to function, which it is for mine. I only want one Retrofit instance and TokenDataStore is a Datastore which only allows one instance.

Resources

Conclusion

  • Thank you for taking the time out of your day to read this blog post of mine. If you have any questions or concerns please comment below or reach out to me on Twitter.

Top comments (0)