Dev Journal #16 - Dependency Injection using Hilt



Motivations for using Dependency Injections

This is a follow-up post to Part 1 and Part 2 of the Android App Architecture series. While implementing this MVVM architecture, I had to create several classes that required dependencies on many other classes. Some of these classes produced boilerplate codes that needed to be repeated multiple times to initializes the dependencies. Soon I realized that this will only get worse as the app grows. One of the ways you can maintain a simpler dependency relationship between classes is by following the Dependency Injection design pattern.

Dependency injection is one way of applying inversion of control. Instead of the client requesting which services it will use, the injector defines what services can be used by passing the dependency into the client object. Key benefits of this proposed designs are:

But of course there are some downsides to the design as well. The main issue is the increased level of complexity caused by the additional layer of abstraction. Dependency injection can make the code more difficult to trace especially for the new developers in the project.

Dagger & Hilt

Hilt is the recommended dependency injection library for Android. Hilt is built on top of the Dagger library to provide a standard way of implementing Dagger into an Android app by generating Dagger setup codes.

In the following sections, I’ll explain the steps that I took to refactor my project with dependency injection using Hilt.

We need to first import the libraries into the project by declaring them in the app-level build.gradle file:

apply plugin: "dagger.hilt.android.plugin"

dependencies {
    // Hilt
    implementation "com.google.dagger:hilt-android-gradle-plugin:2.37"
    implementation "com.google.dagger:hilt-android:2.37"
    kapt "com.google.dagger:hilt-compiler:2.37"
}

and to the project-level:

buildscript {
    dependencies {
        classpath "com.google.dagger:hilt-android-gradle-plugin:2.37"
    }
}

The first annotation you will need to add to the project is the @HiltAndroidApp to the base application class. This annotation will trigger Hilt’s code generation that will take care of injecting members into the Android classes that have the @AndroidEntryPoint annotation.

@HiltAndroidApp
class FindingFitnessApplication : Application() {
    // ...
}

@AndroidEntryPoint
class FindingFitnessActivity : AppCompatActivity() {
    // ...
}

Constructor injection

One way to provide the instance of the dependency to the client is through using the constructor injection. This is demonstrated in Hilt View Models which are constructor injected ViewModels by Hilt. Simply annotate the ViewModel with @HiltViewModel and they will be available for creation by the HiltViewModelFactory.

@HiltViewModel
class EditProfileViewModel @Inject constructor(
    private val repository: UserProfileRepository,
    application: Application
) : AndroidViewModel(application) {
    // ...
}

Then an activity or fragments annotated with @AndroidEntryPoint can get the ViewModel instance by using ViewModelProvider. This means that you can skip the ViewModelProvider.Factory boilerplate class.

@AndroidEntryPoint
class EditProfileFragment : Fragment() {
    // ...
    private lateinit var editProfileViewModel: EditProfileViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        editProfileViewModel = ViewModelProvider(this).get(EditProfileViewModel::class.java)
        // ...
    }
}

Compare the above code snippet to the following and see how much fewer lines of code were needed to achieve the same thing when using Hilt:

Before using Hilt
class EditProfileFragment : Fragment() {
    private lateinit var editProfileViewModel: EditProfileViewModel
    // ...

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val application = requireNotNull(this.activity).application
        val userProfileDao = FindingFitnessDatabase.getInstance(application).userProfileDao
        val networkService = NetworkService(application)
        val sharedPreferences: SharedPreferences =
            application.getSharedPreferences(APP_PREFERENCES, Context.MODE_PRIVATE)
        val repository = UserProfileRepository(userProfileDao, networkService, sharedPreferences)
        val viewModelFactory = EditProfileViewModelFactory(repository, application)

        editProfileViewModel =
            ViewModelProvider(this, viewModelFactory).get(EditProfileViewModel::class.java)
        // ...
    }
}

Hilt modules

Not all types can be injected by a constructor. You can’t constructor-inject an interface or an external library. For these dependencies, you can still bind them to Hilt by using Hilt modules annotated by @Module. In my case, all of the dependencies declared inside this module require the same instance to be used every time throughout the app. So SingletonComponent is the appropriate scope to use for the component binding in this case. The SingletonComponent provides the Application binding by default. It is available for use either as @ApplicationContext Context or Application.

@Module
@InstallIn(SingletonComponent::class)
class PersistenceModule {

    @Provides
    @Singleton
    fun provideNetworkService(application: Application): NetworkService {
        return NetworkService(application)
    }

    @Provides
    @Singleton
    fun provideSharedPreferences(application: Application): SharedPreferences {
        return application.getSharedPreferences(Constants.APP_PREFERENCES, Conqtext.MODE_PRIVATE)
    }

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): FindingFitnessDatabase {
        return FindingFitnessDatabase.getInstance(context)
    }

    @Provides
    @Singleton
    fun provideUserProfileDao(findingFitnessDatabase: FindingFitnessDatabase): UserProfileDao {
        return findingFitnessDatabase.userProfileDao()
    }
}

Now the repository class can directly access the dependencies using the constructor injection like the following:

@Singleton
class UserProfileRepository @Inject constructor(
    private val userProfileDao: UserProfileDao,
    private val networkService: NetworkService,
    private val sharedPreferences: SharedPreferences
) {
    // ...
}

It is also annotated as a singleton class which means there will only ever be one instance of the repository that is shared across the app. The repository instance can be constructor-injected into other classes such as the ViewModel example from earlier.

Conclusion

Switching over to using dependency injection in my project was a no-brainer. I only had to change a small amount of code, most of which was removing redundant lines. It also resolved the synchronization issue on repository instances I had earlier by annotating it as a singleton object. If you were once intimidated by Dagger in the past, I would highly recommend trying out Hilt!

Previous Post