Modularization tip: optimize the Root Module
A guide to make a modularization process for Android
As the modularization of a big app may take years to finish, it’s hard to see the effect of this process on build time any time soon. At iFood we practiced the following method to have a faster rebuild on moularized features.
According to Android App Architecture: Modularization, Clean Architecture, MVVM, here are the reasons we need to modularize our app:
- Faster build time (once you add your first module you should edit your gradle.properties file with this line: org.gradle.parallel=true - it uses all cores on your machine to build modules in parallel. Right-click into any folder in the project in Android Studio->Load/Unload Modules will open a screen where you can unload modules that you are not using and avoid their compilation).
- Code ownership.
- Faster Continuous Integration.
- App bundles - Dynamic features.
To achieve modularization, there are multiple approaches and advises, such as “Try to make the dependency graph as flat as possible”, etc. Nevertheless, here we don’t want to talk about them. Here we just want to explain a step that people often miss and I cannot find in any other resources, which may help the companies, who starts modularization or about to start it, to achieve above list of advantages sooner, before the team actually finishes modularizing all the features/libraries.
What is the root module?
It may look like a complicated concept, but it’s just the :app
module in your project. When someone tries to draw the dependency graph of modules, :app
module will be the root module in that graph, where all codes are gathered to build the app. There can be more than one root module in a project, but it’s not effecting this guide, apply it on each of them!
Why does it need to be optimized?
Whenever you touch a feature module, the built classes of root module will become invalidated, so you need to spend time to rebuild them. This demands to optimize the root module to make it as light as possible, to have a faster rebuild process. This reasoning is so similar to why should the dependency graph be as flat as possible, if you heard of it before.
Let’s have an example to make it clear. Take a look at below diagram.
Here after creating a new module, named :legacy
, we can remove the dependency of this module to :feature1
module. So any changes in :feature1
module would not invalidate :legacy
build. Instead, it will invalidate the :app
module, which we assumed it’s a really tiny module, so that’s okay. This implies the iteration of developers on :feature1
would be much faster than iteration on other modules in this example.
How to optimize it in the middle of modularization process
The legacy code, which not yet modularized, tend to be in the big :app
module. So simply you need to put the legacy code in a new module, aka :legacy
module, before finishing modularization of the legacy code! In this way, assembling the app after every changes in feature/library modules, where :legacy
module is not depending on them, will be faster.
To create the :legacy
module, there are multiple ways but we did it in the following order. I have to mention that details make it looks hard, but it’s just copy the :app
module and make it work process :D
1 — Run cp -a app legacy
Run cp -a app legacy
and add include ':legacy'
to settings.gradle
file. Commit your changes because we need it later!
2 — Run ./gradlew :legacy:compileSources
Remove classes like CustomApplication
, AppComponent
, AppModule
, etc. until ./gradlew :legacy:clean :legacy:compileSources
successfully passes. To do so, you need to change the build.gradle
file respectively, so the first thing is to apply com.android.library
instead of com.android.application
plugin.
To make it work you may need to apply dependency inversion principle on some of generated stuff such as BuildConfig
in the new :legacy
module. For instance, you can rely on the following interface instead of BuildConfig
in :legacy
module and provide the implementation in the :app
module based on generated BuildConfig
.
interface BuildConfigProvider {
val applicationId: String
val debug: Boolean
val buildType: String
val flavor: String
val versionCode: Int
val versionName: String
}
Commit your changes to be able to revert the following steps if needed!
3 — Fix test classes
Fix test classes until ./gradlew :legacy:testDebugUnitTest
and ./gradlew :legacy:connectedDebugAndroidTest
successfully pass. Commit your changes to be able to revert the following steps if needed!
4 — Add :legacy
module to dependencies of :app
module
Add :legacy
module to dependencies of :app
module. Run rm -r app/src/main/kotlin/*
, then try to checkout back what is missing, such as git checkout path/to/CustomApplication.kt
until ./gradlew :app:assemble
successfully passes. You may also need to remove all tests and their configurations in build.gradle
from :app
module.
In this process you may change some codes from :legacy
, but it’s not a problem as long as you commit changes of :app
module first! So in the end, you will have something like the following.
$ git log --oneline --graph --all* c44b3b3080 (HEAD -> optimize) Run "./gradlew :app:assemble"
* f240b91d45 Remove stuff from :app module
* e140d2d5f0 Run "./gradlew :legacy:connectedDebugAndroidTest"
* 6424b285a9 Run "./gradlew :legacy:unitTest"
* b9b09b78f2 Run "./gradlew :legacy:clean :legacy:compileSources"
* 908af8c372 Run "cp -a app legacy"
To avoid losing history of files, you can reorder it then squash f240b91d45
and 908af8c372
commits to let the git track renaming of files from :app
to :legacy
module! Something like this.
git rebase -i HEAD~6pick 908af8c372 Run "cp -a app legacy"
squash f240b91d45 Remove stuff from :app module
pick b9b09b78f2 Run "./gradlew :legacy:clean :legacy:compileSources"
pick 6424b285a9 Run "./gradlew :legacy:unitTest"
pick e140d2d5f0 Run "./gradlew :legacy:connectedDebugAndroidTest"
pick c44b3b3080 Run "./gradlew :app:assemble"
Notice e140d2d5f0
, 6424b285a9
and b9b09b78f2
are all have only changes in :legacy
module and f240b91d45
have only changes in :app
module, so you will not have any conflict to reorder them. Also squash will not have conflict, because f240b91d45
has no conflict with 908af8c372
for the same reason.
5 — Optimization
This is the optimization part! Here we have at least two method to do the job, but in the end they are complementary methods.
- For first method, you can use dependency-analysis-android-gradle-plugin, then configure it and run
./gradlew :legacy:projectHealth
to have a list of unused modules in the:legacy
. Nonetheless, it’s not detecting all removable modules as we test it. - For second method, you need to remove dependency modules from
:legacy
module one by one, then run./gradlew :legacy:testDebugUnitTest
to see if it passes successfully, if not bring them back. This boring proccess will end up with a lot of removed modules from dependencies of:legacy
, where any changes on them will not invalidate the build of:legacy
module. Awesome! 🚀
Benchmark
After this process, developers will be inspired to modularize their code to have a faster rebuild, while iterating on a task, without need to finish modularization of all features/libraries in a project.
The following is a benchmark of changing a file in one of those removed modules from :legacy
in the Optimization step above.
As you can see on average the rebuild time decreased by 55%, which is fantastic!
Hope you enjoyed! Please feel free to give me your feedback. Thanks!