Android light classes

Note: Android light classes are only loosely related to “Kotlin light classes” (see kotlin-light-classes.md). Both mechanisms borrow the name from LightElement, which is a supertype for PSI elements not backed by actual source code (i.e. not created by a PsiParser), but implement the idea in very different ways.

Background

A common pattern in Android development is to expose various assets or “resources” to the app‘s code via classes generated at build time. This idea has been used from the earliest days of Android, with the original aapt tool generating app’s R class based on contents of the res directory. It has been later adopted by Data Binding and View Binding and is considered for more libraries in the future. Usually the build-time component is implemented in the Android Gradle Plugin, but it could also be an annotation processor or a separate Gradle plugin.

From the IDE point of view this approach is problematic, since additional code is generated at build time and, until this code is written to disk, standard mechanisms like code completion etc. are not aware of the generated classes. This means that without additional support from the IDE, these features are not usable before the first build. Even after the first build, additional changes are not reflected in code completion until the next build. This is not a good user experience considering the current latency of (even incremental) builds.

This is why Android Studio tries to simulate known code generators in real time, as the user modifies their inputs. For instance, it maintains an up-to-date repository of all known resources (built by parsing XML files) and uses it to create “fake” classes injected directly into editor mechanisms like code completion, reference resolution etc.

Limitations

To properly implement the “light classes idiom” for a code generator, these requirements have to be met:

  1. IDE needs to understand the API of generated classes. This means the logic for picking the class name, its methods, and fields has to be relatively simple since it has to be duplicated between the IDE and the code generator. In practice this also means it cannot change too often.
  2. IDE needs to have an efficient way of computing all required information from the input files and keeping that information up-to-date. Note: This might be less information than what the full code generator needs. For example, the IDE only needs to know about the class's API, not its method bodies or field values.
  3. IDE needs to know if the feature should be enabled at all or not. For instance R classes are generated for all Android modules, but the Data Binding support runs only in modules that enabled Data Binding in their build.gradle files. This information is usually stored in the Gradle model, obtained by the IDE at sync time.

Implementation details

Implementing a feature based on light classes means “tricking” the IDE into believing certain classes exist, even though they are not defined in any source files of the project. This means several core editor mechanisms need to be extended using the typical IntelliJ approach of registering extensions for platform extension points. Support for a build time code generator means implementing the following components:

Model

A mechanism to quickly determine what classes should be made available. This part is what varies most between different features that use light classes, since the logic is specific to the feature. Other components described below are IDE extension points that we discovered over time that had to be implemented for light classes to work. But to know what logic to put in those extension points, the IDE needs to have an understanding of the code generator semantics and relevant input files that affect the generated code.

The only requirement for the model code is that extension points described below need a way of calling into it, which means usually it ends up being an IntelliJ module service. In the case of R classes we use a whole IDE subsystem of resource repositories (ResourceRepositoryManager) that are used by per-build-system implementations of LightResourceClassService. For Data Binding we have a custom IntelliJ index (BindingXmlIndex) which is used from ModuleDataBinding.

It‘s important to avoid unnecessary work for modules that don’t use the code generator in question. Another aspect to consider is memory usage, avoiding memory leaks and correctly disposing any model information when the module or project are closed.

PsiClass Implementation

Representation of the generated classes that will be passed to IntelliJ platform APIs to enable editor features like code completion etc. This is usually a subclass of AndroidLightClassBase with the right implementation of getFields(), getMethods(), getInnerClasses() and related methods.

PsiElementFinder

Extension point used by JavaPsiFacade to find classes and packages based on their fully qualified names. This ends up called by reference resolution code, meaning references to generated classes are not highlighted in red.

ResolveScopeEnlarger

To be considered for reference resolution and code completion, the new classes have to be in the right scope, usually the “resolve scope” of the files in which they are used. Light classes live in light virtual files which live in a light virtual file system, which means they are not part of the project. To fix this, we provide implementations of ResolveScopeEnlarger and KotlinResolveScopeEnlarger.

PsiShortNamesCache

Extension point used by code completion for unqualified class names and for suggesting imports. It needs to be implemented for the light classes to behave correctly.

GotoDeclarationHandler or getNavigationElement

Light classes and their members don't have corresponding source code, so calling “go to declaration” on code referencing them will by default display a small balloon saying “cannot find destination” or something similar. Typically these light elements “represent” some other files in the project, so we override “go to declaration” to open these other files instead. This can be done either by overriding getNaviationElement on the light classes/fields/methods or by providing a custom GotoDeclarationHandler extension point, if more context is needed or a completely different behavior (other than opening a PsiElement in a code editor) should be used.

Modifying AGP model

To avoid duplicate definitions of light classes (some of them out-of-date), sources generated at build time cannot be included by the IDE as project sources. This means they either have to be not in the source set model at all or excluded at sync time based on other fields in the model.