Всем привет! Меня зовут Алексей, я техлид Android-направления в компании Домклик.
Добро пожаловать во вторую часть статьи о разработке плагинов для Android Studio. В предыдущей части мы выполнили первоначальные настройки и разобрали некоторые задачи. Теперь рассмотрим новые примеры задач и способы их решения с помощью собственного плагина. Предполагается, что проект уже настроен, поэтому сразу перейдём к делу.

Постановка задачи
Как и в предыдущей части, возьмём некий большой проект и рассмотрим потенциальные проблемы и неудобства, а также способы их решения с помощью плагина. Для простоты демонстрации задачи могут быть немного додуманы, а предложенное решение — не самым оптимальным или не единственно возможным.
Предположим, у вас большой, многомодульный проект. Каждый модуль делится на «подмодули», например, api- и impl-модули (хотя бывают и другие варианты, но мы остановимся на этом). Каждый такой модуль содержит некоторое количество абсолютно одинаковых для всего проекта файлов или структур. Это обязательные файлы, такие как build.gradle, пакеты исходников и ресурсов, при необходимости — манифест. Также могут быть файлы, присущие вашему проекту, например, readme и внутренняя структура пакетов (di, data, domain, ui и т.п.). Частично все эти файлы формируются при создании модуля из меню New в Студии. Однако, там не учитывается наша api/impl-структура, будут и лишние файлы, вроде пустых тестов и proguard. И, само собой, не будет ничего проектно‑зависимого. Давайте попробуем изменить процесс создания модуля, чтобы он стал удобнее в этом случае.
Далее посмотрим на работу с Git и диалогом коммита в Студии. Улучшим commit message, автоматически добавляя рутину в виде названия ветки, которая представляет собой название задачи в трекере задач. Оценим, что ещё можно сделать с этим диалогом.
Затем вмешаемся в работу встроенного плагина Coverage, отвечающего за расчёт покрытия кода Unit-тестами. На момент написания статьи в работе плагина был ряд недостатков: например, он всегда вычислял покрытие для всего проекта, что при больших объёмах занимало много времени и затрудняло чтение результата. Также отсутствовала автоматическая фильтрация ненужных классов, будь то кодогенерация или UI.
Наконец, после добавления множества фич в наш плагин, было бы неплохо создать руководство с иллюстрациями и описаниями (по аналогии c описанием новых функций при обновлении Android Studio в правой панели инструментов).
Создание модуля
Мы будем генерировать файлы для модуля. На самом деле это могут быть любые файлы — всё, что представляет собой шаблонный код и прочую рутину, но не охватывается шаблонами или кодогенерацией.
Для начала добавим AnAction. При клике на неё появится диалоговое окно для ввода имени модуля и пакета. Опционально можно добавить чекбокс для выбора, нужен ли api-модуль или Activity. Это диалоговое окно принципиально не будет отличаться от DialogBuilder из предыдущей статьи.
<action id="NewModuleAction" class="ru.domclick.example_plugin.templates.NewModuleAction" icon="AllIcons.Actions.AddDirectory" text="Создать модуль"> <add-to-group group-id="NewModuleGroup" anchor="first"/> </action>
Более интересная часть по клику на OkAction диалога. В первую очередь необходимо использовать функцию WriteCommandAction.runWriteCommandAction(project), в которую завернём все операции записи физических файлов. Но прежде чем приступить к созданию физического файла, сделаем необходимую структуру с помощью VirtualFile. Для начала сформируем пакеты. За точку отсчёта удобно взять project.baseDir, а далее через createChildDirectory и findChild наметим нужные папки.
Пример:
private fun createModuleStructure(moduleName: String, location: VirtualFile, isApiModule: Boolean): VirtualFile { val moduleDir = location.createChildDirectory(this, moduleName) moduleDir.createChildDirectory(this, "src") val srcDir = moduleDir.findChild("src")!! val mainDir = srcDir.createChildDirectory(this, "main") mainDir.createChildDirectory(this, "res") mainDir.createChildDirectory(this, "kotlin") if (!isApiModule) { val testDir = srcDir.createChildDirectory(this, "test") testDir.createChildDirectory(this, "kotlin") val resDir = mainDir.findChild("res")!! resDir.createChildDirectory(this, "values") } return moduleDir }
Теперь создадим файл, например, build.gradle. Проще всего скопировать нужный текст и передать его в функцию VfsUtil.saveText вместе с созданным виртуальным файлом moduleDir.createChildData(this, "build.gradle").
Это может выглядеть примерно так:
private fun createBuildGradle( moduleDir: VirtualFile, packageName: String, withApiModule: Boolean, moduleName: String ) { val apiImpl = if (withApiModule) { "implementation project(path: ':$moduleName-api')" } else { "" } val buildGradleContent = """ apply from: '../common.module.gradle' android { namespace "$packageName" } dependencies { implementation project(path: ':core') $apiImpl } """.trimIndent() val buildGradleFile = moduleDir.createChildData(this, "build.gradle") VfsUtil.saveText(buildGradleFile, buildGradleContent) }
Аналогичным образом создаются и остальные виртуальные файлы. Также при создании модуля необходимо добавить include в settings.gradle и подключить модуль в основном модуле (обычно это app). Для этого потребуется изменить существующие файлы. Здесь можно продолжать работать с файлами как с текстом, через VfsUtil.loadText(file) и регулярные выражения для вставки в нужные места.
После создания всех необходимых элементов следует записать новую структуру и запустить синхронизацию Gradle (вероятно, существует более элегантный способ вызова синхронизации, но в исходниках его найти не удалось, поэтому приходится находить и вызывать Action из главного меню):
private fun refreshProject() { project.baseDir?.refresh(true, true) ActionManager.getInstance().getAction("Android.SyncProject").actionPerformed( com.intellij.openapi.actionSystem.AnActionEvent.createEvent( { _ -> project }, null, "", ActionUiKind.MAIN_MENU, null ) ) }
Итак, по клику мы можем создать сразу два модуля, каждый из которых будет иметь необходимую структуру и файлы. Вроде совсем не сложно, а масштабируется в любом направлении. Не будем на этом долго останавливаться и перейдём к следующей задаче.
CheckinHandler
Теперь рассмотрим диалог коммита в Студии. Он представляет собой сборку фабрик, каждая из которых отвечает за выполнение какой-либо небольшой функции: анализ кода, оптимизация импортов, precommit-хуки и т.д. Любую из этих функций можно легко заменить своей через реализацию CheckinHandlerFactory.

