How We Optimized Our Dagger 2 Dependency Injection Framework in Our Android Driver App

Robin Qian Wu
motive-eng
Published in
4 min readJun 22, 2022

To perform dependency injection at Motive–that is, to make a class independent of its dependencies in our Android driver app–we use Dagger 2. In this blog, we share our learnings and the common pitfalls we found in our extensive use of Dagger 2.

Typically, we add a class with @Inject to its constructor and add a @Provide method to the Module class. Without digging deeper, this approach appears acceptable, although @Inject and @Provide are redundant dependency provider annotations. Luckily, if your project is simple enough, this should suffice. In addition, Dagger 2 provides compile-time dependency injection, meaning that it will not throw an exception during runtime if there are no compile-time exceptions.

During our last “technical debt” week, we had some time to refactor our Dagger 2 usage and found it educational. Here are some lessons we have learned.

We Replaced @Provide Methods With Constructor Injections

One common pitfall we found is that we were often using @Provide methods instead of constructor injections. Compared to constructor injections, @Provide methods create extra factory classes and increase build time. We can use constructor injections if all of the required dependencies are provided by either other constructor injections in the dependency injection graph or by @Provide methods.

We learned that when we have control of a class’s implementation, we should give preference to the constructor injection. This way, when Dagger compiles, we don’t need to create extra factory classes for @Provide methods. We can benefit from build time reduction and a smaller APK size.

For example, we need to implement a VehicleCache class to store information about the last connected vehicle in our Driver app. Assuming that User is provided in the current module and that DataManager also has @Inject in its constructor, we can simply use constructor injection with the appropriate scope tag: @Singleton. Using @Singleton here, whenever another VehicleCache is requested for another class within the module, the same instance will be reused. We use:

Instead of:

Limitations of Constructor Injections

1. External Dependencies

If a dependency is external (meaning that its code is not controlled by our team), then we cannot use the constructor injection. This is straightforward; we either do not have the permission to change the external constructor, or it has its own builder methods. One common example is the retrofit handle:

2. Dependencies With the Same Return Type

In this example, we have two different MockDependency instances that share nearly the same dependency; however, one needs NAMED_USER_SHARED_PREFERENCES, while the other requires NAMED_APP_SHARED_PREFERENCES. The constructor injection cannot differentiate between the two contexts because we cannot add two @Named annotations in the constructor.

3. Dependencies From Other Classes

In cases where we also need to use another dependency’s getter method, we cannot use the constructor injection:

Benefits of Constructor Injections

1. Smaller APK Size

Here’s a comparison of the module folder size in java(generated); we benefit by a 31.4% reduction in size:

2. Faster Builds

We use Gradle-profiler to run consecutive builds and compare the build times because it can normalize the build time data. The graphs below depict a 3% decrease in build time when using the constructor injection.

Using @Provide
Using @Inject constructor()

At first, a 3% reduction in compile time does not seem significant. Considering that we have a team of 20 that is rapidly growing, and assuming that each team member runs around 4 clean builds per day, and each build takes about 5 minutes, we can save 4~5 hours of productivity each month.

Recipe for a Cleaner Dependency Injection Graph

To better structure our dependency injection architecture, we decided to have one AppComponent hold the dependencies that are generally shared between features with the @Singleton scope. Each feature then has its own component with the module.

For example, because AppData and UserData hold universal data for the app, they belong to AppComponent with the @Singleton scope:

But we also need a Login feature that will rely on AppComponent. We then create a new LoginComponent with @LoginScope and with AppComponent dependency:

Finally, we can use LoginService in ViewModel:

If we do the same for each feature, our overall dependency injection graph will be improved:

Before
After

Compared to the “Before” graph, the “After” one has much better separation of concerns. As a result, we can achieve a cleaner structured dependency injection graph.

Come Join Us!

Together, we laugh hard and work as a team to drive innovation at the intersection of tech and transportation. Check out our open positions on our careers page and learn more about our engineering team. Join our team today!

--

--