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

Action запуска Coverage во всплывающем окне по имени теста или пакету с тестами
Action запуска Coverage во всплывающем окне по имени теста или пакету с тестами

Поскольку одна из задач заключается в фильтрации в выводе ненужных результатов, то наиболее подходящим кажется метод 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().

Выбор runtime с JCEF
Выбор runtime с JCEF

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

Получилось что-то такое, останется только немного дошлифовать стили
Получилось что-то такое, останется только немного дошлифовать стили

Заключение

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

Если всё сложится удачно, то в следующей статье мы рассмотрим создание плагина в конт��ксте интеграции с ИИ.