How we made a shift from kotlinx.synthetics to Android View Binding

Ivan Antsiferov
hh.ru
Published in
12 min readJun 28, 2022

Hi! I’m Vanya, I’m Android-developer from hh.ru’s product team. In this article, I will speak on our experience of migration to ViewBinding.

In late 2020, the official Android Developers blog announced that the android-kotlin-extensions plugin for Gradle is no longer Koltin friendly starting September 2021 and will be declared e̶x̶c̶o̶m̶m̶u̶n̶i̶c̶a̶d̶o̶ deprecated.

Our Android app has a rather large code base, and this plugin was used everywhere and for every screen. We didn’t want to make a code freeze and send all the developers’ efforts for migration, so we decided to try to automate the refactoring process and make it iterative at the same time.

For those who aren’t the fans of reading, we made a video version.

Why is migration needed?

In late 2020, there was a post warning that the android-kotlin-extensions plugin for Gradle would no longer come with Koltin. Even though as of February 2022 it still comes with Kotlin, migration was still inevitable. After all, sooner or later it will be removed from there, and the price of using synthetics in the project now is the potential inability to update to new Kotlin versions in the future. Of course, this will be one of the other problems that arise when using kotlinx.synthetics.

Google immediately offered ViewBinding as a replacement, so in this article we will not look at alternatives, but focus on the features and pitfalls of switching from one method of working with View to another.

What we had

Before we started the work, we decided to estimate the problem scale. We had 570 files using kotlinx.android.synthetic and about 5 thousand requests to View using synthetics. There were also 3 types of classes that used this way of working with View: Fragment, Custom View and Cell — our abstraction over list elements, saving us from writing a boilerplate. And all of them were permeated by it through and through.

We had 570 files using kotlinx.android.synthetic and about 5 thousand calls to View using synthetics. There were also 3 types of classes that used this way of working with View…

The task was shaped in the following way:

  1. Add a Binding object declaration to each class with Synthetics. Different for each of the 3 types of classes
  2. All call to View via Synthetics are to changed for requests via binding
  3. Delete synthetics imports, add imports for ViewBinding
  4. In the build module files, remove the kotlin-android-extensions plugin and add android.buildFeatures.viewBinding = true

What should be used to migrate to ViewBinding?

Surprisingly, no ready-made migration solutions suitable for our project could be found.

The first thing that came up in our heads was to write a script, for example in Python, which:

  • Will parse all .xml files in the src/main/res/layout folders of all modules in the project for the “android:id=”@+id/whatever” attribute
  • With a lot of IDs, will go through Fragment.kt and similar files, replacing the calls corresponding to the parsed IDs with binding.someViewId

It seems that with such an approach, it would not be difficult to perform all the other task stages as well. But we rejected it because of the unreliability of the solution: with .kt files the script would work as with plain text, so there is a great risk of getting completely invalid code, for example, when accessing within ViewHolder via itemView:

Why we chose IntelliJ Plugins

The main reason is that IDEA plugins allow you to work with project code as Abstract Syntax Tree, presenting each element of our code as a typed tree element object. Such an object contains all the necessary metadata about the code element it represents. So we were confident that this way we could define calls to the View through Kotlinx Synthetics.

This solution seemed more reliable than working with code as with text. We didn’t expect false responses from it, either. In addition, plugins have access to the project’s indexes, which allows us to collect data about the connection between the code and the xml layout.

The second reason is that the plugin is more flexible than the script. Accordingly, it will be easier to adapt it for other projects.

The third reason is the team’s expertise growth. One more person’s dive into the subject has improved the bus factor.

Contiguous migration or single shots

IntelliJ Plugins allow you to write custom actions for the context menu that are applied to a user-selected IDE file, so we decided to use this method to implement our refactoring.

But why not do refactoring for the whole project at once? You can run the plugin, it goes through the whole project — profit. But thanks to the experience of our previous global refactoring, we realized that this is a completely uncontrollable process, which will also break the building of the entire project, until the developers fix each file manually. Therefore, step by step.

What we have achieved

  • Find ~95% of synthetics usages and replace them with ViewBinding
  • Replace file’s imports
  • Add a declaration of the View Binding property to the class (Fragment, Cell, View)

