Moxy Strategy plugin

в 17:28, , рубрики: android, kotlin, moxy, plugin, разработка мобильных приложений, Разработка под android

Иногда простые вещи очень утомляют, особенно когда их необходимо делать постоянно. Одна из таких вещей при работе с фреймворком Moxy — это добавление стратегий к функциям. Для ускорения этого процесса был написан плагин, который по "alt+enter" предоставляет выбор стратегии если ее нет или диалог с заменой на другую стратегию. Те, кто хочет узнать как это работает, добро пожаловать под кат.

Moxy Strategy plugin - 1

Когда я только начинал изучать Moxy, одной из сложностей было запомнить названия стратегий и не забывать об их существовании. После практики с ними эта проблема ушла, но все равно было желание каким то образом это оптимизировать.

В этот момент я заинтересовался созданием плагинов, и adev_one подсказал мне, что плагин как раз и может решить такую проблему.

Inspection

Итак, мы написали функцию и хотим добавить стратегию. В этот момент нам на помощь приходит code inspections. Они анализируют код текущего файла при помощи PSI (Program Structure Interface), и если что то не так, подсвечивают код красным или серым, и по alt+enter предлагают возможные исправления. Официальная документация по разработке code inspections.
PSI — это структура, в которой у каждого выражения, ключевого слова и т.д. есть свой аналог, который содержит информацию о нем, его parent и child. Для того, чтобы понять какой класс соответствует необходимой конструкции языка, есть хороший плагин Psi viewer plugin.

Рассмотрим для примера с помощью него структуру интерфейса с функцией.

Видим что интересующий нас класс это KtNamedFunction.

Рассмотрим создание Inpection.

Как создавать проект и не только хорошо расписано в цикле статей. Дополнительно понадобятся зависимости в файле plugins.xml

    <depends>org.jetbrains.kotlin</depends>
    <depends>com.intellij.modules.lang</depends>

и в файле build.gradle

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
    implementation "org.jetbrains.kotlin:kotlin-reflect"
    implementation "com.github.moxy-community:moxy:1.0.13"
}

intellij {
 ...
    plugins = ["Kotlin"]
 ...
}

Перейдем к созданию inpection.

Для начала необходимо наследовать AbstractKotlinInspection

class MvpViewStrategyInspection : AbstractKotlinInspection() {}

В нем нам понадобится переопределить следующую функцию:

override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor 

В ней мы создаем Visitor, который будет анализировать текущий код файла и добавлять ошибки и предупреждения.

Хорошей документации по Visitor я не нашел, но когда смотрел плагины kotlin наткнулся на очень удобные функции-фабрики для создания Visitor. Для анализа KtNamedFunction есть следующая фабрика:

org.jetbrains.kotlin.psi VisitorWrappersKt.class 
public fun namedFunctionVisitor(block: (KtNamedFunction) → Unit): KtVisitorVoid

В лямбде нужно проанализировать PSI: выбрана ли функция без аннотации и является ли она частью интерфейса, который реализует интерфейс MvpView. Когда все условия выполнены, регистрируем ошибку с помощью ProblemsHolder.
В итоге выглядит это вот так:

private val fixes = MoxyStrategy.values().map { AddStrategyFix(it) }.toTypedArray()

override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
        return namedFunctionVisitor { ktNamedFunction ->
            if (!ktNamedFunction.isClassInheritMvpView() ||
                ktNamedFunction.isHasMoxyAnnotation()
            ) return@namedFunctionVisitor
            holder.registerProblem(
                ktNamedFunction, // psiElement
                StrategyIntentionType.MissingStrategy.title,  // description
                *fixes // LocalQuickFix
            )
        }
    }

Что такое AddStrategyFix? Это объект, который наследует LocalQuickFix. Он отвечает за исправления кода и выполнится, когда будет выбран из предложенных пунктов:

class AddStrategyFix(
        private val strategy: MoxyStrategy
    ) : LocalQuickFix {
        override fun getFamilyName(): String = "add ${strategy.className}"

        override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
            val ktFunction = (descriptor.psiElement as KtNamedFunction)
            val editor = ktFunction.getProjectEditor()
            ktFunction.addStrategyAnnotation(strategy, project, editor)
        }
    }

getFamilyName — определяет название пункта, который будет в диалоге.
В applyFix очевидно нужно выполнить исправление. В нашем случае добавить аннотацию.

Осталось зарегистрировать Inspection в plguin.xml, (похоже Activity в Manifest)

 <extensions defaultExtensionNs="com.intellij">
        <localInspection language="kotlin"
                         displayName="missing strategy for function"
                         groupPath="Moxy"
                         groupBundle="messages.InspectionsBundle"
                         groupKey="group.names.probable.bugs"
                         enabledByDefault="true"
                         level="ERROR"
                         implementationClass="com.maksimnovikov.inspection.MvpViewStrategyInspection"/>
    </extensions>

