Why we dropped the transitive R class

After completing the Duplo modularization effort, the task that generated the transitive R class had become a major time sink. Since the non-transitive R class — a Gradle flag that namespaces R classes so each module only sees its own resources — promised up to a 40% improvement in incremental builds, we decided to migrate. The flag is enabled by default for projects started in Android Studio Bumblebee or later.

With non-transitive R classes, resources can no longer be referenced through the current module's R class if they're declared elsewhere. Before migrating, we had to settle on reference conventions.

Kotlin conventions

Using fully-qualified names like getString(slack.l10n.R.string.at_everyone) is verbose, so we standardized on Kotlin import aliases:

import slack.l10n.R as L10nR

class Clazz(context: Context) {
  init{
    context.getString(L10nR.string.at_everyone)
  }
}

Java conventions

Import aliases are not available in Java. Since only about 4% of our codebase was still Java, we accepted fully-qualified names there — which gave us extra motivation to finish converting those files to Kotlin.

Migration strategy

Our first plan was to use Android Studio's Refactor > Migrate to Non-Transitive R Classes feature, then run a script to replace fully-qualified references with import aliases. But with more than 400 modules, Android Studio ran all night on an Intel MacBook Pro and froze the UI. We needed a different approach.

Most of our resources live in just two modules: :l10n-strings holds strings, and :slack-kit-resources holds most other resources. That shaped the actual migration:

  1. Using Android Studio Find & Replace, we applied the regex ([\[|(|]| )R\.string\. to rewrite string references as $1L10nR.string., and ([\[|(|]| )R\.(color|dimen|drawable|font|raw|style|attr)\.sk_ to rewrite other resource references as $1SlackKitR.$2.sk_.
  2. We ran a modified version of the resource-to-import-alias script to add the proper import aliases wherever L10nR or SlackKitR appeared. Modules that had relied on transitive resource access via the R class got explicit dependencies on :l10n-strings or :slack-kit-resources. The build compiled again.
  3. We set android.nonTransitiveRClass=true in the root gradle.properties and manually fixed the few remaining references that failed.
  4. Finally, we added android.enableAppCompileTimeRClass=true to the same file, enabling the compile-time-only R class that uses the app's local resources.

Developer workflow and guardrails

Live templates for import aliases

To keep discovery easy, we identified the four most common import aliases with grep -o -h -r -E 'import \w+(\.\w+)+\.R as [a-zA-Z0-9]+R' . | sort | uniq -c | sort -b -n -r and added them as live templates prefixed with r.

Aliases live templates

Unlike file and code templates, live templates can't be distributed through the IDE project settings. Instead, we copied the templates file from ~/Library/Application Support/Google/AndroidStudio<version>/templates into config/templates in our Git repository. A bootstrap script then copies it into any newly installed Android Studio as part of local environment setup.

Lint checks

Beyond the templates, we added three lint checks to enforce the conventions:

  1. Only the local module's R class may be imported without an alias. For instance, slack.uikit.resources.R cannot appear outside :slack-kit-resources without an import alias.
  2. R class aliases must be consistent. slack.uikit.resources.R can only be imported as SlackKitR, not SKR or another name.
  3. R classes and resources can't be referenced fully qualified; import aliases are required. For example, getString(L10nR.string.at_everyone), not getString(slack.l10n.R.string.at_everyone).

All three checks ship with auto-fixes.

Measured results

The move to non-transitive R classes delivered a ~14% improvement in incremental build times after resource or layout changes. It also cut APK and DEX size by ~8.5%, roughly 5.5MB of code.

APK size difference graph

There were also two unexpected gains:

  • It surfaced resources declared in one module but only consumed in another, so we could move them and improve module cohesion.
  • Tracing where a resource originated became much simpler, which helped us identify and triage inaccessible UI elements.

The migration was a clear success, and developer sentiment has been positive thanks to the build improvements and the supporting tooling. For any multi-module project — or one about to modularize — non-transitive R classes are well worth adopting.