Пишем простой плагин для Android Studio

от автора

Содержание

Введение

Всем привет. Работаю мобильным разработчиком в Narisuemvse. В настоящий момент для разработки используем Flutter и в наших проектах стараемся придерживаться принципов чистой архитектуры типа feature-first. Из-за этого приходится создавать множество папок и файлов по одному и тому же шаблону, поэтому в целях ускорения разработки было принято решение по написанию простого плагина для Android Studio.

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

Подготовка

Для разработки вам понадобится IntelliJ и Plugin DevKit.

Для начала создадим новый проект:

  1. File > New > Project…

  2. В списке «Generators» выберите IDE Plugin.

  3. Введите название и расположение проекта.

Окно создания проекта

Окно создания проекта

Настройка плагина

Если вы используете версию IDE 2024.2+, вам потребуется произвести миграцию на Gradle Plugin (2.x)

Поскольку для разработки я использую локальную версию Android Studio, минимальная настройка build.gradle.kts выглядит так:

  plugins {     id("java")     id("org.jetbrains.kotlin.jvm") version "1.9.25"     id("org.jetbrains.intellij.platform") version "2.2.1" }  group = "com.murlodin" version = "1.0.0"  repositories {     mavenCentral()     intellijPlatform {         defaultRepositories()     } }  intellijPlatform {     pluginConfiguration {         name = "FCA"         id="com.murlodin.fca-plugin"     } }  dependencies {     intellijPlatform {         local("/Applications/Android Studio.app/Contents")     } }

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

Создание Action

С помощью системы действий мы можем добавлять элементы своего плагина в IDE, например, в нашем случае будем добавлять действие в группу New, которое позволит создавать фичу внутри выбранной нами папки (скриншот ниже).

Пример Action

Пример Action

Создадим файл с нашим действием. Это будет класс с реализацией AnAction():

class FCAAction : AnAction() {      override fun actionPerformed(actionEvent: AnActionEvent) {       ...     }  }

Нам нужно реализовать метод actionPerformed(), код в данном методе выполняется при вызове действия. Метод содержит доступ к контекстным данным по типу, информации о проекте, файлам, выбранному элементу и т.д.

Для начала нужно зарегистрировать наше действие. Это можно сделать двумя способами:

  1. С помощью IDE, выбрав нужное действие при наведении на название класса. В данном конструкторе можно легко найти нужную группу и действие. Более подробно можно почитать здесь. После успешной регистрации действия оно появится в файле plugin.xml (пункт 2).

  2. Также действие можно зарегистрировать вручную, открыв файл resources/META-INF/plugin.xml, зарегистрированное действие выглядит так:

    <actions>     <action          id="com.murlodin.fcaplugin.actions.FCAAction"         class="com.murlodin.fcaplugin.actions.FCAAction"         text="Add FCA Feature"         description="Action for create fca feature"         icon="icons/action_icon.svg"     >         <add-to-group group-id="NewGroup" anchor="last"/>     </action> </actions>

    Для того чтобы добавить свою иконку к действиям, создайте папку icons внутри папки resources. Рекомендации по иконкам можно изучить здесь.

Создание пользовательского интерфейса

Пока наше действие не вызывает какой-либо интерфейс, будем реализовывать модальное окно для ввода названия фичи.

Диалог с вводом имени

Диалог с вводом имени

Для этого создадим отдельный файл с реализацией класса DialogWrapper() . Для этого нужно переопределить метод createCenterPanel() :

class FCADialogWrapper(private val action: AnActionEvent) :      DialogWrapper(action.project) {      override fun createCenterPanel(): JComponent {       ...     }  }

В приведенном выше коде добавим для класса конструктор с параметром action: AnActionEvent, с помощью него сможем получить данные о проекте и выбранной папке. Вызовем конструктор базового класса с передачей проекта DialogWrapper(action.project), в окне которого будет отображаться наше окно.

Добавим метод инициализации диалога, в котором зададим заголовок:

class FCADialogWrapper(private val action: AnActionEvent) :      DialogWrapper(action.project) {      init {         title = "Create FCA Feature"         super.init()     }      ...  }

Приступим к написанию UI, для этого будем использовать Kotlin UI DSL Version 2, добавим в классе текстовое поле private lateinit var nameTextField: Cell<JBTextField>, который инициализируем позже, и метод для получения значения этого поля getNameTextFieldValue(), он понадобится для получения значения после нажатия кнопки OK. Реализуем нужный нам интерфейс в createCenterPanel(). Обновленный класс будет выглядеть так:

class FCADialogWrapper(private val action: AnActionEvent) :      DialogWrapper(action.project) {      // Инициализируем в методе createCenterPanel()     private lateinit var nameTextField: Cell<JBTextField>      ...      fun getNameTextFieldValue() : String = nameTextField.component.text       override fun createCenterPanel(): JComponent {       return panel {             row {                 label("Feature name")             }             row {                 nameTextField = textField()                     //добавим автоматический фокус                     .focused()                     //валидация по нажатию кнопки OK                     .validationOnApply(nameValidator)             }         }     }  }

Во время валидации мы должны проверить значение на соответствие двум условиям: поле не является пустым и отсутствие в родительской папке папок с таким же названием (для этого нам и понадобится параметр action). Реализация валидации выглядит следующим образом:

override fun createCenterPanel(): JComponent {      //получаем выбранный с помощью Action объект     val selectedFolder = PlatformDataKeys.VIRTUAL_FILE.getData(action.dataContext)      val nameValidator: ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = {          var isSameName = false                // проверяем, есть ли среди дочерних папок папка с таким же названием         if (selectedFolder != null) {             for(child in selectedFolder.children) {                 if (it.text == child.name && child.isDirectory) {                     isSameName = true                     break                 }             }         }          // Возвращаем ответ относительно выполненых условий, null уберает ошибку         when {             isSameName -> ValidationInfo("A folder with that name already exist")             it.text.isNullOrBlank() -> ValidationInfo("Please enter a name")             else -> null         }     }        ...  }

Реализация логики генератора

Ранее мы реализовывали класс AnAction , теперь нужно добавить вывод диалога в метод actionPerformed() , для этого напишем следующее:

override fun actionPerformed(actionEvent: AnActionEvent) {      //Создаем экземпляр нашего диалога      val dialog = FCADialogWrapper(actionEvent)      // Вызываем его     if (dialog.showAndGet()) {         //После нажатия кнопки OK мы можем получить нужные нам данные         val featureName = dialog.getNameTextFieldValue()         //реализацию метода генерации можно посмотреть на github         generateFeature(actionEvent.dataContext, featureName)     } }

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

Реализуем простую генерацию папок:

private fun generateFeature(dataContext: DataContext, featureName: String) {      //получаем проект из контекста     val project = CommonDataKeys.PROJECT.getData(dataContext) ?: return     //получаем выбранную папку     val selected = PlatformDataKeys.VIRTUAL_FILE.getData(dataContext) ?: return       WriteCommandAction.runWriteCommandAction(project) {         val featureFolder = selected.createChildDirectory(this, featureName)         val featureFile = featureFolder.createChildData(this, "feature_file.txt")         featureFile.writeText("Hello World!")     }  }

Полную реализацию для генератора фичи можно посмотреть на моем GitHub проекте.

Реализация настроек плагина

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

Начнем с создания модели данных состояния, поскольку мы будем использовать упрощенный подход к управлению состоянием, наследуем модель от класса BaseState , это позволит оперировать данными без дополнительных усилий:

class FCASettingsState : BaseState() {      var isCreateDataMapperTemplates by property(IS_CREATE_DATA_MAPPER_FOLDER)     var dataMappersFolderName by string(DATA_MAPPER_FOLDER_NAME)     ...        companion object DefaultFCASettingsProperties {         const val IS_CREATE_DATA_MAPPER_FOLDER = false         const val DATA_MAPPER_FOLDER_NAME = "mapper"         ...     } } 

Более подробно про реализацию состояния можно почитать здесь.

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

@Service(Service.Level.PROJECT) @State(     name = "com.murlodin.fcapluginFCASettings",     storages = [Storage("FCASettingsPlugin.xml")], ) class FCASettings :      SimplePersistentStateComponent<FCASettingsState>(FCASettingsState()) {      override fun noStateLoaded() {         loadState(FCASettingsState())     }  }

Разберем вышенаписанный код.

В первую очередь объявляем аннотации. Аннотация @Service используется для регистрации сервиса, мы можем зарегистрировать сервис на двух уровнях:

  • @Service(Service.Level.PROJECT) — Сервис создается для каждого проекта отдельно. Если в IDE открыто несколько проектов, каждый из них будет иметь свой экземпляр этого сервиса.

  • @Service(Service.Level.APP) — Cоздает сервис на уровне всей IDE (глобальный для всех проектов).

Далее объявляем аннотацию @State . Указываем, что данный класс явяляется компонентом состояния. Здесь мы указываем уникальное имя для состояния и файл, в котором будет хранится состояние.

Поскольку мы используем SimplePersistentStateComponent , нам нужно только указать тип модели данных и передать экземпляр в конструктор. Единственный метод, который нам надо реализовать, это noStateLoaded() , тут мы просто указываем поведение в том случае, если состояние не загрузилось.

Если вам нужно больше контроля в управлении состоянием, вы можете реализовать класс PersistentStateComponent , в котором нужно реализовать методы getState() и loadState() самостоятельно.

Теперь нам нужно создать окно для настроек, для этого будем использовать класс BoundConfigurable. Он автоматически связывает элементы UI с моделью данных, а также автоматически отслеживает обновление UI и сохраняет данные при нажатии Apply и OK. Если вам нужно больше контроля, вы можете заменить BoundConfigurable на другой тип Configurable, подробнее здесь.

Реализация выглядит так:

internal class FCASettingsConfigurable(project: Project) :     BoundConfigurable(displayName = "FCASettings") {      //получаем состояние     private val fcaSettings = project.service<FCASettings>()      private lateinit var dataSourcesFolderNameTextField: Cell<JBTextField>         override fun createPanel(): DialogPanel {          val textValidator: ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = { textField ->             if (textField.text.isNullOrBlank()) {                 error("Поле не может быть пустым")             } else {                 null             }         }          return panel {             group("FCA Settings") {                 group("Folder Names") {                     row { label("data") }                     row {                         dataSourcesFolderNameTextField = textField()                             //данные будут автоматически привязаны к состоянию                             .bindText(                                 { fcaSettings.state.dataSourcesFolderName ?: "" },                                 { value -> fcaSettings.state.dataSourcesFolderName = value }                             )                             //проверим поле во время ввода                              .validationOnInput(textValidator)                     }                                    }             }                      }     }      //если вам не нужна проверка на пустые поля, можно удалить эту реализацию     override fun apply() {         if(dataSourcesFolderNameTextField.component.text.isNullOrBlank()) {             //отобразит ошибку в окне настроек             throw ConfigurationException("Fields cannot be empty")         }         super.apply()     }  }

Последнее, что нам осталось, это зарегистрировать Configurable, для этого нужно добавить новый атрибут в файл plugin.xml внутри тега <idea-plugin>:

<extensions defaultExtensionNs="com.intellij">     <projectConfigurable           parentId="tools"           instance="com.murlodin.fcaplugin.settings.FCASettingsConfigurable"          id="com.murlodin.fcaplugin.settings.FCASettingsConfigurable"          displayName="FCA Settings "     /> </extensions>

Теперь можем проверять результат. У меня вышел такой плагин:

Работа плагина

Работа плагина

Заключение

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

Спасибо за внимание!

Ссылка на проект


ссылка на оригинал статьи https://habr.com/ru/articles/880160/


Комментарии

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

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