В итоге получаем:

Moxy Strategy plugin - 2

Так же автоматически добавляются импорты аннотации StateStrategyType и выбранной стратегии.

Для стратегии AddToEndSingleTagStrategy дополнительно сделал добавление второго аргумента
tag и перенос курсора на него.

Moxy Strategy plugin - 3

Intention

Функцию написали, стратегию быстро добавили. Передумали и решили заменить ее на другую.
Теперь наш помощник это Intention.
Принцип работы аналогичен с Inspection, но без какого либо выделения кода и анализируется только текущий выбранный элемент под кареткой. Как использовать уже имеющиеся intention. Официальная документация по разработке.

Рассмотрим создание Intention.

Необходимо наследовать PsiElementBaseIntentionAction :

class MvpViewStrategyIntention : PsiElementBaseIntentionAction(){
    override fun getText(): String 
    override fun getFamilyName(): String
    override fun isAvailable(project: Project, editor: Editor?, element: PsiElement): Boolean
    override fun invoke(project: Project, editor: Editor?, element: PsiElement)
}

getText — определяет отображаемое название
getFamilyName — нужен для иерархии intention и отображения в настройках
isAvailable — в этой функции исходя из текущего элемента под курсором, необходимо определить будет ли добавлен intention в текущий список или нет.
invoke — вызывается, когда мы выбрали данный intention из списка

Рассмотрим подробнее isAvailable и invoke:

isAvailable

В этой функции нужно как можно раньше определить, что intention не нужно добавлять и не анализировать дальше. Это увеличит производительность.
Так же текущий выбранный элемент может быть child текущий функции, потому необходимо получить саму функцию. Остальные проверки аналогичны.

override fun isAvailable(project: Project, editor: Editor?, element: PsiElement): Boolean {
        if ((element.containingFile ?: return false) !is KtFile) return false
        if (!element.isClassInheritMvpView()) return false
        val ktFunction = element.getParentOfType<KtNamedFunction>() ?: return false
        return ktFunction.isHasMoxyAnnotation()
    }

invoke

Поскольку добавление отдельного Intention для каждой стратегии не выгодно по производительности, необходимо показать еще один диалог по нажатию на один пункт.
Под капотом наша любимая IDE использует swing. Соответственно, кто хорошо с ним знаком, может создавать разнообразный интерфейс. Я пошел другим путем и использовал простой билдер для диалога выбора — JBPopupFactory

fun <T> Editor.showSelectPopup(
    items: List<T>,
    onSelected: (T) -> Unit
) {
    JBPopupFactory.getInstance()
        .createPopupChooserBuilder(items)
        .setRequestFocus(true)
        .setCancelOnClickOutside(true)
        .setItemChosenCallback { onSelected(it) }
        .createPopup()
        .showInBestPositionFor(this)
}

Однако, после создания диалога контекст сменяется на другой, который уже не может изменять код и при попытки сделать это выдает ошибку. Чтобы это исправить дополнительно оборачиваем действие по замене стратегии в функцию WriteCommandAction.runWriteCommandAction(project) {}

  override fun invoke(project: Project, editor: Editor, element: PsiElement) {
        editor.showSelectPopup(MoxyStrategy.values().toList()) { selectedStrategy ->
            WriteCommandAction.runWriteCommandAction(project) {
                val ktFunction = element.getParentOfType<KtNamedFunction>() ?: return@runWriteCommandAction
                ktFunction.replaceAnnotation(selectedStrategy, project, editor)
            }
        }
    }

Так же регистрируем intention в plugin.xml

<extensions defaultExtensionNs="com.intellij">
        <intentionAction>
            <className>com.maksimnovikov.intention.MvpViewStrategyIntention</className>
            <category>Moxy intentions</category>
        </intentionAction>
</extensions>

В итоге получаем следующий функционал:

Moxy Strategy plugin - 4

Moxy Strategy plugin - 5

Итого

С этим плагином решается проблема запоминания названий стандартных стратегий. Их список всегда виден. Если принцип работы стратегии поначалу может быть не понятен, возможно вставить её и перейти к исходному коду, где есть документация. Также в общем улучшается удобство работы со стратегиями Moxy.
Создание плагинов раньше для меня казалось очень сложным и непонятным. В основном из-за отсутствия примеров и недостаточности документации на официальном сайте. Это и подтолкнуло меня написать эту статью. Надеюсь создание плагинов стало для вас чуть более понятным. Буду рад конструктивной критике и обратной связи.

Полный код проекта можно посмотреть здесь

Полезные ссылки

Автор: MaksimNovikov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js