Содержание
Введение
Всем привет. Работаю мобильным разработчиком в Narisuemvse. В настоящий момент для разработки используем Flutter и в наших проектах стараемся придерживаться принципов чистой архитектуры типа feature-first. Из-за этого приходится создавать множество папок и файлов по одному и тому же шаблону, поэтому в целях ускорения разработки было принято решение по написанию простого плагина для Android Studio.
Хотелось бы предупредить, что это мой первый опыт в создании плагинов, и я не претендую на роль эксперта, но возможно кто-то находится в поисках простой реализации плагина, и сможет почерпнуть для себя что-то полезное.
Подготовка
Для разработки вам понадобится IntelliJ и Plugin DevKit.
Для начала создадим новый проект:
File > New > Project...
В списке "Generators" выберите IDE Plugin.
Введите название и расположение проекта.
![Окно создания проекта Окно создания проекта](https://habrastorage.org/getpro/habr/upload_files/250/f77/d58/250f77d5830ff2ce87a521187735eb42.png)
Настройка плагина
Если вы используете версию 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](https://habrastorage.org/getpro/habr/upload_files/b23/e8c/670/b23e8c670e8058b7f96f29dd40affc5c.png)
Создадим файл с нашим действием. Это будет класс с реализацией AnAction()
:
class FCAAction : AnAction() {
override fun actionPerformed(actionEvent: AnActionEvent) {
...
}
}
Нам нужно реализовать метод actionPerformed()
, код в данном методе выполняется при вызове действия. Метод содержит доступ к контекстным данным по типу, информации о проекте, файлам, выбранному элементу и т.д.
Для начала нужно зарегистрировать наше действие. Это можно сделать двумя способами:
-
С помощью IDE, выбрав нужное действие при наведении на название класса. В данном конструкторе можно легко найти нужную группу и действие. Более подробно можно почитать здесь. После успешной регистрации действия оно появится в файле plugin.xml (пункт 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. Рекомендации по иконкам можно изучить здесь.
Создание пользовательского интерфейса
Пока наше действие не вызывает какой-либо интерфейс, будем реализовывать модальное окно для ввода названия фичи.
![Диалог с вводом имени Диалог с вводом имени](https://habrastorage.org/getpro/habr/upload_files/068/944/e9c/068944e9c7fe48e63278c6bb4e70048a.png)
Для этого создадим отдельный файл с реализацией класса 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>
Теперь можем проверять результат. У меня вышел такой плагин:
![Работа плагина Работа плагина](https://habrastorage.org/getpro/habr/upload_files/f34/605/4fb/f346054fb4aca86f6cad0e04919d857a.gif)
Заключение
В ближайшее время опубликую плагин в сторе. Название плагина FCA. Буду рад выслушать конструктивную критику и предложения по улучшению плагина.
Спасибо за внимание!