Магическая шаблонизация для Android-проектов

ueultcu9h6_imr6b0x6kdrl-nxq.jpeg

Начиная с Android Studio 4.1, Google прекратил поддержку кастомных FreeMarker-ных шаблонов. Теперь вы не можете просто взять и написать свои ftl-файлы и сложить их в определённую папку, чтобы Android Studio самостоятельно добавила их в меню New → Other. В качестве альтернативы нам предлагают разбираться в плагиностроении и создавать шаблоны изнутри плагинов IDEA. Нас в hh такая ситуация не очень устраивает, так как есть несколько полезных FreeMarker-ных шаблонов, которые мы постоянно используем и которые иногда нуждаются в обновлениях. Лезть в плагины, чтобы поправить какой-то шаблон? Нет уж, увольте. 

Всё это привело к тому, что мы разработали специальный плагин для Android Studio, который поможет решить эти проблемы. Встречайте — Geminio.

Про то, как работает плагин и что требуется для его настройки вы можете подробнее почитать в его README, а вот про то, как он устроен изнутри — только здесь. А ещё я расскажу, как теперь можно из плагинов создавать свои шаблоны.

*Geminio — заклинание удвоения предметов во вселенной Гарри Поттера


Немного терминологии

Чтобы меньше путаться и синхронизировать понимание того, о чём мы говорим, введём немного терминологии.

Я буду называть шаблоном набор метаданных, который необходим в построении диалога для ввода пользовательских параметров. «Рецептом» назовём набор инструкций для исполнения, который отработает после того, как пользователь введёт данные. Когда я буду говорить про шаблонный текст генерируемого кода, я буду называть это ftl-шаблонами или FreeMarker-ными шаблонами.


Чем заменили FreeMarker?

Google уже давно объявил Kotlin предпочитаемым языком для разработки под Android. Все новые библиотеки, новые приложения в Google постепенно переписываются именно на Kotlin. И плагин android-а в Android Studio не стал исключением.

Как механизм шаблонов работал до Android Studio 4.1? Вы создавали папку для описания шаблона, заводили в нём несколько файлов — globals.xml.ftl, template.xml, recipe.xml.ftl для описания параметров и инструкций выполнения шаблона, а ещё вы помещали туда ftl-шаблоны, служившие каркасом генерируемого кода. Затем все эти файлы перемещали в папку Android Studio/plugins/android/lib/templates/. После запуска проекта Android Studio парсила содержимое папки /templates, добавляла в интерфейс меню New –> дополнительные action-ы, а при вызове action-а читала содержимое template.xml, строила UI и так далее.

В целом понятно, почему в Google отказались от этого механизма. Создание нового шаблона на основе FreeMarker-ных recipe-ов раньше напоминало русскую рулетку: до запуска ты никогда не мог точно сказать, правильно ли его описал, все ли требуемые параметры заполнил. А потом, по реакции Android Studio, ты пытался определить, в какой конкретной букве ошибся. Находил ошибку, менял шаблон, и всё шло на новый круг. А число шаблонов растёт, растёт и количество мест в интерфейсе, куда хочется добавлять эти шаблоны. Раньше для добавления одного и того же шаблона в несколько мест интерфейса приходилось создавать дополнительные action-ы плагины. Нужно было упрощать.

Вот так и появился удобный Kotlin DSL для описания шаблонов. Сравните два подхода:


FreeMarker-ный подход

Вот так выглядел файл template.xml:


А ещё был файл recipe.xml.ftl:




    <#if useSupport>
    
    

    

    

    

    

    <#if includeModule>
        

        
    


То же самое, но в Kotlin DSL

Сначала мы создаём описание шаблона с помощью специального TemplateBuilder-а:

