Всем привет! Меня зовут Алексей, я техлид 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 Студии разработчика, а погружение в дизайн или вёрстку документа при каждом редактировании минимальное.

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