The last point from the initial task: change the build.gradle in the module — was not implemented, but through plugins for IDEA it is quite possible. You can see an example in other plugins from our team.

Import replacements and property declaration is not that difficult and interesting task, you can see the detailed implementation of these items in the plugin repository. But I will tell you about implementation of the funniest part — finding invocations through synthetics and replacing them with ViewBinding.

Synthetics -> View Binding

Searching for files for refactoring

That was the first time I encountered plugin development for IDEA, and in many ways I was pleasantly surprised. For example, the following code is enough to check that the selected file is suitable for our plugin:

The code of hasSyntheticImports extension is also quite declarative:

This is all the code that was needed for our Action to recognize the correct files.

Searching for synthetics usage

The basic unit in IntelliJ’s Abstract Syntax Tree is the PsiElement.

All the possible object types in our code are inherited from it: operators, expressions, arguments, classes, and so on:

A small explanation of PsiElement

It is a basic interface for any element that represents an object in code. Functions (e.g., KtReferenceExpression), annotations (KtAnnotiatonEntry), classes (KtClass), and whatever else is inherited from it: from variables to binary operators.

We needed to find PsiElements inside this tree, which represented the following expressions:

To find and collect information to later replace them with ViewBinding, we decided to bypass the PSI tree of Kotlin files using inheritance from KotlinRecursiveElementVisitor. The Visitor pattern is a standard way to bypass code in IntelliJ Plugins:

By trial and error, we figured out that we need to redefine two callbacks:

We get KtReferenceExpression and KtCallExpression, both inherited from PsiElement, as a function parameter in callbacks. From that interface we can get a lot of useful data about the element, for example: the text representation, all of its branches and links to all of the related objects. This is exactly the list of references we need.

The link is represented by the PsiReference interface, and contains the resolve() method, which allows you to get the PsiElement at which the link points to. It can be used to determine if the received element is a View ID:

Modifying the code

The last step is to modify the code. We need to do the following:

  • Generate the text for a new code
  • Create a PsiElement heir object of the type we want
  • Replace a previously found call via synthetic with a new object

The first action is pretty straightforward. We already got an XmlAttributeValue object, where the value field is the text from the android:id field of our xml file. For example “@+id/fragment_about_description_text_view”. We need to get a line like “binding.fragmentAboutDescriptionTextView” in any convenient way.

KtPsiFactory will help us in creating a new piece of code and from this object we can create any element, such as arguments for functions and expressions:

For now, this is just an object in our IDE’s memory, not written to the codebase. The last step is to replace the previously found PsiElement with it:

As a result, our code has changed like this:

That’s all the basic part of our plugin’s algorithm. For more details on the synthetic search engine and code modifications, you can visit the plugin repository.

How plugin usage looked like

We divided the codebase into 10 parts with roughly equal number of uses of kotlinx.android.synthetic. We applied the plugin sequentially to files from each part, then went through those files manually, fixing highlighted IDE errors. These were mostly nullable View calls that were supported by synthetic, but not supported by ViewBinding, which sometimes required a little local refactoring.

Much rarer were files that used more than one .xml layout. We didn’t teach the plugin to handle such cases, so we had to intervene here too. More details on how to handle such cases in the ViewBinding in general will be described in the “Pitfalls” section.

Another frustrating thing was the with, apply and let constructions with large attachment. The plugin couldn’t get very deep, and the calls to the View remained there in the old form of some_view_id.

The total amount of changed code is about +6800 and — 6000 lines. There is more code, but mostly due to the need to declare ViewBinding in every file where it is used.

Pitfalls

ID from android space

Since the refactoring didn’t create any new scenarios, but affected all existing ones, the UI tests helped to discover the vast majority of the problems resulting from the refactoring. Their run-through after the infusion of some refactoring usually looked like this:

After a few hours or so of searching for the problem and its solution, it was getting better:

Then the typical “right, I forgot to fix it still in the horizontal layout,” and finally:

Tests caught such a kind of rare case as a crash due to using View “android:id=android:id/text1” deep in the bowels of the old screens for ArrayAdapter with a drop-down list. The solution was to add:

