Unveiling the Reality: My Experiment with Non-Transitive R Classes and Compilation Avoidance

Stefan M.
6 min readFeb 16, 2024

--

Image generated with Microsoft Copilot Designer

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 features.

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 features depend on :lib:strings, all of our features 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 🙂

--

--

No responses yet