Например, можно не только зафиксировать галочку Optimize imports во включённом состоянии, чтобы обеспечить единообразие стиля кода. Для этого реализуем CodeProcessorCheckinHandler и передадим в метод createHandler у CheckinHandlerFactory. Вся структура подсмотрена в репозитории intellij-community, часть кода скопирована оттуда.
Пример:
class OptimizeOptionsCheckinDomclickHandlerFactory : CheckinHandlerFactory() { override fun createHandler(panel: CheckinProjectPanel, commitContext: CommitContext): CheckinHandler = OptimizeImportsBeforeCheckinHandler(panel.project) } class OptimizeImportsBeforeCheckinHandler(project: Project) : CodeProcessorCheckinHandler(project) { override fun getBeforeCheckinConfigurationPanel(): RefreshableOnComponent { settings.OPTIMIZE_IMPORTS_BEFORE_PROJECT_COMMIT = true return DisabledBooleanCommitOption( project = project, text = VcsBundle.message("checkbox.checkin.options.optimize.imports"), disableWhenDumb = true ) } override fun isEnabled(): Boolean = settings.OPTIMIZE_IMPORTS_BEFORE_PROJECT_COMMIT override fun getProgressMessage(): String = VcsBundle.message("progress.text.optimizing.imports") override fun createCodeProcessor(files: List<VirtualFile>): AbstractLayoutCodeProcessor = OptimizeImportsProcessor(project, getPsiFiles(project, files), COMMAND_NAME, null) companion object { @JvmField @NlsSafe val COMMAND_NAME: String = CodeInsightBundle.message("process.optimize.imports.before.commit") } } class DisabledBooleanCommitOption( project: Project, text: String, disableWhenDumb: Boolean ) : BooleanCommitOption(project, text, disableWhenDumb, { true }, { true }) { override fun Panel.createOptionContent() { row { cell(checkBox.also { it.isEnabled = false }) label("(Активно по умолчанию)") } } override fun restoreState() { setSelected(true) } override fun saveState() {} }
Для решения нашей задачи необходимо дописать имя ветки в commit message. Для этого в build.gradle добавляем плагин:
bundledPlugins(listOf("Git4Idea"))
Затем используем GitRepositoryManager, где для текущего репозитория (пусть будет первый) можно получить currentBranch. В общем виде плагин Git4Idea обслуживает всё, что связано с Git, и может быть полезен в более сложных сценариях. Сообщение подменяем в методе beforeCheckin, передав в класс ссылку на CheckinProjectPanel, где находится текст сообщения.
class PreCommitMessageFactory : CheckinHandlerFactory() { override fun createHandler(panel: CheckinProjectPanel, commitContext: CommitContext): CheckinHandler { return PreCommitMessageFactoryHandler(panel) } class PreCommitMessageFactoryHandler(private val panel: CheckinProjectPanel) : CheckinHandler() { override fun beforeCheckin(): ReturnResult { val branchName = getCurrentBranchName(panel.project) if (!panel.commitMessage.matches(Regex("[A-Z]+-[0-9]+:.*")) && branchName != null && branchName.matches(Regex("[A-Z]+-[0-9]+")) ) { panel.commitMessage = "$branchName: " + panel.commitMessage } return ReturnResult.COMMIT } private fun getCurrentBranchName(project: Project): String? = GitRepositoryManager.getInstance(project) .repositories .firstOrNull() ?.currentBranch ?.name ?.substringAfterLast("/") } }
В конце не забываем переопределить расширение в plugin.xml:
<extensions defaultExtensionNs="com.intellij"> <checkinHandlerFactory implementation="ru.domclick.example_plugin.checkin.PreCommitMessageFactory"/> <checkinHandlerFactory implementation="ru.domclick.example_plugin.checkin.OptimizeOptionsCheckinDomclickHandlerFactory"/> </extensions>
В CheckinHandler довольно большой простор для творчества. Например, можно вклиниться в CodeAnalysis, добавив дополнительные проверки или сделав их блокирующими. Но об этом поговорим в другой раз.
Coverage
Ещё одним полезным встроенным плагином является Coverage. Однако указанные в постановке нюансы его работы нас не устраивали. После изучения выяснилось, что некоторые компоненты этого плагина, подобно диалогу коммита, можно «подменить». Доступ к этим компонентам осуществляется через javaCoverageEngineExtension, coverageRunner или coverageEngine, которые переопределяются в extensions в plugin.xml. В каждом из них есть ряд полезных методов для переопределения, но не во всех получилось разобраться.

