Привет! Меня зовут Ваня, я Android-разработчик из продуктовой команды hh.ru, и в этой статье я расскажу о нашем опыте миграции на ViewBinding.

В конце 2020 года в официальном блоге Android Developers объявили, что android-kotlin-extensions plugin для Gradle больше не дружит с Koltin с сентября 2021 и будет объявлен  ̶э̶к̶с̶к̶о̶м̶ь̶ю̶н̶и̶к̶а̶д̶о̶ deprecated.

В нашем  Android приложении довольно большая кодовая база, и этот плагин использовался повсеместно и на каждом экране. Делать код-фриз и направлять все силы разработчиков на миграцию совсем не хотелось, и мы решили попробовать автоматизировать процесс рефакторинга, а заодно сделать его итеративным.

 Для тех, кому больше нравится смотреть, а не читать, есть видеоверсия.  

Зачем мигрировать

В конце 2020 года случился  пост с предупреждением о том, что android-kotlin-extensions plugin для Gradle более не будет поставляться с Koltin. И хоть на момент февраля 2022 года он всё ещё поставляется с Kotlin, миграция всё равно была неизбежна. Ведь рано или поздно его оттуда уберут, а цена synthetics в проекте сейчас — это потенциальная невозможность обновить Kotlin в будущем. Разумеется, помимо прочих проблем, которые возникают при использовании kotlinx.synthetics.

Google в качестве замены сразу предложило ViewBinding, поэтому в этой статье мы не станем рассматривать альтернативы, а сосредоточимся на особенностях и подводных камнях перехода с одного способа работы с View на другой. 

Дано

До начала работ мы решили оценить масштаб проблемы. У нас было 570 файлов, в которых используется kotlinx.android.synthetic и около 5 тысяч обращений ко View с помощью synthetics. Также  было 3 типа классов, в которых использовался такой способ работы с View: Fragment, Custom View и Cell — наша абстракция над элементами списков, спасающая от написания бойлерплейта. И все они были пронизаны им вдоль и поперек.

Задача выглядела так:

  1. Добавить объявление объекта Binding в каждый класс с Synthetics. Разное для каждого из 3-х типов классов

  2. Все обращения ко View через Synthetics заменить на обращения через binding

  3. Удалить импорты synthetics, добавить импорты для ViewBinding

  4. В билд файлах модулей убрать плагин kotlin-android-extensions и добавить android.buildFeatures.viewBinding = true

Чем мигрировать на ViewBinding?

Удивительно, но готовых решений по миграции, подходящих нашему проекту, обнаружить не удалось.

Первое, что пришло в голову — написать скрипт, например на Python, который:

  • Распарсит все .xml файлы в папках src/main/res/layout всех модулей проекта на предмет атрибута "android:id=”@+id/whatever”

  • Имея множество айдишников, пройдется по Fragment.kt и подобным файлам, заменяя в них вызовы, соответствующие распаршенным айдишникам на binding.someViewId

Кажется, что с таким подходом несложно было выполнить и все остальные пункты задачи. Но мы отказались от него ввиду  ненадежности решения: с .kt файлами скрипт работал бы как с обычным текстом, из-за чего велик риск получить совсем невалидный код, например, при обращениях внутри ViewHolder через itemView:

// До применения скрипта

override fun bind(viewHolder: RecyclerView.ViewHolder, payloads: List<Any>) {
   viewHolder.itemView.my_awesome_text_view.text = "lorem ipsum"
}

// После применения скрипта

override fun bind(viewHolder: RecyclerView.ViewHolder, payloads: List<Any>) {
   viewHolder.itemView.binding.myAwesomeTextView.text = "lorem ipsum"
}

Также Python не сумеет отличить ресурсы типа R.id от R.string и прочих из namespace R, что тоже может кончиться проблемами с ложными срабатываниями скрипта.

Почему выбрали IntelliJ Plugins

Основная причина — плагины IDEA позволяют работать с кодом проекта как с Abstract Synthax Tree, представляя каждый элемент нашего кода как типизированный объект элемента дерева. Такой объект содержит все необходимые метаданные об элементе кода, который представляет. Поэтому мы были уверены, что таким образом получится определить вызовы к View через Kotlinx Synthetics. 

Такое  решение выглядело более надежно, чем работа с кодом, как с текстом. Ложных срабатываний мы тоже от него не ждали. К тому же у плагинов есть доступ к индексам проекта, что позволяет  собрать данные о связи кода и xml layout.

Вторая причина — плагин более гибкий, чем скрипт. Соответственно, его проще будет адаптировать для других проектов.

Третья причина — повышение экспертизы в команде. Погружение еще одного человека в тему позволило улучшить bus-фактор.

Ковровая миграция или одиночные выстрелы

