Exploring Hilt for Dependency Injection in XML Projects

Dependency Injection (DI) is a crucial technique for building maintainable and testable applications. While modern Android development often embraces Kotlin and Jetpack Compose, many projects still rely on XML layouts and the traditional Android framework. Integrating Hilt, a popular DI library, into such projects can significantly improve their architecture.

What is Hilt?

Hilt is a dependency injection library for Android that reduces the boilerplate of doing manual dependency injection in your project. Built on top of Dagger, Hilt provides a standard way to incorporate DI into Android applications, streamlining development and making testing easier.

Why Use Hilt in XML-Based Projects?

  • Reduced Boilerplate: Hilt automates the process of creating and providing dependencies, reducing manual work.
  • Improved Testability: Makes it easier to write unit and integration tests by allowing easy replacement of dependencies with test doubles.
  • Centralized Dependency Management: Provides a clear and maintainable structure for managing dependencies.
  • Standardized Approach: Hilt offers a consistent way to perform DI across your application.

How to Integrate Hilt into XML Projects

To integrate Hilt into an XML-based Android project, follow these steps:

Step 1: Add Hilt Dependencies

Add the Hilt Gradle dependencies to your project-level build.gradle file:


plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'dagger.hilt.android.plugin'
    id 'kotlin-kapt'
}

android {
    ...
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.48")
    kapt("com.google.dagger:hilt-android-compiler:2.48")
}

// Add Kotlin compiler options
kapt {
    correctErrorTypes = true
}

And ensure that you’ve applied the Hilt and Kotlin Kapt plugins in your app-level build.gradle:


apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

Remember to sync your project after adding the dependencies.

Step 2: Create an Application Class and Annotate it with @HiltAndroidApp

Hilt requires an application class annotated with @HiltAndroidApp. If you don’t have one, create it:


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

@HiltAndroidApp
class MyApplication : Application()

Make sure to declare this class in your AndroidManifest.xml file:


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

Step 3: Annotate Activities and Fragments

Annotate your Activities and Fragments with @AndroidEntryPoint. This annotation signals Hilt to inject dependencies into these classes.


import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import android.widget.TextView

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var someDependency: SomeDependency

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView: TextView = findViewById(R.id.textView)
        textView.text = someDependency.doSomething()
    }
}

Step 4: Provide Dependencies

Create modules to tell Hilt how to provide instances of dependencies. Use the @Module and @InstallIn annotations to define Hilt modules.


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

class SomeDependency {
    fun doSomething(): String {
        return "Hello from SomeDependency!"
    }
}

@Module
@InstallIn(ActivityComponent::class)
object AppModule {
    @Provides
    fun provideSomeDependency(): SomeDependency {
        return SomeDependency()
    }
}

In this example:

  • @Module: Indicates that this class is a Hilt module.
  • @InstallIn(ActivityComponent::class): Specifies that this module is installed in the ActivityComponent, meaning it’s available for Activities.
  • @Provides: Informs Hilt how to create an instance of SomeDependency.

Step 5: Inject Dependencies

Use the @Inject annotation to inject dependencies into your classes. Ensure that the field you are injecting into is a lateinit var if it cannot be initialized in the constructor.


import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import android.widget.TextView

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var someDependency: SomeDependency

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView: TextView = findViewById(R.id.textView)
        textView.text = someDependency.doSomething()
    }
}

Example: Integrating Hilt with Retrofit

A common use case is integrating Hilt with Retrofit for network requests. Here’s how you can set that up:

Step 1: Define API Interface

Create an interface for your API endpoints:


import retrofit2.http.GET
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Inject

interface ApiService {
    @GET("/todos/1")
    fun getTodo(): Call<Todo>
    
    companion object {
        var apiService: ApiService? = null
        fun getInstance() : ApiService {
            if (apiService == null) {
                apiService = Retrofit.Builder()
                    .baseUrl("https://jsonplaceholder.typicode.com")
                    .addConverterFactory(GsonConverterFactory.create())
                    .build().create(ApiService::class.java)
            }
            return apiService!!
        }
    }
}

data class Todo(
    val userId: Int,
    val id: Int,
    val title: String,
    val completed: Boolean
)

Step 2: Provide Retrofit and API Service Instances

Create a Hilt module to provide Retrofit and API service instances:


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

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

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

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

In this setup:

  • @Singleton ensures that only one instance of Retrofit is created for the entire application.
  • provideRetrofit() builds the Retrofit instance with the base URL and converter factory.
  • provideApiService() creates the API service from the Retrofit instance.

Step 3: Inject API Service into ViewModel or Presenter

If you are using MVVM or MVP, inject the ApiService into your ViewModel or Presenter.


import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import androidx.lifecycle.MutableLiveData

@HiltViewModel
class MyViewModel @Inject constructor(
    private val apiService: ApiService
) : ViewModel() {

    val todoLiveData = MutableLiveData<Todo>()

    fun fetchTodo() {
        apiService.getTodo().enqueue(object : Callback<Todo> {
            override fun onResponse(call: Call<Todo>, response: Response<Todo>) {
                if (response.isSuccessful) {
                    todoLiveData.value = response.body()
                } else {
                    // Handle error
                }
            }

            override fun onFailure(call: Call<Todo>, t: Throwable) {
                // Handle failure
            }
        })
    }
}

In your Activity or Fragment:


import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint
import androidx.activity.viewModels
import android.widget.TextView
import androidx.lifecycle.Observer

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val viewModel: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val textView: TextView = findViewById(R.id.textView)

        viewModel.todoLiveData.observe(this, Observer { todo ->
            textView.text = todo?.title ?: "Loading..."
        })

        viewModel.fetchTodo()
    }
}

Benefits and Considerations

  • Benefits:
    • Easier management of dependencies.
    • Improved testability with mockable dependencies.
    • Cleaner code and separation of concerns.
  • Considerations:
    • Requires careful planning and understanding of DI principles.
    • Can introduce a learning curve for developers unfamiliar with DI.

Conclusion

Integrating Hilt into XML-based Android projects brings numerous benefits, including reduced boilerplate, improved testability, and better dependency management. By following the steps outlined above, you can modernize your existing projects and ensure a more maintainable and scalable architecture. While it may require some initial setup and learning, the long-term advantages make Hilt a worthwhile addition to any Android project.