What is Dependency Injection (DI)?
Before answering the question, let’s split them and understand what each word means. A dependency is an object or a thing that is required by another object or a thing to function and Injection is a technique that passes the dependency to the dependent object.
So, Dependency Injection is a technique that makes dependent objects independent and helps in making the code more reusable, better code structure, and easy to test or debug.
Let’s understand dependency injection with the help of an example.
interface Engine { fun startEngine() } class EngineA: Engine { override fun startEngine() { // ...... } } class EngineB: Engine { override fun startEngine() { // ...... } } class Car { val engine = EngineA() fun startCar() { engine.startEngine() } } fun main() { val car = Car() car.startCar() }
A car is dependent upon the engine to function and there are different types of engines in the market. In the above code snippet, the Car class creates the instance of EngineA which is later used to start the car. The above code works in a general scenario but what if we want to upgrade the engine of the car, say EngineB, which is a bit costly, with the above code snippet we will have to modify the Car class to replace EngineA with EngineB but the problem now is that if the user wants to buy a car with EngineA he won’t be able to do that because now the car has only EngineB, and in this way, we lost a customer or probably a dozen. But don’t worry, dependency injection will solve that problem for us.
interface Engine { fun startEngine() } class EngineA: Engine { override fun startEngine() { // ...... } } class EngineB: Engine { override fun startEngine() { // ...... } } class Car { lateinit var engine: Engine fun startCar() { engine.startEngine() } } fun main() { val engineA = EngineA() val carA = Car() carA.engine = engineA carA.startCar() val engineB = EngineB() val carB = Car() carB.engine = engineB carB.startCar() }
Here, in the above snippet, we have injected the Engine dependency by assigning the type of engine to the engine field of the Car class. Now we can create multiple cars with different engines and if a new type of engine comes then we just have to assign the new instance of the engine to the engine field of the Car class and voilà we are done!
How to achieve DI in Android?
- Manual Dependency Injection — In the previous example, we had handled the dependency on our own without relying on libraries. This is called manual dependency or dependency injection by hand.
- Automated Dependency Injection — Manual dependency requires creating lots of boilerplate code and if we are building a big app with different features then it will be time-consuming and could lead to chaos if not maintained correctly. So, there are libraries that will automate all these processes by creating the boilerplate codes which you would have written manually. Currently, the popular library for automating the DI process is Hilt, a library built on top of the popular DI library, Dagger.
What is Dagger or Dagger 2?
Dagger is a dependency injection framework created by developers at Square for java and android. Later, the developers at Google collaborated on the project making it an improved and faster version. This was called Dagger2. Dagger2 is a compile-time framework and it generates the code with the help of the annotations. It also added support for the kotlin language.
We won’t be exploring DI with Dagger in Android as we will be using Hilt for that. We will talk more about the Hilt in the below section.
What is Hilt? And Why it was built?
Hilt is a dependency injection library built on top of Dagger and it was built specifically for Android applications. The problem with Dagger was that it was difficult to configure when it comes to Android, so Hilt was built with the below goals –
- To simplify dagger-related infrastructure for Android apps
- Easy to setup
- Lets you focus on what is important
Setting up your Project
Add the hilt-android-gradle-plugin plugin to your project’s root build.gradle file
buildscript { ... ext.hilt_version = '2.35' dependencies { ... classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" } }
Apply the Gradle plugin and add these dependencies in your app/build.gradle file
plugins { kotlin("kapt") id("dagger.hilt.android.plugin") } android { ... } dependencies { implementation("com.google.dagger:hilt-android:$hilt_version") kapt("com.google.dagger:hilt-android-compiler:$hilt_version") }
Hilt uses Java 8 features. To Enable Java 8 in your project, add the following to the app/build.gradle file
android { ... compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } }
Hilt Annotations
@Inject
When this annotation is used with the constructor, it tells which constructor is to be used while providing instances and which dependencies the type has.
When this annotation is used on the fields then it populates the field with its value. The fields cannot be private.
@HiltAndroidApp
An app that uses hilt for dependency injection must contain an Application class that is annotated with @HiltAndroidApp. This annotation triggers Hilt’s code generation for different hilt components present in our app. Without having this annotation in our project, hilt won’t be functional. Suppose, we have a class SomeClass that we want to inject into various components then it could be done in the below manner. Note, the initialization of someClass in the MyApplication is handled by the Hilt with the help of @Inject annotation and we are just accessing the variable.
@HiltAndroidApp class MyApplication : Application() { @Inject lateinit var someClass: SomeClass override fun onCreate() { super.onCreate() someClass.printLog() } } class SomeClass @Inject constructor() { fun printLog() { Log.i("DI Success") } }
@AndroidEntryPoint
Just like @AndroidHiltApp, this annotation is used by Activity, Fragment, View, Service, and BroadcastReceiver, to initialize the code generation in them.
@HiltEntryPoint class SomeActivity : AppCompatActivity() { @Inject lateinit var someClass: SomeClass override fun onCreate() { super.onCreate() setContentView(R.layout.activity_some) someClass.printLog() } }
Hilt currently only supports activities that extend ComponentActivity and fragments that extend androidx library Fragment, not the now deprecated Fragment in the android platform. Only non-retained fragments are supported by Hilt to avoid memory leaks, a runtime exception is thrown if any fragment is retained.
@HiltViewModel
This annotation tells Hilt how to provide instances of a ViewModel.
@HiltViewModel class MyViewModel @Inject constructor(private someClass: SomeClass, private val state: SavedStateHandle) : ViewModel() { ... } @HiltEntryPoint class SomeActivity : AppCompatActivity() { private val viewModel: MyViewModel by viewModels() }
@Module
There are certain classes that cannot be constructor injected, in that case, we use @Module annotations. It is used in conjunction with another annotation, @InstallIn.
@InstallIn
The module is installed in the components specified with the component type argument passed in the @InstallIn annotation.
@Provides
With the help of this annotation, the fields that cannot be constructor injected are bound. It provides the value of the field and its return type is the binding type.
class AnotherClass(private val someClass: SomeClass) { ... } @Module @InstallIn(SingletonComponent::class) class MyModule { @Provides fun provideAnotherClass(someClass: SomeClass): AnotherClass { return AnotherClass(someClass) } }
In the above code snippet, hilt isn’t aware of how to provide an instance of AnotherClass, so we created a module MyModule that will provide an instance of AnotherClass.
@Binds
@Binds is similar to @Provides but it is used with the abstract classes and interfaces. With it, we can skip the implementation but there is a catch that you can pass only one parameter that is the implementation of the interface to the abstract method and its return type will be the binding type i.e. the interface.
interface Engine { ... } class DieselEngine: Engine { ... } class PetrolEngine: Engine { ... } @Module @InstallIn(SingletonComponent::class) abstract class DieselModule { @Binds abstract fun bindsDieselEngine(myEngine: DieselEngine): Engine } @Module @InstallIn(SingletonComponent::class) abstract class PetrolModule { @Binds abstract fun bindsPetrolEngine(myEngine: PetrolEngine): Engine }
@EntryPoint
We have seen @AndroidEntryPoint that is used by Activities, Fragments, Views, Services, and BroadcastReceivers which are all android components but what if, we need to obtain dependencies in the class that is not supported by Hilt or cannot use Hilt, at that point, we can use @EntryPoint annotation.
For example, we are building an app that has different APIs for production and developer environments. Without the use of @EntryPoint, our code would look like something like below –
@AndroidEntryPoint class MainActivity : AppCompatActivity() { @Inject lateinit var prodAPI: AppProdAPIProvider @Inject lateinit var developerAPI: AppDevAPIProvider override fun onCreate() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) if (environment == "production") { prodAPI.getData() } else { developerAPI.getData() } } }
In the above snippet, we had injected both the API providers. The code will work fine but the problem is that one field will be left unused, if are in the production environment, then we will be only using prodAPI and won’t be using developerAPI which will result in wasteful injection. To avoid this, we can use the @EntryPoint annotation.
@EntryPoint @InstallIn(ActivityComponent::class) interface ApiEntryPoint { fun getProdAPI(): AppProdAPIProvider fun getDeveloperAPI(): AppDevAPIProvider } @AndroidEntryPoint class MainActivity : AppCompatActivity() { override fun onCreate() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val apiEntryPoint = EntryPoint.get(this, ApiEntryPoint::class.java) if (environment == "production") { apiEntryPoint.getProdAPI().getData() } else { apiEntryPoint.getDeveloperAPI().getData() } } }
@DefineComponent
Components are used to assign lifetime for dependencies, lifetime is basically, the duration between birth and death. Hilt provides standard components for Application, Activity, Fragments, Service, and View.
Pre-defined Components
There might be a situation where the above components don’t satisfy our requirements and creating a custom component is the only choice, then @DefineComponent annotation will be used. For example, we want to have a component that will maintain user details for the duration between the user log-in and log-out.
/** * Since we are creating a custom component we will also need to create * a custom scope for that component. **/ @Scope @MustBeDocumented @Retention(value = AnnotationRetention.RUNTIME) annotation class LoggedUserScope @LoggedUserScope @DefineComponent(parent = SingletonComponet::class) interface UserComponent { @DefineComponent.Builder interface UserComponentFactory { fun build(): UserComponent } } /** * User-defined components require a handler that will handle the lifetime of * the component like when to create and destroy the custom components **/ @Singleton class UserComponentHandler @Inject constructor(private val factory: UserComponent.Builder) : GeneratedComponentManager<UserComponent> { var userComponent: UserComponent? = null private set fun login() { userComponent = factory.build() } fun logout() { userComponent = null } override fun generatedComponent(): UserComponent { return userComponent!! } } data class UserModel( val username: String, val userId: String, val phoneNumber: Long) @Module @InstallIn(UserComponent::class) object UserModule { @LoggedUserScope @Provides fun providesUserDetails(userData: UserData): UserModel { return UserModel(userData.userName, userData.userId, userData.phoneNumber) } } @EntryPoint @InstallIn(UserComponent::class) interface UserComponentEntryPoint { fun getUserDetails(): UserDetails } @AndroidEntryPoint class MainActivity : AppCompatActivity() { override fun onCreate() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val userEntryPoint = EntryPoint.get(this, UserComponentEntryPoint::class.java) val userData = userEntryPoint.getUserDetails() } }
Hope everyone was able to understand the dependency injection and how it can be achieved in the android application with the help of Hilt. If you want to have a practical code on using all the above-mentioned annotations or if you want to migrate your existing application that uses Dagger to Hilt then you can check out the codelabs link mentioned below. To have a more detailed understanding of this topic, you can check out the References section at the bottom.