How We Optimized Our Dagger 2 Dependency Injection Framework in Our Android Driver App
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.
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:
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!