Dependency Injection is one of the hottest topics in Android and software development in general. It's also a topic that can provide a lot of anxiety and create imposter syndrome for developers.
In this post, we'll take incremental steps toward understanding DI, why we need it, and how to implement it inside our applications.
If interested, you can find this content in video form on YouTube:
It's also been published on Android Essence.
Code Without Dependency Injection
To start, let's review problems we might face if we write code without dependency injection. Consider the following code, where we have a ProfileViewModel class, and we want to track an event every time the user views a profile. This way we can see how common the screen is compared to others.
class ProfileViewModel() {
fun onProfileLoaded() {
Firebase.analytics.logEvent("viewed_profile")
}
}
This code doesn't look like it causes any problems at first glance. In fact, if you look at the documentation for Firebase Analytics and similar tools, it will suggest writing code like this. So don't be ashamed if your codebase looks something like this. For code like this, there are two specific problems I'd lke to highlight:
- Our code relies so heavily on Firebase Analytics, that if we wanted to change to another vendor we'd have to update every ViewModel in the application.
- We will have difficulty writing tests for this piece of code.
Testing Difficulties
A good unit test to write for this piece of code is one that verifies when onProfileLoaded
is called, that we also track the correct analytics event. If we start writing this test, though, we'll recognize a problem very quickly. How can we verify a call was made to Firebase?
class ProfileViewModelTest {
@Test
fun verifyEventTracked() {
val viewModel = ProfileViewModel()
viewModel.onProfileLoaded()
// No Ability To Verify Event Tracked
}
Not only that, this particular test will just crash. We'll get a RuntimeException that our main looper is not mocked, because of some work that's happening inside the Firebase Analytics library:
Caused by: java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked.
Dependency Injection
Our code has a dependency on Firebase Analytics. We can help solve this testing problem by injecting it to the constructor:
class ProfileViewModel(
private val analytics: FirebaseAnalytics = Firebase.analytics,
) {
fun onProfileLoaded() {
analytics.logEvent("viewed_profile")
}
}
This approach is often referred to as "Constructor Injection", where dependencies are supplied via the constructor of a class. This is the core concept of dependency injection. Kelly Shuster summed it up well in her Tweet:
DI has a complicated name for a concept that can be reduced down to "passing stuff in."
Updating Our Test
Now that we have a way to supply our dependency, we can update our test accordingly to provide a fake implementation of Firebase Analytics, that we can then use to verify the proper event was tracked:
class ProfileViewModelTest {
@Test
fun verifyEventTracked() {
val mockAnalytics = FakeFirebaseAnalytics()
val viewModel = ProfileViewModel(mockAnalytics)
viewModel.onProfileLoaded()
mockAnalytics.verifyEventLogged("viewed_profile")
}
}
However, when we do something like this we should be considering the other problem discussed earlier. Despite injecting this dependency, we still have a strong reliance on Firebase Analytics. We should avoid this, so we can support the ability to provide a different analytics service in the future.
Wrapping Dependencies
We could create our own interface that defines an AnalyticsTracker, and a Firebase implementation of it. In the future, we can create other implementations for other services.
interface AnalyticsTracker {
fun trackEvent(eventName: String)
}
class FirebaseAnalyticsTracker : AnalyticsTracker {
override fun trackEvent(eventName: String) {
Firebase.analytics.logEvent(eventName)
}
}
Now we can have our ViewModel depend on this interface instead:
class ProfileViewModel(
private val analytics: AnalyticsTracker = FirebaseAnalyticsTracker(),
)
Up until this point in the blog post, you've gained an understanding of the core concept of dependency injection, why we need it, and how to implement it for a given class. Having said that, let's address a new question:
Why does dependency injection seem so much more complicated than passing stuff in?
There are a few advanced topics of dependency injection, starting with the idea of sharing dependencies. We can continue with our analytics tracking example. Analytics are everywhere, so we want to be able to provide one instance of an analytics tracker to be used by each screen. We also want to create a setup so that we can make one change to have every screen updated accordingly.
Dependency Injection Recipe
To achieve this goal, we can follow three steps.
- Create a container to hold the dependencies used in our application.
- Modify our
Application
class to be the host of those dependencies. - Update our individual screens to request those dependencies.
To implement the first step, we can create a class called AppDependencies
. Notice how our analytics tracker is typed to the interface from earlier, but will return a Firebase implementation:
class AppDependencies {
val analyticsTracker: AnalyticsTracker
get() = FirebaseAnalyticsTracker()
}
Next, we can create a reference to this dependency container inside our Application
class:
class MyApp : Application() {
val appDependencies = AppDependencies()
}
Lastly, we can get a reference to our Application Context from within an Activity or Fragment to request the dependencies:
class ProfileActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appDependencies = (this.application as MyApp).appDependencies
val analyticsTracker = appDependencies.analyticsTracker
val profileViewModel = ProfileViewModel(analyticsTracker)
}
}
You can learn more about manual dependency injection from this Android guide.
Dependency Injection Libraries
Every few months a flamewar emerges on Android Twitter about which dependency injection library people should use. This only contributes to the confusion and anxiety people face when discussing dependency injection, which is why discussing them has been saved for the end of this post. DI libraries exist to help reduce some of the boilerplate code of a manual approach, but they actually follow the same recipe: create a group of dependencies, store them in your Application class, create a mechanism for requesting those dependencies.
Let's look briefly at two dependency injection libraries and how they achieve this.
Hilt
Hilt is the official recommendation from Google for a dependency injection library. It manages dependencies through annotation processing. I recommend viewing the official docs to learn more, but let's review how Hilt uses our DI recipe.
First, we can create a Module
in Hilt that defines a group of dependencies. Here is an example module - don't be scared about all the annotations. The key point here is that we have a way to create an instance of AnalyticsTracker
of type FirebaseAnalyticsTracker
:
@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {
@Binds
abstract fun bindAnalyticsTracker(
analyticsTracker: FirebaseAnalyticsTracker
): AnalyticsTracker
}
Next, we can setup our Application class. Hilt makes this incredibly easy, we just need to use the @HiltAndroidApp
annotation:
@HiltAndroidApp
class MyApp : Application()
Last, we need a mechanism for requesting dependencies. With Hilt, we just use the @Inject
annotation:
@AndroidEntryPoint
class ProfileActivity : Activity() {
@Inject
lateinit var analytics: AnalyticsTracker
}
Koin
Another common DI library is Koin. While the syntax and technical implementations are different, we once again see the same recipe used.
Create a collection of dependencies:
val analyticsModule = module {
single<AnalyticsTracker> {
FirebaseAnalyticsTracker()
}
}
Store them in your Application class:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
modules(analyticsModule)
}
}
}
Request them in your Activity:
class ProfileActivity : Activity() {
val analytics: AnalyticsTracker by inject()
}
We're not going to dive into the nuanced differences between Hilt and Koin today. Instead, I just wanted to provide an overview of the libraries and show that they follow the same steps as writing our own manual dependency injection.
Recap
The dependency injection library you choose (or don't) matters so much less than why we need dependency injection in the first place. In this post, we reviewed how dependency injection enables better testing in our codebase, the ability to swap out dependencies for any reason in the future, and provides us with a centralized location to manage all of our applications dependencies.
Did this help ease some of your anxiety around this complicated topic? Let me know in the comments, as well as any other imposter-syndrome-inducing topics we should look at in the future.