Experimenting with Gradle dependencies
Today, I opened my app/build.gradle
file and cringed at the sight of my dependencies
block. So unorganized and just hard to look at.
This is something that’s been bothering me for sometime but I never really had the time to figure out a better way, until now.
I started out researching what others were doing with their build file. One of the most common patterns I’ve been seeing recently looks something like this:
dependencies {
implementation deps.libraryA
implementation deps.libraryB
}
Where deps
is defined like so:
ext.deps = [
libraryA: "com.example:library-a:1.0",
libraryB: "com.example:library-b:1.0"
]
Although this cleans up the dependencies block a bit, I find the need to map the dependency string to a map key slightly redundant. I then thought, “What if I could organize my dependencies by feature?”, similarly to how you would organize packages by feature.
My dependencies block already had comments, creating sections like UI, Data, etc., so it seemed like a good direction to go in.
dependencies {
// UI
implementation "com.android.support:appcompat-v7:27.0.0"
implementation "com.android.support.constraint:constraint-layout:1.1.0-beta3"
implementation "com.android.support:design:27.0.0"
// Network
implementation "com.squareup.moshi:moshi-adapters:1.5.0"
implementation "com.squareup.retrofit2:retrofit:2.3.0"
}
With this in mind, I thought about what this would look like and I ended up with the following:
dependencies {
ui()
network()
}
Looks great! But, how do we get there? What does ui()
look like?
def ui() {
implementation "com.android.support:appcompat-v7:27.0.0"
implementation "com.android.support.constraint:constraint-layout:1.1.0-beta3"
implementation "com.android.support:design:27.0.0"
}
The above method doesn’t work because implementation
is scoped to the dependencies
block. So the question becomes, how do we get access to implementation
outside of the dependencies
block?
Looking at the Gradle DSL documentation, we see that the dependencies
block delegates to DependencyHandler
which we can get access to from Project.getDependencies()
. So with that knowledge, we can modify our ui method to look like…
def ui() {
getDependencies().add("implementation", "com.android.support:appcompat-v7:27.0.0")
}
So far so good, but it’s still hard to look at. We can refactor the above to conform to Groovy conventions.
def ui() {
dependencies.add("implementation", "com.android.support:appcompat-v7:27.0.0")
}
A little better, but ideally we should be able to declare the dependency as if it were inside the dependencies
block.
If we’re able to access the DependencyHandler
APIs from the dependencies
block, then we should be able to access the same API from getDependencies()
. So we rewrite our ui
method again like so:
def ui() {
dependencies.implementation "com.android.support:appcompat-v7:27.0.0"
}
And… we see that everything compiles successfully! So now, our build file looks something like this:
def ui() {
dependencies.implementation "com.android.support:appcompat-v7:27.0.0"
dependencies.implementation "com.android.support.constraint:constraint-layout:1.1.0-beta3"
dependencies.implementation "com.android.support:design:27.0.0"
}
def network() {
dependencies.implementation "com.squareup.moshi:moshi-adapters:1.5.0"
dependencies.implementation "com.squareup.retrofit2:retrofit:2.3.0"
}
dependencies {
ui()
network()
}
To take things a bit further, we can use Groovys’ closure delegate feature to create our own DSL so we can avoid writing dependencies
every time we need to declare a new dependency.
def dependencyGroup(Closure closure) {
closure.delegate = dependencies
return closure
}
def ui = dependencyGroup {
implementation "com.android.support:appcompat-v7:27.0.0"
implementation "com.android.support.constraint:constraint-layout:1.1.0-beta3"
implementation "com.android.support:design:27.0.0"
}
def network = dependencyGroup {
implementation "com.squareup.moshi:moshi-adapters:1.5.0"
implementation "com.squareup.retrofit2:retrofit:2.3.0"
}
dependencies {
ui()
network()
}
You can read more about delegates here.
This looks great! There’s just one last thing to do. To clean things up, we can move everything out into a separate Gradle file so our main build.gradle
fie doesn’t get too cluttered…
app/build.gradle
android {
// config
}
apply from: "dependencies.gradle"
app/dependencies.gradle
def dependencyGroup(Closure closure) {
closure.delegate = dependencies
return closure
}
def ui = dependencyGroup {
implementation "com.android.support:appcompat-v7:27.0.0"
implementation "com.android.support.constraint:constraint-layout:1.1.0-beta3"
implementation "com.android.support:design:27.0.0"
}
def network = dependencyGroup {
implementation "com.squareup.moshi:moshi-adapters:1.5.0"
implementation "com.squareup.retrofit2:retrofit:2.3.0"
}
dependencies {
ui()
network()
}
To summarize, we took a standard dependencies
block and grouped the dependencies by feature. We then moved the declarations out into methods and leveraged the Gradle API to be able to access the same DependencyHandler
API. Then we created our own dependencyGroup
DSL using Groovys’ closure delegate feature, which allowed us to write dependencies naturally.
DISCLAIMER: The method outlined above is completely experimental and may or may not be the best way, but it works pretty well for my purposes.