in the layout file, which was used as an item in the ArrayAdapter. The ViewBinding cannot generate a wrapper for a View with an ID from android space.

The problem of layout with qualifiers

ViewBinding cannot wisely generate a wrapper for xml layout, if different versions of it, for example for phones and tablets, have different View sets. It will only work fine if the base layout has a View that is not present in the other configurations.

The easiest solution was to write your own Binding class, which has a proxy to all View from all configurations, making them nullable:

<include>

We often use include to break up a large layout into separate parts or to reuse its fragments. There are a couple of pitfalls hidden here, too.

Each included layout will have its own ViewBinding class, which can be obtained in different ways, depending on the following conditions:

  1. If the root layout tag we specified in the include is <merge>, then we need to manually create a Binding object for it:

So, besides the main ViewBinding object, we will have auxiliary ones for each included merge layout of our fragment. And one more important thing — in xml for such a tag you can’t specify the ID attribute, it will result in a compilation error.

2. If the root tag is an any View, the situation becomes easier. Be sure to specify an ID attribute for our included layout, and then we can get it from the main binding object:

Where fragmentNotificationSettingsMainLoadingContainer is the ID of the included layout, through which we get an attached object ViewBinding.

Highlights from Intellij Plugins development

Debugger

Another point worth mentioning is the debugger for IDEA plugins:

It allows you to run code in runtime and see the result, having all the objects of the scope where the current breakpoint is. The debugger helped me a lot when searching for a way to determine that the current code element is a View reference.

Internal Actions Viewer

The last killer feature for plugin developers, which helped me enormously, is Viewer for any interface elements. Click control + option + lmb on any button, for example, in the file context menu, and you see this window:

In the property column, pay attention to the Action value — there is written the specific class that performs the action for which we called this window. This way, you can see how JetBrains themselves have implemented various IDE functions, such as code formatting or renaming a class. A lot of things can be seen and adopted for your plugins.

To enable this debugging option, you need to write a flag in the IDEA according to these instructions.

MISSING_DEPENDENCY_CLASS

The most troublesome problem is that it slowed me down a lot in the beginning. It seemed to connect everything correctly, but the IDE highlighted an error in the code:

At the same time, the project was getting built, but the previously mentioned debugger functions refused to work, which greatly slowed down the process of learning the API of working with Abstract Syntax Tree.

After some time of research, I managed to find out that the problem was not in my build file, but in the Kotlin plugin for Gradle. You can read about it in more detail in the relevant issues here and here.

The problem is solved very simply (and weirdly):

Simply put, we add manual cast to the necessary type file.getClasses() as Array<PsiClass>. The IDE will highlight it as redundant, but the debugger will start working and MISSING_DEPENDENCY_CLASS will stop bothering you.

Instead of summary

Developing autorefactoring through a plugin was an interesting experience. I definitely recommend you to consider this method if your code refactoring requires some metadata about the objects, for example, to check the type of variable or the return type of function. If it is not specified in the declaration, then working with the code as text, it will be impossible to do this, while PSI will have this data.

On the other hand, for simple cases, such as replacing the package in all the files, Python looks like the fastest solution, and in many cases the built-in IDEA refactoring tools will be enough.

In general, as always, it is necessary to choose a solution tool individually for each problem, but I can definitely recommend taking Intellij Plugins into your arsenal.

Useful links

Issue on the tracker with a warning to declare deprecated kotlin-android-extensions plugin: https://youtrack.jetbrains.com/issue/KT-42121

If you want to dive into developing your own IDE plugins, here are some useful links:

  • You can start with a workshop by my colleague, where he develops a small plugin from scratch and shares the handy lifehacks for quick immersion onto the topic: part 1 and part 2.
  • Template of plugin from Jetbrains on Github. It is already configured CI and all sorts of basic stuff. I recommend cloning it. This will save some time on writing a boilerplate and setting up the project.
  • The official Intellij Plugins documentation was unfortunately much less helpful because of the almost complete lack of examples, but there is plenty of theory.

And, of course, the repository with our plugin. This solution is by no means universal and will definitely require modification if you want to use it, but it will certainly give you a glimpse of something useful.

Thank you for your attention. Have fast migrations and less problems with automation, see you on our blog!

--

--