Kotlin android extensions как подключить
Перейти к содержимому

Kotlin android extensions как подключить

  • автор:

Расширения (extensions)

Kotlin позволяет расширять класс путём добавления нового функционала без необходимости наследования от такого класса и использования паттернов, таких как Decorator. Это реализовано с помощью специальных выражений, называемых расширения.

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

Функции-расширения

Для того чтобы объявить функцию-расширение, укажите в качестве префикса расширяемый тип, то есть тип, который мы расширяем. Следующий пример добавляет функцию swap к MutableList :

fun MutableList.swap(index1: Int, index2: Int) < val tmp = this[index1] // 'this' даёт ссылку на список this[index1] = this[index2] this[index2] = tmp >

Ключевое слово this внутри функции-расширения соотносится с объектом расширяемого типа (этот тип ставится перед точкой). Теперь мы можем вызывать такую функцию в любом MutableList .

val list = mutableListOf(1, 2, 3) list.swap(0, 2) // 'this' внутри 'swap()' будет содержать значение 'list' 

`, and you can make it generic: —>

Следующая функция имеет смысл для любого MutableList , и вы можете сделать её обобщённой:

fun MutableList.swap(index1: Int, index2: Int) < val tmp = this[index1] // 'this' относится к списку this[index1] = this[index2] this[index2] = tmp >

Вам нужно объявлять обобщённый тип-параметр перед именем функции для того, чтобы он был доступен в получаемом типе-выражении. См. Обобщения.

Расширения вычисляются статически

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

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

open class Shape class Rectangle: Shape() fun Shape.getName() = "Shape" fun Rectangle.getName() = "Rectangle" fun printClassName(s: Shape) < println(s.getName()) >printClassName(Rectangle()) 

Этот пример выведет нам Shape на экран потому, что вызванная функция-расширение зависит только от объявленного параметризованного типа s , который является Shape классом.

Если в классе есть и функция-член, и функция-расширение с тем же возвращаемым типом, таким же именем и применяется с такими же аргументами, то функция-член имеет более высокий приоритет.

class Example < fun printFunctionType() < println("Class method") >> fun Example.printFunctionType() < println("Extension function") >Example().printFunctionType() 

Этот код выведет Class method.

Однако для функций-расширений совершенно нормально перегружать функции-члены, которые имеют такое же имя, но другую сигнатуру.

class Example < fun printFunctionType() < println("Class method") >> fun Example.printFunctionType(i: Int) < println("Extension function #$i") >Example().printFunctionType(1) 

Обращение к Example().printFunctionType(1) выведет на экран надпись Extension function #1.

Расширение null-допустимых типов

Обратите внимание, что расширения могут быть объявлены для null-допустимых типов. Такие расширения могут ссылаться на переменные объекта, даже если значение переменной равно null и есть возможность провести проверку this == null внутри тела функции.

Благодаря этому метод toString() в Kotlin вызывается без проверки на null : она проходит внутри функции-расширения.

fun Any?.toString(): String < if (this == null) return "null" // после проверки на null, `this` автоматически приводится к не-null типу, // поэтому toString() обращается (ориг.: resolves) к функции-члену класса Any return toString() >

Свойства-расширения

Аналогично функциям, Kotlin поддерживает расширения свойств.

val List.lastIndex: Int get() = size - 1 

Since extensions do not actually insert members into classes, there’s no efficient way for an extension > property to have a [backing field](properties.md#backing-fields). This is why _initializers are not allowed for > extension properties_. Their behavior can only be defined by explicitly providing getters/setters. —>

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

val House.number = 1 // ошибка: запрещено инициализировать значения // в свойствах-расширениях 

Расширения для вспомогательных объектов (ориг.: companion object extensions)

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

class MyClass < companion object < >// называется "Companion" > fun MyClass.Companion.printCompanion()

Область видимости расширений

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

package org.example.declarations fun List.getLongestString() < /*. */>

Для того, чтобы использовать такое расширение вне пакета, в котором оно было объявлено, импортируйте его на месте вызова.

package org.example.usage import org.example.declarations.getLongestString fun main()

См. Импорт для более подробной информации.

Объявление расширений в качестве членов класса

Внутри класса вы можете объявить расширение для другого класса. Внутри такого объявления существует несколько неявных объектов-приёмников (ориг.: implicit receivers), доступ к членам которых может быть произведён без квалификатора. Экземпляр класса, в котором расширение объявлено, называется диспетчером приёмников (ориг.: dispatch receiver), а экземпляр класса, для которого вызывается расширение, называется приёмником расширения (ориг.: extension receiver).

class Host(val hostname: String) < fun printHostname() < print(hostname) >> class Connection(val host: Host, val port: Int) < fun printPort() < print(port) >fun Host.printConnectionString() < printHostname() // вызывает Host.printHostname() print(":") printPort() // вызывает Connection.printPort() >fun connect() < /*. */ host.printConnectionString() // вызов функции-расширения >> fun main() < Connection(Host("kotl.in"), 443).connect() // Host("kotl.in").printConnectionString() // ошибка, функция расширения недоступна вне подключения >

В случае конфликта имён между членами классов диспетчера приёмников и приёмников расширения, приоритет имеет приёмник расширения. Чтобы обратиться к члену класса диспетчера приёмников, можно использовать синтаксис this с квалификатором.

class Connection < fun Host.getConnectionString() < toString() // вызывает Host.toString() this@Connection.toString() // вызывает Connection.toString() >> 

Расширения, объявленные как члены класса, могут иметь модификатор видимости open и быть переопределены в унаследованных классах. Это означает, что диспечеризация таких функций является виртуальной по отношению к типу диспетчера приёмников, но статической по отношению к типам приёмников расширения.

open class Base < >class Derived : Base() < >open class BaseCaller < open fun Base.printFunctionInfo() < println("Base extension function in BaseCaller") >open fun Derived.printFunctionInfo() < println("Derived extension function in BaseCaller") >fun call(b: Base) < b.printFunctionInfo() // вызов функции расширения >> class DerivedCaller: BaseCaller() < override fun Base.printFunctionInfo() < println("Base extension function in DerivedCaller") >override fun Derived.printFunctionInfo() < println("Derived extension function in DerivedCaller") >> fun main() < BaseCaller().call(Base()) // "Base extension function in BaseCaller" DerivedCaller().call(Base()) // "Base extension function in DerivedCaller" - приемник отправки является виртуальным DerivedCaller().call(Derived()) // "Base extension function in DerivedCaller" - приемник расширения является статическим >

Примечание о видимости

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

  • Расширение, объявленное на верхнем уровне файла, имеет доступ к другим private объявлениям верхнего уровня в том же файле;
  • Если расширение объявлено вне своего типа приёмника, оно не может получить доступ к private или protected членам приёмника.

© 2015—2024 Open Source Community

Урок 7. ViewModel и LiveData. Сохранение и передача состояния активити при повороте устройства

Продолжаем курс по обучению основам разработки мобильных приложений в Android Studio на языке Kotlin.

Это урок 7, в котором разберемся, зачем сохранять состояние активити при изменениях конфигурации и какие инструменты для этого лучше использовать: savedInstanceState или ViewModel и LiveData.
Предыдущий урок, на котором мы разбирали жизненный цикл активити, здесь.

Урок 7. ViewModel и LiveData. Сохранение и передача состояния активити при повороте устройства

При пересоздании активити ViewModel остается живым и используется во вновь созданном активити.

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

Приложение с ViewModel

Рассмотрим простой пример приложения, которое использует ViewModel.

За основу был взят этот пример на Github.

В файле сборки build.gradle модуля app добавьте такие зависимости для работы со списком и ViewModel:

dependencies

Поскольку приложение будет работать со списком пользователей, нам понадобится модель, сущность:

data class User ( var name: String = "", var description: String = "" )

Здесь два поля – имя и описание.

Далее создадим файл object, выполняющий роль поставщика данных:

object UserData

В реальном приложении данные поставляются из сети или БД, здесь же для простоты просто создаются два статичных списка пользователей. Первый список, который поменьше, будем отображать при старте приложения. Второй список будем отображать по нажатию кнопки в меню главного экрана.

Для отображения списка нам нужно создать файл макета элемента списка user_item.xml в папке ресурсов res/layout:

Изменим файл макета activity_main.xml для размещения списка на главном экране, добавив виджет списка RecyclerView:

Также нужно создать меню, для этого в папке res создадим папку menu и в ней файл main_menu.xml:

Это меню с одним пунктом Refresh, по нажатию которого будем обновлять список.

Теперь адаптер, который будет создавать список и наполнять его данными:

import android.support.v7.widget.RecyclerView import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import kotlinx.android.synthetic.main.user_item.view.* import java.util.ArrayList class UserAdapter : RecyclerView.Adapter() < private var users: List= ArrayList() //создает ViewHolder и инициализирует views для списка override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserHolder < return UserHolder( LayoutInflater.from(parent.context) .inflate(R.layout.user_item, parent, false) ) >//связывает views с содержимым override fun onBindViewHolder(viewHolder: UserHolder, position: Int) < viewHolder.bind(users[position]) >override fun getItemCount() = users.size //передаем данные и оповещаем адаптер о необходимости обновления списка fun refreshUsers(users: List) < this.users = users notifyDataSetChanged() >//внутренний класс ViewHolder описывает элементы представления списка и привязку их к RecyclerView class UserHolder(itemView: View) : RecyclerView.ViewHolder(itemView) < fun bind(user: User) = with(itemView) < userName.text = user.name userDescription.text = user.description >> > 

Унаследуем наш адаптер от RecyclerView.Adapter и указываем наш собственный ViewHolder, который предоставит доступ к View-компонентам. Далее инициализируем список. Функция onCreateViewHolder создает ViewHolder и инициализирует View-компоненты для списка. Функция onBindViewHolder связывает View-компоненты с содержимым. В функции refreshUsers передаем данные и оповещаем адаптер о необходимости обновления списка вызовом notifyDataSetChanged(). Внутренний класс ViewHolder описывает View-компоненты списка и привязку их к RecyclerView.

Теперь мы подходим к самому главному – получению данных для списка.

Этим будет заниматься класс UserViewModel, унаследованный от ViewModel:

import android.arch.lifecycle.MutableLiveData import android.arch.lifecycle.ViewModel class UserViewModel : ViewModel() < var userList : MutableLiveData> = MutableLiveData() //инициализируем список и заполняем его данными пользователей init < userList.value = UserData.getUsers() >fun getListUsers() = userList //для обновления списка передаем второй список пользователей fun updateListUsers() < userList.value = UserData.getAnotherUsers() >> 

Для списка пользователей используется объект класса MutableLiveData – это подкласс LiveData, который является частью Архитектурных компонентов, и следует паттерну Observer (наблюдатель). Если вы знакомы с RxJava, класс LiveData похож на Observable. Но если с Observable вы должны удалять связи вручную, то класс LiveData зависит от жизненного цикла и выполняет всю очистку самостоятельно. Подписчиками LiveData являются активити и фрагменты. LiveData принимает подписчика и уведомляет его об изменениях данных, только когда он находится в состоянии STARTED или RESUMED. Состояние подписчиков определяется их объектом LifeCycle. Более подробно LifeCycle и состояния жизненного цикла мы рассматривали на прошлом уроке.

Класс MutableLiveData предоставляет методы setValue и postValue (второй – поточно-безопасный), посредством которых можно получить и отправить данные любым активным подписчикам.

В классе UserViewModel мы инициализируем список и заполняем его данными пользователей. Функция getListUsers() возвращает список, а функция updateListUsers() обновляет список, сохраняя в него второй список пользователей из класса UserData.

Теперь код MainActivity:

import android.arch.lifecycle.Observer import android.arch.lifecycle.ViewModelProviders import android.support.v7.app.AppCompatActivity import android.os.Bundle import android.support.v7.widget.LinearLayoutManager import android.view.Menu import android.view.MenuItem import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() < //инициализируем ViewModel ленивым способом private val userViewModel by lazy override fun onCreate(savedInstanceState: Bundle?) < super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) //инициализируем адаптер и присваиваем его списку val adapter = UserAdapter() userList.layoutManager = LinearLayoutManager(this) userList.adapter = adapter //подписываем адаптер на изменения списка userViewModel.getListUsers().observe(this, Observer < it?.let < adapter.refreshUsers(it) >>) > //создаем меню override fun onCreateOptionsMenu(menu: Menu?): Boolean < menuInflater.inflate(R.menu.main_menu, menu) return super.onCreateOptionsMenu(menu) >//при нажатии пункта меню Refresh обновляем список override fun onOptionsItemSelected(item: MenuItem?): Boolean < when(item?.itemId) < R.id.refresh -> < userViewModel.updateListUsers() >> return super.onOptionsItemSelected(item) > > 

Инициализируем объект класса UserViewModel так называемым ленивым способом с помощью функции lazy(). Это функция, которая принимает лямбду и возвращает экземпляр класса Lazy, который служит делегатом для реализации ленивого свойства: первый вызов get() запускает лямбда-выражение, переданное lazy() в качестве аргумента, и запоминает полученное значение, а последующие вызовы просто возвращают вычисленное значение. Таким образом, объект UserViewModel инициализируется только при первом вызове, а далее используется уже инициализированный объект.

В теле onCreate() инициализируем адаптер и присваиваем его списку. Далее подписываем адаптер на изменения списка с помощью функции observe(@NonNull LifecycleOwner owner, @NonNull Observer observer), которой на вход передается объект LifecycleOwner (текущее активити) и интерфейс Observer – колбек, уведомляющий об успешном получении данных. При этом вызывается метод обновления списка адаптера и ему передается обновленный список.

Ниже создаем меню и обрабатываем нажатие пункта меню Refresh, по которому обновляем список.

Запуск приложения

Теперь запустим приложение на эмуляторе и проверим его работу.

После запуска открывается экран со списком пользователей. Обновим список из меню. Теперь отображается другой, расширенный список. Но если мы перезапустим приложение, то снова увидим первоначальный список, который открывается по умолчанию.

Снова обновим список. Теперь покрутим устройство. Как мы знаем, при повороте активити уничтожается, однако на экране все еще отображается второй список. Это значит, что, несмотря на уничтожение активити, список сохраняется в объекте ViewModel и новое активити использует его данные. С другой стороны, если данные в списке будут обновлены, то посредством LiveData список также будет обновлен.

Дополнительно о LiveData можно почитать здесь.

Исходный код приложения можно посмотреть здесь.

Kotlin Android Extensions

Внимание! Kotlin Android Extensions теперь deprecated, это значит, что его поддержка не гарантируется. Альтернативные методы описаны здесь: ссылка

Если вы заметили, мы обращаемся к экранным компонентам без вызова метода findViewById, прямо по идентификатору. Это происходит благодаря использованию плагина Kotlin Android Extensions — это плагин для Kotlin, который включён в стандартный пакет. Он позволяет восстанавливать view из Activities, Fragments, и Views таким вот простым способом.

Плагин генерирует дополнительный код, который позволяет получить доступ к view в виде XML, так же, как если бы вы имели дело с properties с именем id, который вы использовали при определении структуры.

Также он создаёт локальный кэш view. При первом использовании свойства, плагин выполнит стандартный findViewById. В последующем, view будет восстановлен из кэша, поэтому доступ к нему будет быстрее.

По умолчанию плагин уже интегрирован в модуль благодаря вот такой строчке в файле сборки модуля:

apply plugin: 'kotlin-android-extensions'

При первом обращении к любому экранному компоненту в MainActivity автоматически добавляется такой импорт:

import kotlinx.android.synthetic.main.activity_main.*

Больше о Kotlin Android Extensions рекомендую почитать в переводе статьи Antonio Leiva на Медиуме.

На этом наш урок подошел к концу. Вопросы задавайте в комментариях. Всем добра!

Kotlin Android Extensions deprecated. Что делать? Инструкция по миграции

Безусловно, это было очень удобно, особенно если у вас проект полностью на Kotlin. Однако, мир меняется и теперь нужно искать альтернативы. В этой статье мы кратко рассмотрим, что такое плагин Kotlin Android Extension, какие были проблемы с ним и что теперь нам, Android-разработчикам делать. Частично, использовался материал этой статьи. Итак, поехали.

Кратко о Kotlin Android Extensions

Kotlin Android Extensions — это плагин для Kotlin, позволяющий восстанавливать view из Activities, Fragments, и Views без написания стандартного бойлерплэйт-кода типа findViewById.
Плагин генерирует дополнительный код, который позволяет получить доступ к view в виде XML, так же, как если бы вы имели дело с properties с именем id, который вы использовали при определении структуры.

Также он создаёт локальный кэш view. При первом использовании свойства, плагин выполнит стандартный findViewById. В последующем, view будет восстановлен из кэша, поэтому доступ к нему будет быстрее.

Если это всё так удобно, то зачем его сделали deprecated?

Проблемы Kotlin Android Extensions

  • Используется глобальный нэйминг идентификаторов. Могут возникнуть ситуации, когда один и тот же идентификатор имеется у разных view в разных лэйаутах — соответственно только на этапе работы приложения вы узнаете о том, что использовали не тот id.
  • Возможно использовать только в проектах на Kotlin (кэп)
  • Отсутствует Null Safety. В случае, когда view представлена в одной конфигурации и отсутствует в другой — может возникнуть краш, т.к отсутствует обработка таких ситуаций
  • Невозможно использовать в многомодульных проектах. Очень распространённый сценарий: у вас есть модуль UI Kit, хранящий общие UI-компоненты, которые вы хотите переиспользовать в других модулях. До сих пор висит issues которое вряд ли поправят. В таком сценарии обычно используют старый добрый findViewById 🙁
  • Резюмируя приведённые недостатки, нетрудно понять, что этот подход не идеален — хотя, безусловно, очень удобен на небольших проектах. На больших проектах с многомодульной архитектурой и сотнями экранов — использование Kotlin Android Extensions уже не кажется идеальным решением.

Альтернативные способы

  • Использование KotterKnife (кек, даже не думайте).
  • Старый добрый FindViewById() — уже получше, но так себе.
  • Использование AndroidAnnotations (привет из 2015)
  • View Binding от Google — бинго!

View Binding от Google

Итак, победителем в этом списке выглядит ViewBinding от Google (не путайте с DataBinding). Давайте кратко рассмотрим, что это такое.

View Binding — это инструмент, который позволяет проще писать код для взаимодействия с view. При включении View Binding в определенном модуле он генерирует binding классы для каждого файла разметки (layout) в модуле. Объект сгенерированного binding класса содержит ссылки на все view из файла разметки, для которых указан android:id

Главные преимущества View Binding — это Null safety и Type safety.

Начало работы с View Binding

Начать работать с ViewBinding достаточно просто. Нужно добавить опцию в build.gradle:

android < . buildFeatures < viewBinding true >>

После этого можно уже использовать. Каждый сгенерированный binding класс содержит ссылку на корневой view разметки (root) и ссылки на все view, которые имеют id. Имя генерируемого класса формируется как «название файла разметки», переведенное в camel case + «Binding». Например, для файла разметки result_profile.xml:

Будет сгенерирован класс ResultProfileBinding, содержащий 2 поля: TextView name и Button button.

Использование в Activity

Например у вас вот такой layout:

Результат работы ViewBinding:

public final class ActivityMainBinding implements ViewBinding < @NonNull private final ConstraintLayout rootView; @NonNull public final TextView textView;

Использовать viewBinding можно так:

private lateinit var binding: ResultProfileBinding override fun onCreate(savedInstanceState: Bundle?)

И теперь, после того, как получили ссылки на view:

binding.name.text = viewModel.name binding.button.setOnClickListener

Если вы используете ViewBinding во фрагменте и держите ссылку на binding во фрагменте (а не только в методе onCreateView()) то не забывайте очищать ссылки в методе onDestroyView().

private var _binding: ResultProfileBinding? = null // This property is only valid between onCreateView and // onDestroyView. private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? < _binding = ResultProfileBinding.inflate(inflater, container, false) val view = binding.root return view >override fun onDestroyView()

Это необходимо делать из-за жизненного цикла фрагмента и view:

image

В целом, переключиться на ViewBinding достаточно не сложно, хотя и жаль, что Kotlin Android Extensions объявлен deprecated. Не забудьте присоединиться к нам в Telegram, а на платформе AndroidSchool.ru публикуются полезные материалы для Android-разработчика и современные туториалы.

  • Туториалы в телеграм
  • Документация по ViewBinding
  • Статья о жизненном цикле фрагмента и view
  • Статья о применении ViewBinding
  • android development
  • viewbinding
  • kotlin

Как мы перешли с kotlinx.synthetics на Android View Binding

Привет! Меня зовут Ваня, я 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) < viewHolder.itemView.my_awesome_text_view.text = "lorem ipsum" >// После применения скрипта override fun bind(viewHolder: RecyclerView.ViewHolder, payloads: List)

Также 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

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

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

  1. Если рутовый тег layout, который мы указали в include, — , тогда нам понадобится вручную создать для него объект Binding:
override fun onViewCreated(view: View, savedInstanceState: Bundle?)

Таким образом, помимо основного объекта 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).mapNotNull < psiClass ->val ktClass = ((psiClass as? KtLightElement)?.kotlinOrigin as? KtClass) if (ktClass == null) < null >else < psiClass to ktClass >>

Проще говоря, мы добавляем ручной каст к нужному нам типу file.getClasses() as Array . 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 к сожалению оказалась куда менее полезна из-за практически полного отсутствия примеров, но теории там достаточно.

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

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *