Unveiling the Reality: My Experiment with Non-Transitive R Classes and Compilation Avoidance
In December 2022 we switched from the traditional transitive R classes to the back then relatively new non-transitive R classes behavior in our Android app.
With this change, we hoped to get faster build times. Especially since the official Android documentation says so:
Use non-transitive
R
classes to have faster builds for apps with multiple modules […] This leads to faster builds and the corresponding benefits of compilation avoidance.
Since our app currently has 93+ Gradle modules (and growing), we thought this would be a significant benefit for us.
Unfortunately, after months of using this feature, we realized that this isn’t the case. We found out that when adding or removing a string in a module, all of its dependents are still recompiled. Which in our case is about half of the whole app.
Didn’t the docs say something about faster builds and compilation avoidance? Let’s find out what they meant by that and why it doesn’t apply to our app.
Non-transitive R class explained
As an Android developer, you’re probably familiar with the R class. It is a class that contains fields that you can use to reference resources. For example, to get a (translated) string from your resources, you would use in code something like getString(R.string.app_name)
.
The R class is generated for you by the build system. Traditionally, each module generates its own R class, which contains its resource identifiers. Not only that, they also contain every identifier of every transitive dependency. This is the reason why, for example, you could say getString(R.string.library_name)
while importing your app
R class (guru.stefma.app.R
) inside your app
module, even though the library_name
resource is part of a library
module used in app
as a dependency. In fact, the R class generated by the app
module also contains the resource identifiers of the library
.
Exactly this has changed with the use of non-transitive R classes. With this feature enabled, each module’s R class contains only the resource identifiers it defines — no more, no less. Therefore, if you want to use a resource from a dependency in your app
module, you have to import exactly that R class. That is, you must import guru.stefma.library.R
instead of the R from your app
(guru.stefma.app.R
).
The following image also illustrates this concept:
This results in significantly smaller R classes in your project overall. At first glance, it also makes sense that this will reduce build time since there is no need to recreate an R class for each dependent module whenever you add or remove a new resource from a module. But is this really the case?
Our Android app architecture
Before I explain why this doesn’t give us the improvement we were hoping for, I need to explain what our architecture looks like.
Our architecture is quite flat. We have two top-level buckets, feature
and lib
, which contain multiple feature
and lib
modules, respectively. While feature
modules shouldn’t reference each other, lib
modules are allowed to do so. However, we have a special lib
module called strings
. For legacy and localization reasons, all of our strings are contained in this module. Every time a feature
(or lib
, but it is desirable not to use strings in a lib
, but unfortunately sometimes it is unavoidable) needs access to our strings, it must include the :lib:strings
module as a dependency. This is the case for all of our feature
s.
So our project structure looks like this:
lib
- strings
[...]
feature
- feature1 dependsOn :lib:strings
- feature2 dependsOn :lib:strings
- feature3 dependsOn :lib:strings
- [...]
Info: If you want to have a more in-depth look at our architecture, check out the following blog:
A short explanation of compilation avoidance
Compilation avoidance is, as the name suggests, a feature to avoid (re)compiling classes in Gradle modules.
This feature only makes sense in multi-module Gradle projects and tries to avoid recompiling a dependent module. In our case, the app
module shouldn’t be recompiled when we make changes in our library
module, as long as those changes don’t affect the ABI.
There is even a detailed blog post from Gradle that explains this concept in detail. But to sum it up how avoiding recompiling a module works, if only private
, internal
, or function
bodies were changed, Gradle does not need to recompile dependent modules. As soon as public
classes or function
signatures change, compilation avoidance can’t kick in and every dependent of that module has to be recompiled.
Note: There is also the concept of incremental Kotlin/Java avoidance, which goes a step further and avoids recompiling classes that are not affected by a dependency’s (public) class changes. But that is a bit beyond the scope of this blog. However, if you’re curious, there is another blog by Gradle that explains this concept.
The problem
With all the information provided above, you might already know why non-transitive R classes don’t give us any build speed improvements, right? 😉
At the beginning of the blog, I mentioned that even the Android documentation describes that projects using non-transitive R classes do “benefit from compilation avoidance”. While this is true in general (I’ll explain this later in this blog), it has no impact on our project with this given architecture.
The reason for this is how compilation avoidance works together with our architecture. Earlier in this blog, I mentioned that once a public
class changes, compilation avoidance will not work. But, as soon as you add or remove a resource from your module, the public R class
adds or removes a public
field. Therefore, every dependent needs to be recompiled. With our architecture, where all of our feature
s depend on :lib:strings
, all of our feature
s must be recompiled whenever we add or remove a string to or from :lib:strings
. We don’t have a significant build speed benefit from using non-transitive R classes.
An experimental project
Are you more of a hands-on kind of guy and want to poke around in the source code and play with an example? I got you covered:
If you’re missing something, want to make a comment, or just have general feedback, feel free to open a new issue.
The non-transitive R classes benefit
In what situations does the non-transitive R class feature have an impact on compilation avoidance? When you have a dependency tree that is at least three levels deep. Suppose you have a module app
that depends on lib
, while lib
depends on common
, and you add or remove a resource to or from common
. In this case, and given that lib
adds common
as an implementation
dependency and not as an api
dependency, compilation avoidance kicks in for app
so that app
doesn’t need to be recompiled.
Obviously, the public R class of common
has been changed, so lib
needs to be recompiled. But since lib
hasn’t changed (because the lib
R class doesn’t get new fields), app
doesn’t need to be recompiled. The app
module makes use of compilation avoidance 🎉
Of course, the same goes for every dependent up the dependency tree. Imagine there are 10 more modules “above” app
, they don’t need to be recompiled either. With transitive R classes, on the other hand, they all need to be recompiled because the R class of each module has changed.
Savvvy?
Disclaimer
I wrote this blog post simply to show you what I found out. I’m not against this feature, or even saying you shouldn’t enable it. Well, enabling it has a significant effect, because your R classes don’t get blown up with (unnecessary) fields from their transitive dependencies. This alone is a huge benefit. However, in terms of build speed, this change may not have such a big impact on your project, depending on your project structure.
So if you are still considering whether or not to switch to non-transitive R classes, do it. It will definitely bring benefits. But depending on your project structure, maybe not the ones you expected 🙂