Поскольку одна из задач заключается в фильтрации в выводе ненужных результатов, то наиболее подходящим кажется метод createSuite из JavaCoverageEngine, у которого есть аргумент filters. Анализ исходного кода показал, что filters принимает список имён файлов вместе с пакетом, либо часть пакета с символом «*» в конце (например, ru.test.package.*). Это существенно ограничивает наши возможности, так как мы хотели бы передавать паттерны, как в Jacoco. В качестве альтернативы можно попробовать следующий метод: в параметре name передаётся название запускаемой конфигурации (обычно это имя класса теста или пакет тестов, если запускаем применительно к пакету или модулю). Используя эту информацию, можно вычислить модуль, в котором находится тест, путём поиска по файловой системе. Это позволит исключить из рассмотрения весь остальной проект. Затем в оставшихся исходниках модуля можно найти файлы с именами пакетов, для которых ожидаются тесты (например, именами, оканчивающимися на UseCase).
Иными словами, мы переопределяем JavaCoverageEngine , передавая в createSuite в filters список всех файлов, для которых необходимо отображать степень покрытия. После этого регистрируем coverageEngine в plugin.xml.
<coverageEngine implementation="ru.domclick.example_plugin.coverage.DomclickKotlinCoverageEngine"/>
Этот плагин также нужно подключить в build.gradle:
bundledPlugins(listOf("Coverage"))
Явное указание нужных файлов позволило значительно сократить время выполнения расчётов и объёма отчётов, сохранив при этом полную функциональность плагина. Можно пойти дальше и передавать паттерны искомых файлов извне, а также использовать конфигурацию в явном виде, не пытаясь парсить поле name.
Руководство
Поскольку плагин переопределяет в том числе базовые функции Студии и добавляет новые, порой неочевидные, их описание крайне необходимо. Идеальным решением было бы создание руководства, аналогичного встроенным в Студию, когда в правой панели инструментов появляется вкладка, автоматически открывающаяся при первом запуске.
Казалось бы, задача не такая уж и сложная. Боковая панель собирается с помощью ToolWindowFactory, где в методе createToolWindowContent передадим созданный с контентом Panel:
class OnboardingToolWindowFactory : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { showOnboardingPanel(toolWindow) } private fun showOnboardingPanel(toolWindow: ToolWindow) { val panel = createPanel() toolWindow.component.removeAll() toolWindow.component.add(panel) toolWindow.component.revalidate() toolWindow.component.repaint() } }
Главный вопрос заключался в выборе формата контента. Полная вёрстка на DSL показалась слишком неудобной. Кроме того, пришлось бы уделять внимание дизайну. Хотелось набросать что-то вроде Markdown-файла и просто вставить его, но и тут возникли нюансы.
Прежде всего, для отображения файлов .md внутри Студии требуется JCEF (Java Chromium Embedded Framework), а runtime обычно по умолчанию поставляется без него. Наличие JCEF можно проверить из плагина с помощью JBCefApp.isSupported().

