All Articles

Analyzing the Internals of Kotlin's Android Synthetic Import (Part 1)

Kotlin Screenshot

Kotlin has a plugin called “Android Extensions”, which you can apply to your Android project to have a set of really nice features available on your Kotlin code. One of them is Synthetic Imports.

Synthetic Imports allow us to quickly use components declared on our XML. No need to create a field, no need to use findViewById, just import the XML and we’re ready to go.

I’ll admit it: at first I thought it was pure magic. But there is no magic in programming, just really smart things happening behind the scenes.

How does it looks like?

We’ll take a look at how Synthetic imports look in code. (you can skip this section if you have already used it).

Let’s say that you have an XML file with a few view

<LinearLayout
  android:id="@+id/main_layout"
  ...>
  <EditText
    android:id="@+id/my_edit_text"
    ...>
  <Button
    android:id="@+id/my_button"
    ...>
</LinearLayout>

The Android way

The “old-fashion” way of accessing those methods from your Activity / Fragment / View / etc., would be:

  1. Declare a field for each component.
  2. Assign its value with findViewById.
  3. Now you can use them.
lateinit var mainLayout: LinearLayout
lateinit var myEditText: EditText
lateinit var myButton: Button

fun onCreate() {
  setContentView(R.layout.activity_main)
  mainLayout = findViewById(R.id.main_layout)
  myEditText = findViewById(R.id.my_edit_text)
  myButton = findViewById(R.id.my_button)
}

The Synthetic import way

  1. Import the layout that you want to use.
  2. Use its components right away.
import kotlinx.android.synthetic.main.activity_main.*

fun onCreate() {
  setContentView(R.layout.activity_main)
  mainLayout.orientation = ...
  myEditText.setOnTexChangedListener { ... }
  myButton.setOnClickListener { ... }
}

Inspecting the code

Most of the times, whenever I use a library, I like to see the source code to understand what’s going on. What code to inspect in this case, though? As you can see, the variables are declared automatically. If you try to go to its declaration, you are redirected to the XML file.

Inspecting the bytecode

What’s really happening is that the Android Extensions plugin is automatically generating code for us behind the scenes. It basically instructs the compiler to create fields, to create methods, and to handle the findViewById logic without us having to deal with it.

If we want to take a look at the actual code that is generated by the plugin, we can analyze the internal bytecode that is generated. Android Studio allows us to take a look at the bytecode generated by our Kotlin code, and then decompile it to Java. If we apply that technique, we can see the real magic behind the extensions.

There are a few articles that already do that. They are great and I’ll share them at the end of this post. That’s not the way that we’ll do it here, though.

Inspecting the real code

In this article we’ll analyze the source code of the plugin itself. We can do that because it is open source, and you can see it here.

“Inspect the bytecode” just shows us the final output. By inspecting the plugin code, we will:

  • (Try to) Understand the intentions behind some of the internal implementations.
  • Discover the scenarios that the developers considered
  • Discover the scenarios that are not allowed.
  • Demistify synthetic imports. Some devs are afraid of code they can’t see, and will immediately blame the plugin for unexpected behavior.

Note: The following code snippets will be VERY simplified - check out the actual source code links to see the real implementations

1. Container Types

Widgets are contained within a parent container. It could be an Activity, a Fragment, or any other View.

The plugin declares an enum called AndroidContainerType.