val baseFragmentTemplate: Template
    get() = template {
        revision = 1
        name = "HeadHunter BaseFragment"
        description = "Creates HeadHunter BaseFragment"
        minApi = 7
        minBuildApi = 8

        formFactor = FormFactor.Mobile
        category = Category.Fragment
        screens = listOf(
            WizardUiContext.FragmentGallery,
            WizardUiContext.MenuEntry
        )

        // параметры
        val className = stringParameter {
            name = "Fragment Name"
            constraints = listOf(
                Constraint.CLASS,
                Constraint.NONEMPTY,
                Constraint.UNIQUE
            )
            default = "BlankFragment"
            help = "The name of the fragment class to create"
        }
        val fragmentName = stringParameter {
            name = "Fragment Layout Name"
            constraints = listOf(
                Constraint.LAYOUT,
                Constraint.NONEMPTY,
                Constraint.UNIQUE
            )
            default = "fragment_blank"
            suggest = { "fragment_${classToResource(className.value)}" }
            help = "The name of the layout to create"
        }
        val includeFactory = booleanParameter {
            name = "Include fragment factory method?"
            default = true
            help = "Generate static fragment factory method for easy instantiation"
        }

        // доп. параметры
        val includeModule = booleanParameter {
            name = "Include Toothpick Module class?"
            default = true
            help = "Generate fragment Toothpick Module for easy instantiation"
        }
        val moduleName = stringParameter {
            name = "Fragment Toothpick Module"
            constraints = listOf(
                Constraint.CLASS,
                Constraint.NONEMPTY,
                Constraint.UNIQUE
            )
            visible = { includeModule.value }
            suggest = { "${underscoreToCamelCase(classToResource(className.value))}Module" }
            help = "The name of the Fragment Toothpick Module to create"
            default = "BlankFragmentModule"
        }

        thumb { File("template_base_fragment.png") }

        recipe = { templateData ->
            baseFragmentRecipe(
                moduleData = templateData as ModuleTemplateData,
                className = className.value,
                fragmentName = fragmentName.value,
                includeFactory = includeFactory.value,
                includeModule = includeModule.value,
                moduleName = moduleName.value
            )
        }
    }

Затем описываем рецепт в отдельной функции:

fun RecipeExecutor.baseFragmentRecipe(
    moduleData: ModuleTemplateData,
    className: String,
    fragmentName: String,
    includeFactory: Boolean,
    includeModule: Boolean,
    moduleName: String
) {
    val (projectData, srcOut, resOut, _) = moduleData

    if (projectData.androidXSupport.not()) {
        addDependency("com.android.support:support-v4:19.+")
    }
    save(getFragmentBlankLayoutText(), resOut.resolve("/layout/${fragmentName}.xml"))
    open(resOut.resolve("/layout/${fragmentName}.xml"))

    save(getFragmentBlankClassText(className, includeFactory), srcOut.resolve("${className}.kt"))
    open(srcOut.resolve("${className}.kt"))

    if (includeModule) {
        save(getFragmentModuleClassText(moduleName), srcOut.resolve("/di/${moduleName}.kt"))
        open(srcOut.resolve("/di/${moduleName}.kt"))
    }
}

private fun getFragmentBlankClassText(className: String, includeFactory: Boolean): String {
    return "..."
}

private fun getFragmentBlankLayoutText(): String {
    return "..."
}

private fun getFragmentModuleClassText(moduleName: String): String {
    return "..."
}

Текст шаблонов перекочевал из FreeMarker-ных ftl-файлов в Kotlin-овские строчки.

По количеству кода получается примерно то же самое, но вот наличие подсказок IDE при описании шаблона помогает не ошибаться в значениях enum-ов и функциях. Добавьте к этому валидацию при создании объекта шаблона (например, покажется исключение, если вы забыли указать один из необходимых параметров), возможность вызова шаблона из разных меню в Android Studio — и, кажется, у нас есть победитель.


Добавление шаблона через extension point

Чтобы новые шаблоны попали в существующие галереи новых объектов в Android Studio, нужно добавить созданный с помощью DSL шаблон в новую точку расширения (extension point) — WizardTemplateProvider.

Для этого мы сначала создаём класс provider-а, наследуясь от абстрактного класса WizardTemplateProvider:

class MyWizardTemplateProvider : WizardTemplateProvider() {

    override fun getTemplates(): List