Содержание

Введение

Всем привет. Работаю мобильным разработчиком в 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. Буду рад выслушать конструктивную критику и предложения по улучшению плагина.

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

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

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