enum class AndroidContainerType(className: String, 
  val doesSupportCache: Boolean = false,
  val isFragment: Boolean = false) {
    ACTIVITY(..., doesSupportCache = true),
    FRAGMENT(..., doesSupportCache = true, isFragment = true),
    DIALOG(..., doesSupportCache = false),
    VIEW(..., doesSupportCache = true),
    LAYOUT_CONTAINER(..., doesSupportCache = true),
    UNKNOWN("");

Within that declaration, we can see that each container type can specify if it supports caching or not. We’ll keep digging into caching later, but for now, we can see that most containers do support it, expect for Dialogs.

Note: We mention just “Activity” and “Fragment”. The code is actually handling all their Support / AndroidX variants too!

2. Finding the views

To access a component that we declared on an Android XML file, we have to reference it by ID. This means that at some point, the plugin has to findViewById for us.

The class ResourcePropertyStackValue is the one in charge of doing that. Let’s take a look at its code:

if (AndroidConst.FRAGMENT_FQNAME == returnTypeString) {
    return putSelectorForFragment(v)
}

if (container.hasCache && shouldCacheResource(resource)) {
    v.invokevirtual(CACHED_FIND_VIEW_BY_ID_METHOD_NAME)
} else {
    when (containerType) {
        ACTIVITY, VIEW, DIALOG -> {
            v.invokevirtual("findViewById")
        }
        FRAGMENT -> {
            v.invokevirtual("getView")
            v.invokevirtual("findViewById")
        }
        LAYOUT_CONTAINER -> {
            v.invokeinterface("getContainerView")
            v.invokevirtual("findViewById")
        }
        else -> throw IllegalStateException("Invalid Android class type: $containerType") // Should never occur
    }
}

From that code, we can see:

  • If the view that you’re referencing is a Fragment, there’s a different logic. We won’t see the details here, but it basically calls findFragmentById instead of findViewById.
  • There is some kind of caching mechanism. We’ll dive into that later.
  • If caching is not enabled, it invokes different methods based on the container type of the view.

Activity / View / Dialog

ACTIVITY, VIEW, DIALOG -> {
    v.invokevirtual("findViewById")
}

We are operating over the views themselves, so we can call findViewById right away.

Fragment

FRAGMENT -> {
    v.invokevirtual("getView")
    v.invokevirtual("findViewById")
}

Remember that for Fragments, we need to implement onCreateView. That method returns a reference to the actual container of our widgets. That’s why we can’t call findViewById right away: we need to have a reference to getView first.

For this reason, it is not safe to use synthetic imports during onCreateView(), but is it safe to call it onViewCreated().

LayoutContainer

LayoutContainer is a simple interface

public interface LayoutContainer {
    /** Returns the root holder view. */
    public val containerView: View?
}

It was created so that you can make any class provide a view, and that way make it compatible with synthetic imports. It only needs to provide a method called getContainerView().

LAYOUT_CONTAINER -> {
    v.invokeinterface("getContainerView")
    v.invokevirtual("findViewById")
}

Once we have a reference to the container view, we can findViewById.

If you want to use LayoutContainers, you will need to enable experimental features on build.gradle.

androidExtensions {
    experimental = true
}

3. Caching the views

It would be inefficient to execute findViewById every time we have to access a widget. That’s why the “old-fashioned” way has always been to declare a variable that holds a reference to the widget, and reuse that reference. Android Extensions does something similar, but adds a caching-layer on top.

Creating the Caching Field

Let’s see again the code when we use synthetic imports:


fun onCreate() {
  setContentView(R.layout.activity_main)
  mainLayout.orientation = ...
  myEditText.setOnTexChangedListener { ... }
  myButton.setOnClickListener { ... }
}

At first look, it seems like 3 fields are created: one for each of our widgets. However when the code is compiled, a single field is created.

The class AbstractAndroidExtensionsExpressionCodegenExtension is responsible of generating a lot fields and methods required for synthetic imports to work.

It implements a method called generateCacheField, that creates a data structure that will be responsible of storing all of our widgets.

private fun generateCacheField() {
    val option = containerOptions.getCacheOrDefault(...)
    val cacheImpl = CacheMechanism.getType(option)
    classBuilder.newField(
        ACC_PRIVATE,
        PROPERTY_NAME,
        cacheImpl.descriptor, ...)
}

However, what is the type of that field…? There are 3 possible CacheMechanisms.

HashMap

Data structure that allows us to store information, and retrieve it instantly given a hash. For our widgets, we use the unique (per layout) ID that we declare on the XML file.

By default, Android Extensions use HashMap.

SparseArray

It’s an Android-specific data structure that sacrifices a little bit of performance in order to handle memory in a more effective way. Its time complexity is O(logN) compared to a O(1) for HashMap.

In most cases, neither implementation impacts neither the performance nor memory in a significant way. However, it’s up to you to decide which one fits better your needs.

None

If you don’t want any caching mechanism to be applied.

Choosing a mechanism

Given an Activity / Fragment / View where you are using synthetic imports, you can annotate it with @ContainerOptions to decide which implementation to use

@ContainerOptions(cache = CacheImplementation.HASH_MAP)
@ContainerOptions(cache = CacheImplementation.SPARSE_ARRAY)
@ContainerOptions(cache = CacheImplementation.NO_CACHE)

It can also be declared at gradle-level, and it will apply to all your views.

androidExtensions {
    defaultCacheImplementation = "HASH_MAP" // also SPARSE_ARRAY, NONE
}

When is caching logic not generated?

Let’s look at the code.

override fun generateClassSyntheticParts(...) {
    if (container.kind != ClassKind.CLASS && 
        container.kind != ClassKind.OBJECT) return

    if (containerOptions.getCacheOrDefault() == NO_CACHE) 
        return

    if (containerOptions.containerType == LAYOUT_CONTAINER 
        && !isExperimental(targetClass)) {
        return
    }

    //generate caching fields, methods, etc...
    context.generateCachedFindViewByIdFunction()
    context.generateCacheField()
    ...
}

Caching logic is not generated if:

  • If the container is not a valid class
  • If the caching option is NO_CACHE
  • If the container is LayoutContainer, but you forgot to enable experimental Android Extensions features.

4. Finding the views - for real

If you remember, the code we analyzed on the second section (where we saw how findViewById is invoked) was the path where cache was not enabled!

In this case, we care about findCachedViewById method. That function is created by the plugin here. Take a quick look and then we will evaluate line by line.

private fun generateCachedFindViewByIdFunction() {
    val methodVisitor = classBuilder.newMethod(ACC_PUBLIC,
            CACHED_FIND_VIEW_BY_ID_METHOD_NAME, ...)

    // Init cache if null
    cacheImpl.initCache()

    // Get View from cache
    cacheImpl.getViewFromCache()

    // Resolve View via findViewById if not in cache
    when (containerType) {
        ACTIVITY, VIEW, DIALOG -> {
            iv.invokevirtual("findViewById")
        }
        FRAGMENT, LAYOUT_CONTAINER -> {
            if (containerType == LAYOUT_CONTAINER) {
                iv.invokeinterface("getContainerView")
            } else {
                iv.invokevirtual("getView")
            }
            // Return if getView() is null
            iv.aconst(null)
            iv.areturn(viewType)

            // Else return getView().findViewById(id)
            iv.invokevirtual("findViewById")
        }
        else -> throw IllegalStateException("Can't generate code for $containerType")
    }

    // Store resolved View in cache
    cacheImpl.putViewToCache { iv.load(2, viewType) }
    iv.areturn(viewType)
}

Note: the comments in the snippet above are actually written on the source code! They tell a lot about the author’s intentions - that wouldn’t be visible by inspecting the bytecode.

LOTS of things going on here.

  1. The cache (HashMap / SparsedArray) is initialized (ONLY if it is not null)
  2. First we try to get the view from getViewFromCache().
  3. If it is not found, then we findViewById by container type, in a similar way that we saw before. For Fragments / LayoutContainer, notice that if getView() or getContainerView return null, then the code will not crash, but it will return null right away.
  4. At the end, the view is stored on cache, and the value is returned.

4. Clearing the cache

At some point during the container lifecycle, we may need to clear the cache of views. That method is also generated on AbstractAndroidExtensionsExpressionCodegenExtension

val CLEAR_CACHE_METHOD_NAME = "_\$_clearFindViewByIdCache"

private fun generateClearCacheFunction() {
    val methodVisitor = classBuilder.newMethod(... ,
        ACC_PUBLIC, 
        CLEAR_CACHE_METHOD_NAME, ...)

    methodVisitor.visitCode()

    val cacheImpl = CacheMechanism.get(...)

    cacheImpl.loadCache()
    cacheImpl.clearCache()

    iv.areturn(Type.VOID_TYPE)
    FunctionCodegen.endVisit(CLEAR_CACHE_METHOD_NAME...)
}

That code is:

  1. Creating a new method called clearFindViewByIdCache
  2. Loading the cache implementation, and if it is not null, it will call its clearCache method.
  3. That’s it. The return type is void so nothing is no value is returned.

The outcome is basically

fun clearFindViewByIdCache() {
    findViewCache?.clearCache()
}

When is it called?

The answer is in AbstractAndroidOnDestroyClassBuilderInterceptorExtension.

val ON_DESTROY_METHOD_NAME = "onDestroyView"

private fun generateClearCacheMethodCall() {
    if (name != ON_DESTROY_METHOD_NAME) return
    if (Type.getArgumentTypes(desc).isNotEmpty()) return
    if (Type.getReturnType(desc) != Type.VOID_TYPE) return

    if (!containerType.isFragment || 
        !cacheImpl.hasCache) return

    iv.invokevirtual(currentClassName, CLEAR_CACHE_METHOD_NAME, "()V", false)
}

The plugin will analyze each method, and it will automatically make a call to clearFindViewByIdCache if:

  • The method name is onDestroyView
  • The method has NO arguments
  • The method return type is void
  • If the container is a Fragment.
  • If the current caching strategy support caching.

Why only Fragments perform a clear?

The lifecycle of the Fragments allow the views to be destroyed but then recreated (onDestroyView -> onCreateView is a valid state change). Given that views are recreated, it makes sense to clear the cache: the views we were referencing before are no longer valid.

Activities / dialogs / custom views don’t have the same lifecycle, and there is not really an scenario where we can get in trouble for not clearing the cache.

Overview

On this first part, we have covered:

  • The code responsible of generating the findViewById methods, both when caching is enabled and when it is not.
  • The container types that are currently supported.
  • The caching mechanisms that we can use.

Continue reading PART 2, where we see how are our XML layouts are analyzed, which widgets can be imported and which ones can’t, how imports work when working with flavors, and much more!

Additional resources

These articles that also explain Android Extensions, highly recommended!