IntelliJ Plugins позволяют писать свои кастомные экшены для контекстного меню, которые применяются к выбранному пользователем IDE файлу, поэтому мы решили использовать этот способ для реализации нашего рефакторинга. 

Но почему бы не сделать рефакторинг сразу для всего проекта? Запустил плагин, он прошелся по всему проекту – профит. Но благодаря опыту наших предыдущих глобальных рефакторингов, мы поняли, что это совсем неконтролируемый процесс, который еще и сломает сборку всего проекта до тех пор, пока разработчики не поправят каждый файл вручную. Поэтому — step by step.

Что у нас получилось

  • Найти ~95% использований synthetics и заменить на ViewBinding

  • Заменить импорты в файле

  • Добавить объявление проперти View Binding в класс (Fragment, Cell, View)

Последний пункт из начальной задачи: изменение build.gradle в модуле – не реализовали, но через плагины для IDEA это вполне возможно. Пример можно глянуть в других плагинах от нашей команды.

Замена импортов и объявление проперти – не такая сложная и интересная задача,  подробную реализацию этих пунктов вы можете посмотреть в репозитории с плагином. А вот о реализации самой интересной части — поиске вызовов через synthetics и их замену на ViewBinding, я расскажу.

Synthetics -> View Binding

Ищем файлы для рефакторинга

Мне пришлось впервые столкнуться с разработкой плагинов для IDEA, и во многом я был приятно удивлен. Например, следующего кода достаточно, чтобы проверить, что выбранный файл подходит для нашего плагина:

val psiFile = e.getData(CommonDataKeys.PSI_FILE)
val isValidForFile = psiFile != null
       && psiFile is KtFile
       && psiFile.hasSyntheticImports()

Код экстеншена hasSyntheticImports тоже весьма декларативен:

fun KtFile.hasSyntheticImports(): Boolean {
   return importDirectives.any {
       it.importPath?.pathStr
           ?.startsWith(Const.KOTLINX_SYNTHETIC) == true
   }
}

Вот и весь код, который понадобился, чтобы наш Action распознал подходящие для него файлы.

Ищем использование synthetics

Базовой единицей в Abstract Synthax Tree от IntelliJ является PsiElement.

От него наследуются все возможные типы объектов нашего кода: операторы, выражения, аргументы, классы и так далее:

Небольшое пояснение на тему PsiElement

Это базовый интерфейс для любого элемента, представляющего объект в коде. От него наследуются и функции (например KtReferenceExpression), и аннотации (KtAnnotiatonEntry), и классы (KtClass), и всё остальное: от переменных до бинарных операторов.

Нам нужно было найти PsiElement-ы внутри этого дерева, которые представляли следующие выражения:

with (view_id) {...}
with (anything) { view_id.doSomething() }
anything.setOnClickListener { view_id.gone() }
someFunc(view_id)
view_id.viewProperty = somehting
view_id.apply {...}
view_id.someFunc(...)

Чтобы найти и собрать  информацию для их последующей замены на ViewBinding мы решили обойти PSI дерево Kotlin файлов с помощью наследования от KotlinRecursiveElementVisitor. Паттерн Visitor — стандартный способ для обхода кода в IntelliJ Plugins:

Методом проб и ошибок мы выяснили, что нам нужно переопределить два колбэка:

fun visitReferenceExpression(expression: KtReferenceExpression)
fun visitCallExpression(expression: KtCallExpression)

В качестве параметра функции в колбэках нам прилетают KtReferenceExpression и KtCallExpression, они оба наследуются от PsiElement. Из этого интерфейса можно достать много полезных данных об элементе, например: текстовое представление, все дочерние элементы и ссылки на все связанные с этим элементом объекты. Как раз список этих ссылок нам и нужен.

Ссылка представлена интерфейсом PsiReference, и содержит метод resolve() который позволяет получить PsiElement, куда указывает ссылка. Уже по нему можно определить, является ли полученный элемент айдишником View:

private fun PsiElement.takeIfAndroidViewIdAttr(): XmlAttributeValue? {
   return when {
       elementType == XmlElementType.XML_ATTRIBUTE_VALUE &&
           (this as XmlAttributeValue).value.startsWith("@+id/") -> this
       else -> null
   }
}

Модифицируем код

Последний шаг — модифицировать код. Здесь нам потребуется сделать следующее:

  • Сгенерировать текст для нового кода

  • Создать объект наследник PsiElement нужного нам типа

  • Заменить найденный ранее вызов через синтетик на новый объект

С первым действием всё предельно просто. Мы уже получили объект XmlAttributeValue, где поле value представляет собой текст из поля android:id нашего xml файла. Например “@+id/fragment_about_description_text_view”. Нам нужно получить из него строку вида “binding.fragmentAboutDescriptionTextView” любым удобным способом.

