Why Adding More Gradle Modules Doesn’t Speed Up Your Build Times
As your Android project grows, there is always a point where your builds start to slow down. It rises and falls like the tides and only when things perfectly align to create a king tide do developers focus on a fix. This helps, but those gains are short lived.
Inevitably, one developer stands up and proclaims:
“Modules will be our savior! Modularize all the things!!!”
Soon, modules start to pop up all over the place and the team notices that builds get faster. Everyone cheers and the hero developer has a statue built in their honor.
Fast-forward six months and the shine on that statue starts to tarnish. Build times start to creep up, your dependency graph becomes more complicated, circular dependencies pop up, and soon the development team is trying to pull down the statue of that once beloved engineer for unleashing this curse upon them.
What happened? What about “Modularize all the things!!!” doesn’t scale?
Let’s run down an over-simplified scenario that will help explain what is happening:
-+- app // 60 second build
// Total Time: 60 seconds
This is where 99% of projects start. A single module with everything in it. There are growing pains with this and build times will continue to inflate as the codebase grows.
This is where things start once you begin to modularize an existing project. All the existing code is put into separate modules and builds starts to speed up.
Problem solved and everyone moves on and focuses on getting features done...
But, slowly build times inflate again.
Over time, as we add more features, we start to realize that things in the other modules are handy for accomplishing the task before us. We, naturally, add a dependency on the module. The hidden cost is our build time grows because of the new prerequisite and we need to wait until that dependency is done building before building the modules upstream. This brings our build back into a linear structure, not parallel like before. What is worse, the lines drawn between modules are done by humans and Gradle can’t optimize building the code as granularly as before because of these “arbitrary” waypoints we added.
This is why projects don’t stay fast-building as you continue to add features and modules as you develop; there is an increase in complexity of the dependency graph and our human impact on it makes Gradle unable cope. This creates a longer linear build unless managed well.
The Solve
What can we do about this? The first thing people attempt is to reconfigure their dependency tree. This will help, but these are short lived gains. Like before, time will allow these issues to creep back in and the builds will slow once again.
At this point we need a repeatable pattern that we can use to solve a wide range of tasks that cross-module communication would solve.
The thing that makes the most sense is creating interfaces in the modules receiving information and use an above layer (hopefully the :app
modules) to stitch the two together. Here is an example:
// Chat Module
class Chat(
val userDataProvider: UserDataProvider
) {
fun username(): String {
return userDataProvider.username
}
}
interface UserDataProvider {
val username: String
}
// User Module
class UserProfile() {
val username: String
}
// App Module
class ClassInApp(
val userProfile: UserProfile
) {
val chat = Chat(
dataProvider = object : UserDataProvider {
val username: String
get() = userProfile.username
}
)
}
Here you can see that we are getting a string from the User Module
into the Chat Module
and we are doing it without the two modules having any knowledge of each other. We use the interface UserDataProvider
defined in the Chat Module
to provide a way of getting the right data that doesn’t need to be defined right away. We accomplish this in the App Module
thus completing the task of getting the data from one to the other but without creating a dependency between them.
Doing this will still allow Gradle to build Chat Module
and User Module
in parallel. The App Module
needs to be built last regardless, so having it do this work as no effect on the build graph.
This example is simple, but that strategy works for even more complex situations where the interface provider might return a complex data structure.
// Chat Module
class Chat(
val userProvider: UserProvider
) {
fun username(): String {
return userDataProvider.user.username
}
}
interface UserProvider {
val user: ChatUser
}
data class ChatUser(
val userId: Int,
val userName: String,
val userAvatarUrl: URL,
// ...
)
// User Module
class UserProfile() {
val user: User
}
data class User(
val user_id: Int,
val user_name: String,
val user_photo_url: String
)
// App Module
class ClassInApp(
val userProfile: UserProfile
) {
val chat = Chat(
dataProvider = object : UserProvider {
val user: User
get() = userProfile.user.toChatUser()
}
)
}
fun User.toChatUser(): ChatUser {
return ChatUser(
userId = this.user_id,
userName = this.user_name,
userAvatarUrl = URL.create(this.user_photo_url)
)
}
Here is a more complex situation where we need more complicated data and even the types across the two modules don’t quite line up. Here we can still achieve the desired module separation and can even provide a convenient class extension function on the User
class in the App Module
to keep our code more readable while still keeping the User Module
and Chat Module
unaware of each other.