Если JCEF недоступен, можно попробовать использовать BrowserUtil.browse. Однако этот подход не соответствует нашим целям. Кроме того, даже если runtime есть и получилось успешно отобразить контент, мы столкнулись с проблемами при конвертации стилей. Вероятно, это связано с тем, что встроенный браузер ориентирован на HTML. После попытки написать парсер из .md в HTML, мы задумались: а зачем нам, собственно, .md? Оказалось намного проще создавать контент в HTML (даже браузер не понадобился), лишь слегка скорректировав стили для визуального сходства с Markdown.
Однако, остаётся проблема с отображением изображений. Прямое указание в <img src=.. (как и в .md) не работает. Изображения резолвятся в плагине, но не в самой Студии. Для решения этой проблемы мы извлекаем ссылки на изображения перед отображением HTML-контента, загружаем их через getResourceAsStream, преобразуем в Base64-строку и записываем вместо исходных ссылок.
private fun loadImageFromResources(path: String): String? { return javaClass.getResourceAsStream(path)?.use { inputStream -> val bytes = inputStream.readAllBytes() "data:image/png;base64,${Base64.getEncoder().encodeToString(bytes)}" } }
После получения и подготовки HTML-контента, его необходимо перенести в Panel. Для этого используем JEditorPane с contentType = "text/html". Дополнительно требуется создать свой editorKit, который позволит добавлять кастомные правила, в частности, стили, как у Markdown.
val kit = HTMLEditorKitBuilder().withWordWrapViewFactory().build() kit.styleSheet.apply { addRule("a {color: " + ColorUtil.toHtmlColor(JBUI.CurrentTheme.Link.Foreground.ENABLED) + "}") addRule(markdownStyle) } editorKit = kit
Далее добавляем JBScrollPane в Panel, а Panel— вtoolWindow , и регистрируем как extensions в plugin.xml:
panel.add(JBScrollPane(content), BorderLayout.CENTER) toolWindow.component.removeAll() toolWindow.component.add(panel) toolWindow.component.revalidate() toolWindow.component.repaint()
<toolWindow id="Plugin" anchor="right" factoryClass="ru.domclick.example_plugin.onboarding.OnboardingToolWindowFactory"/>
Пример (всё вместе):
class OnboardingToolWindowFactory : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { showOnboardingPanel(toolWindow) } private fun showOnboardingPanel(toolWindow: ToolWindow) { val panel = JPanel(BorderLayout()) val regex = Regex("<img src=\"[a-z_./]*\"/>") val html = loadHtmlContent() var htmlContent = html regex.findAll(html).forEach { oldImg -> htmlContent = htmlContent.replace( oldImg.value, "<img src=\"${ loadImageFromResources( oldImg.value .substringAfter("src=\"") .substringBefore("\"/>") ) }\"/>" ) } htmlContent = htmlContent.replace("(\r\n|\n)".toRegex(), "<br/>") val content = JEditorPane().apply { putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, java.lang.Boolean.TRUE) contentType = "text/html" isEditable = false isOpaque = false border = null val kit = HTMLEditorKitBuilder().withWordWrapViewFactory().build() kit.styleSheet.apply { addRule("a {color: " + ColorUtil.toHtmlColor(JBUI.CurrentTheme.Link.Foreground.ENABLED) + "}") addRule(markdownStyle) } editorKit = kit addHyperlinkListener(BrowserHyperlinkListener.INSTANCE) if (BasicHTML.isHTMLString(htmlContent)) { putClientProperty( AccessibleContext.ACCESSIBLE_NAME_PROPERTY, StringUtil.unescapeXmlEntities(StringUtil.stripHtml(htmlContent, " ")) ) } text = htmlContent } panel.add(JBScrollPane(content), BorderLayout.CENTER) toolWindow.component.removeAll() toolWindow.component.add(panel) toolWindow.component.revalidate() toolWindow.component.repaint() } private fun loadHtmlContent(): String = javaClass.getResourceAsStream("/about.html")!!.bufferedReader().use { it.readText() } private fun loadImageFromResources(path: String): String? { return javaClass.getResourceAsStream(path)?.use { inputStream -> val bytes = inputStream.readAllBytes() "data:image/png;base64,${Base64.getEncoder().encodeToString(bytes)}" } } private val markdownStyle: String = """ /* Base Markdown-like Styles */ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; font-size: 14px; line-height: 1.6; padding: 16px; max-width: 800px; margin: 0 auto; } h1 { font-size: 2em; font-weight: 600; margin: 24px 0 16px 0; padding-bottom: 8px; border-bottom: 1px solid #eaecef; } h2 { font-size: 1.5em; font-weight: 600; margin: 20px 0 12px 0; padding-bottom: 6px; border-bottom: 1px solid #eaecef; } h3 { font-size: 1.25em; font-weight: 600; margin: 16px 0 8px 0; } h4 { font-size: 1.1em; font-weight: 600; margin: 12px 0 6px 0; } h5 { font-size: 1em; font-weight: 600; margin: 8px 0 4px 0; } h6 { font-size: 0.9em; font-weight: 600; margin: 8px 0 4px 0; } a { color: #0366d6; text-decoration: none; background-color: transparent; } a:hover { text-decoration: underline; color: #0366d6; } code { background: rgba(110, 118, 129, 0.4); border-radius: 3px; padding: 2px 4px; font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace; font-size: 0.9em; } li { margin: 0 0 0 0; line-height: 1.6; } ul { margin: 0 0 0 0; } img { max-width: 100%; height: auto; border-radius: 4px; margin: 8px 0; } """.trimIndent() }
В результате документацию можно составлять в формате HTML с предпросмотром в Intellij. Изображения следует размещать в resourses и выводить в боковой панели Студии. Но мы хотели бы ещё один раз раскрыть эту панель при обновлении версии плагина.
Запись состояния, показывали ли мы руководство, можно реализовать через PersistentStateComponent, записывая значение с ключом в обычный XML-файл.
Пример:
@Service(Service.Level.PROJECT) @State( name = "PluginOnboardingState", storages = [Storage("plugin-onboarding.xml")] ) class OnboardingStateService : PersistentStateComponent<OnboardingStateService.State> { data class State( var onboardingShown: Boolean = false, var pluginVersion: String = "" ) private var state = State() override fun getState(): State = state override fun loadState(state: State) { this.state = state } fun shouldShowOnboarding(): Boolean { val currentVersion = getPluginVersion() return !state.onboardingShown || state.pluginVersion != currentVersion } fun markOnboardingShown() { getPluginVersion()?.let { state.onboardingShown = true state.pluginVersion = it } } }
Версию плагина пробуем достать из PluginManager:
private fun getPluginVersion(): String? { return try { val pluginId = PluginId.getId("ru.domclick.domclick_plugin") val plugin = PluginManager.getPlugin(pluginId) plugin?.version } catch (e: Exception) { null } }
Далее необходимо прочитать состояние и открыть панель. Для этого можно использовать ProjectActivity, получить toolWindow по ID и вызвать activate:
class PluginGlobalListener : ProjectActivity { override suspend fun execute(project: Project) { ApplicationManager.getApplication().invokeLater({ activateToolWindowIfNeeded(project) }, ModalityState.current()) } private fun activateToolWindowIfNeeded(project: Project) { if (project.isDisposed) return val onboardingService = project.getService(OnboardingStateService::class.java) if (onboardingService.shouldShowOnboarding()) { val toolWindowManager = ToolWindowManager.getInstance(project) val toolWindow = toolWindowManager.getToolWindow("Plugin") toolWindow?.activate({ onboardingService.markOnboardingShown() }, true) } } }
Теперь результат соответствует нашим ожиданиям. Решение работает независимо от версии и runtime Студии разработчика, а погружение в дизайн или вёрстку документа при каждом редактировании минимальное.

Заключение
Мы рассмотрели ещё несколько несложных задач, которые можно эффективно решить с помощью плагинов. Хотя не всегда всё получается так, как ожидалось, но в большинстве случаев плагины позволяют упростить работу и автоматизировать рутинные операции. Важно помнить, что за каждым решением, каким бы простым оно не выглядело, стоят десятки попыток.
Если всё сложится удачно, то в следующей статье мы рассмотрим создание плагина в контексте интеграции с ИИ.