В создании нового фрагмента кода нам поможет KtPsiFactory, из этого объекта можно создать любой элемент, например, аргумент для функций и выражений:

val newElement = psiFactory.createArgument(“binding.fragmentAboutDescriptionTextView”)

Пока это просто объект в памяти нашей IDE, не записанный в кодовую базу. Последним шагом заменим на него найденный ранее PsiElement:

element.replace(newElement)

В итоге наш код изменился так:

// было
hideView(fragment_about_description_text_view)

// стало
hideView(binding.fragmentAboutDescriptionTextView)

Вот и вся основная часть алгоритма нашего плагина. Более подробно устройство поиска синтетиков и модификации кода вы можете посмотреть в репозитории плагина.

Как выглядело использование плагина

Мы разбили кодовую базу на 10 частей с примерно равным количеством использований kotlinx.android.synthetic. Применяли плагин последовательно к файлам из каждой части, затем проходились вручную по этим файлам, исправляя подсвеченные IDE ошибки. В основном это были nullable вызовы View, которые поддерживались синтетиками, но не поддерживались ViewBinding, что иногда требовало небольшого местного рефакторинга.

Гораздо реже это были файлы, в которых использовалось больше одного .xml layout. Мы не научили плагин обрабатывать такие кейсы, поэтому здесь тоже приходилось вмешиваться. Подробнее о том, как вообще работать с такими кейсами на ViewBinding будет описано в секции “Подводные камни”.

Еще одним неприятным моментом были конструкции with, apply и let с большой вложенностью. Плагин не мог пробраться на большую глубину и вызовы ко View оставались там в старом виде some_view_id.

Итоговый объем измененного кода примерно +6800 и — 6000 строк. Кода стало больше, но в основном из-за необходимости объявлять ViewBinding в каждом файле, где он используется.

Подводные камни

id из пространства android

Т.к. рефакторинг не создавал никаких новых сценариев, но затрагивал все существующие, UI тесты помогли обнаружить подавляющее большинство проблем, возникших в результате рефакторинга. Их прогон после вливания части рефакторинга обычно выглядел следующим образом:

Спустя час-другой поисков проблемы и ее решения становилось лучше:

Затем типичное “точно, забыл пофиксить еще и в горизонтальной верстке”, и наконец:

Тесты отловили такой экзотический кейс, как краш из-за использования у View "android:id=@android:id/text1" глубоко в недрах старых экранов для ArrayAdapter с выпадающим списком. Решением стало добавление

tools:viewBindingIgnore="true"

в layout файл, который использовался как item в ArrayAdapter. ViewBinding не может сгенерировать обертку для View с id из пространства android.

Проблема layout с квалифаерами

ViewBinding не умеет адекватно генерировать обертку для xml layout, если в разных его версиях, например для телефонов и планшетов, будет разный набор View. Нормально это сработает только если в базовом layout есть View, которых нет в остальных конфигурациях.

Самым простым решением оказалось написать свой класс Binding, который имеет прокси ко всем View из всех конфигураций, сделав их nullable:

internal class SearchVacancyResultListBinding(private val rootView: View) : ViewBinding {
   
  val viewSearchVacancyResultListRecycler: RecyclerView = ViewBindings
       .findChildViewById(rootView, R.id.view_search_vacancy_result_list_recycler)!!
   val viewSearchVacancyResultListStubView: ZeroStateView = ViewBindings
       .findChildViewById(rootView, R.id.view_search_vacancy_result_list_stub_view)!!
   val viewSearchVacancyResultListBottomHeader: BottomSheetHeaderView? = ViewBindings
       .findChildViewById(rootView, R.id.view_search_vacancy_result_list_bottom_header)
   val viewSearchVacancyResultListLockSize: View? = ViewBindings
       .findChildViewById(rootView, R.id.view_search_vacancy_result_list_lock_size)
   val viewSearchVacancyResultListContent: FrameLayout? = ViewBindings
       .findChildViewById(rootView, R.id.view_search_vacancy_result_list_content)
       
   override fun getRoot(): View = rootView
}

<include>

Мы часто используем include, чтобы разбить большие layout на отдельные части или переиспользовать его фрагменты. Здесь тоже спрятались пара подводных камней.

Каждый included layout будет обладать своим классом ViewBinding, получить который можно разными способами, в зависимости от следующих условий:

  1. Если рутовый тег layout, который мы указали в include, — <merge>, тогда нам понадобится вручную создать для него объект Binding:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   …
   val toolbarBinding = MergeViewToolbarWithButtonBinding.bind(view)
   …
}

Таким образом, помимо основного объекта ViewBinding, для layout нашего фрагмента у нас будут еще и вспомогательные для каждого included merge layout. И еще один важный момент — в xml для такого тега нельзя указывать атрибут id, это кончится ошибкой компиляции.

  1. Если рутовый тег является произвольной View, то ситуация становится проще. Обязательно указываем атрибут id для нашего included layout, и тогда мы сможем достать его из основного объекта binding:

binding.fragmentNotificationSettingsMainLoadingContainer.viewNotificationSettingsLoadingHeader

Где fragmentNotificationSettingsMainLoadingContainer это id included layout, через который получаем вложенный объект ViewBinding.

Хайлайты из разработки Intellij Plugins

Debugger

Еще один заслуживающий отдельного упоминания момент – дебаггер для плагинов IDEA:

Он позволяет в рантайме выполнять код и видеть результат, имея в распоряжении все объекты того скоупа, где стоит текущий брейкпоинт. При поиске способа определить, что текущий элемент кода – это обращение к View, дебаггер очень сильно мне помог.

Internal Actions Viewer

Последняя киллер-фича для разработчиков плагинов, которая мне сильно помогла, это Viewer для любых элементов интерфейса. Кликаем control + option + lmb на любой кнопке, например, в контекстном меню файла, и видим такое окошко:

В столбце property обратите внимание на значение Action — там написан конкретный класс, который выполняет действие, для которого мы вызвали это окно. Таким образом можно посмотреть, как сами JetBrains реализовали различные функции IDE, такие как форматирование кода или переименование класса. Многое можно подглядеть и перенять для своих плагинов.

Чтобы включить эту дебажную опцию, нужно прописать флаг в IDEA по этой инструкции.

MISSING_DEPENDENCY_CLASS

Самая неприятная проблема сильно затормозила меня в начале. Вроде всё подключил правильно, но IDE подсвечивала ошибку в коде:

При этом, проект собирался, но ранее упомянутые функции дебаггера отказывались работать, что сильно замедляло процесс изучения API работы с Abstract Synthax Tree.

Через какое-то время ресерча удалось обнаружить, что проблемы была не в моем билдфайле, а в плагине Kotlin для Gradle. Подробнее о ней можно почитать в соответствующих issue здесь и здесь.

Проблема решается очень просто (и странно):

fun addViewBindingProperty() {
   val classes = (file.getClasses() as Array<PsiClass>).mapNotNull { psiClass ->
       val ktClass = ((psiClass as? KtLightElement<, *>)?.kotlinOrigin as? KtClass)
       if (ktClass == null) {
           null
       } else {
           psiClass to ktClass
       }
   }

Проще говоря, мы  добавляем ручной каст к нужному нам типу file.getClasses() as Array<PsiClass>. IDE подсветит его как избыточный, но дебаггер начнет работать и MISSING_DEPENDENCY_CLASS перестанет мозолить глаза.

Вместо заключения

Разработка авторефакторинга через плагин оказалась интересным опытом. Однозначно рекомендую рассмотреть этот способ в том случае, если ваш рефакторинг кода требует получить какие-то метаданные об объектах, например, проверить тип переменной или возвращаемый тип функции. Если он не указан при объявлении, то, работая с кодом как с текстом, это сделать  будет невозможно сделать, а в PSI эти данные будут.

С другой стороны, для простых кейсов, вроде  замены package во всех файлах, Python выглядит как наиболее быстрое решение, а во многих случаях хватит и встроенных инструментов IDEA для рефакторинга.

В общем, как и всегда, выбирать инструмент решения проблемы нужно индивидуально под каждую проблему, но я однозначно могу порекомендовать взять Intellij Plugins в свой арсенал.

Полезные ссылки

Issue на трекере с предупреждением объявить deprecated плагина kotlin-android-extensions: https://youtrack.jetbrains.com/issue/KT-42121

Если захотите погрузиться в разработку собственных IDE-плагинов, вот несколько полезных ссылок:

  • Начать можно с воркшопа от моего коллеги, в котором он с нуля разрабатывает небольшой плагин и делится всеми полезным лайфками для быстрого погружения в тему: часть один и часть два.

  • Шаблон плагина от Jetbrains на Github там уже настроен CI и всякие базовые мелочи, рекомендую склонировать его, позволит сэкономить немного времени на написании бойлерплейта и настройке проекта.

  • Официальная документация Intellij Plugins к сожалению оказалась куда менее полезна из-за практически полного отсутствия примеров, но теории там достаточно.

И конечно же репозиторий с нашим плагином. Решение отнюдь не универсальное и точно потребует доработок, если вы захотите его использовать у себя, но подглядеть что-то полезное наверняка получится.

Спасибо за внимание, быстрых вам миграций и поменьше проблем с автоматизацией, до встречи в нашем блоге!

Комментарии (1)


  1. ImAllIn
    10.02.2022 12:27
    +3

    Спасибо за опыт!

    Тоже недавно столкнулись с похожей задачей, теперь есть на что опереться ;)