Modularization tip: optimize the Root Module

A guide to make a modularization process for Android

Hadi Lashkari Ghouchani
iFood Engineering

--

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!

References

--

--