company_banner

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


    Начиная с 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/<category>. После запуска проекта Android Studio парсила содержимое папки /templates, добавляла в интерфейс меню New –> дополнительные action-ы, а при вызове action-а читала содержимое template.xml, строила UI и так далее.


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


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


    FreeMarker-ный подход

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


    <?xml version="1.0"?>
    <template
        format="4"
        revision="1"
        name="HeadHunter BaseFragment"
        description="Creates HeadHunter BaseFragment"
        minApi="7"
        minBuildApi="8">
    
        <category value="HeadHunter" />
    
        <!-- параметры фрагмента -->
    
        <parameter
            id="className"
            name="Fragment Name"
            type="string"
            constraints="class|nonempty|unique"
            default="BlankFragment"
            help="The name of the fragment class to create" />
    
        <parameter
            id="fragmentName"
            name="Fragment Layout Name"
            type="string"
            constraints="layout|nonempty|unique"
            default="fragment_blank"
            suggest="fragment_${classToResource(className)}"
            help="The name of the layout to create" />
    
        <parameter
            id="includeFactory"
            name="Include fragment factory method?"
            type="boolean"
            default="true"
            help="Generate static fragment factory method for easy instantiation" />
    
        <!-- доп параметры  -->
    
        <parameter
            id="includeModule"
            name="Include Toothpick Module class?"
            type="boolean"
            default="true"
            help="Generate fragment Toothpick Module for easy instantiation" />
    
        <parameter
            id="moduleName"
            name="Fragment Toothpick Module"
            type="string"
            constraints="class|nonempty|unique"
            default="BlankModule"
            visibility="includeModule"
            suggest="${underscoreToCamelCase(classToResource(className))}Module"
            help="The name of the Fragment Toothpick Module to create" />
    
        <thumbs>
            <thumb>template_base_fragment.png</thumb>
        </thumbs>
    
        <globals file="globals.xml.ftl" />
        <execute file="recipe.xml.ftl" />
    
    </template>

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


    <?xml version="1.0"?>
    <recipe>
    
        <#if useSupport>
        <dependency mavenUrl="com.android.support:support-v4:19.+"/>
        </#if>
    
        <instantiate
            from="res/layout/fragment_blank.xml.ftl"
            to="${escapeXmlAttribute(resOut)}/layout/${escapeXmlAttribute(fragmentName)}.xml" />
    
        <open file="${escapeXmlAttribute(resOut)}/layout/${escapeXmlAttribute(fragmentName)}.xml" />
    
        <instantiate
            from="src/app_package/BlankFragment.kt.ftl"
            to="${srcOutRRR}/${className}.kt" />
    
        <open file="${srcOutRRR}/${className}.kt" />
    
        <#if includeModule>
            <instantiate
                from="src/app_package/BlankModule.kt.ftl"
                to="${srcOutRRR}/di/${moduleName}.kt" />
    
            <open file="${srcOutRRR}/di/${moduleName}.kt" />
        </#if>
    
    </recipe>

    То же самое, но в 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<Template> {
            return listOf(
                baseFragmentTemplate
            )
        }
    
    }

    А затем добавляем созданный provider в качестве extension-а в plugin.xml файле:


    <extensions defaultExtensionNs="com.android.tools.idea.wizard.template">
        <wizardTemplateProvider implementation="ru.hh.plugins.geminio.actions.MyWizardTemplateProvider" />
    </extensions>

    Запустив Android Studio, мы увидим шаблон baseFragmentTemplate в меню New->Fragment и в галерее нового фрагмента.


    Покажи картинки!

    Вот наш шаблон в меню New -> Fragments:



    А вот он же – в галерее нового фрагмента:



    Если вы захотите самостоятельно пройти весь этот путь по добавлению нового шаблона из кода плагина, можете, во-первых, посмотреть на актуальный список готовых шаблонов в исходном коде Android Studio (который совсем недавно наконец-то добавили в cs.android.com), а во-вторых – почитать вот эту статью на Medium (там хорошо описана последовательность действий по созданию нового шаблона, но показан не очень правильный хак с получением инстанса Project-а – так лучше не делать).


    А чем ещё можно заменить FreeMarker?


    Кроме того, добавить шаблоны кода из плагинов можно с помощью File templates. Это очень просто: добавляете его в папку resources/fileTemplates и… Вы восхитительны!


    А можно поподробнее?

    В папку /resources/fileTemplates вашего плагина нужно добавить шаблон нужного вам кода, например, /resources/fileTemplates/Toothpick Module.kt.ft .


    package ${PACKAGE_NAME}.di
    
    import toothpick.config.Module
    
    internal class ${NAME}: Module() {
    
        init {
                // TODO
        }
    }

    Шаблоны кода работают на движке Velocity, поэтому можно добавлять в код шаблона условия и циклы. File template-ы имеют ряд встроенных параметров, например, PACKAGE_NAME (подставит package name, в зависимости от выбранного в Project View файла), MONTH (текущий месяц) и так далее. Каждый "неизвестный" параметр будет преобразован в поле ввода для пользователя.


    После запуска Android Studio в меню New вы увидите новый пункт с названием вашего шаблона:



    Нажав на элемент меню, вы увидите диалог, который построился на основе шаблона.



    Примеры таких шаблонов вы можете подсмотреть в репозитории MviCore коллег из Badoo. 


    В чём минус таких шаблонов – они не позволяют вам одновременно добавить несколько файлов. Поэтому мы в hh их обычно не создаём.


    Что не так с новым механизмом


    Основная претензия к новому механизму – отсутствие возможности повлиять на ваши шаблоны извне плагинов. Вы не можете ни поменять в них текст, ни добавить новый шаблон, пока не залезете в плагин. 


    Мы же хотим оперативно обновлять содержимое ftl-файлов, добавлять новые шаблоны и желательно без вмешательства в плагин, потому что отладка шаблонов из плагина — тот ещё квест =) А ещё – мы очень не хотим выбрасывать готовые шаблоны, которые заточены под использование FreeMarker-а.


    Механизм рендеринга шаблонов


    Почему бы не разобраться в том, как вообще происходит рендеринг новых шаблонов в Android Studio? И на основе этого механизма сделать обёртку, которая сможет пробросить созданные шаблоны на рендер.


    Разобрались. Делимся. 


    Чтобы заставить Android Studio построить UI и сгенерировать код на основе нужного шаблона, придётся написать довольно много кода. Допустим, вы уже создали собственный плагин, объявили зависимости от android-плагина, который лежит в Android Studio 4.1, добавили новый action, который будет отвечать за рендеринг. Тогда метод actionPerformed будет выглядеть вот так:


    Обработка actionPerformed
    override fun actionPerformed(e: AnActionEvent) {
        val dataContext = e.dataContext
    
        val module = LangDataKeys.MODULE.getData(dataContext)!!
    
        var targetDirectory = CommonDataKeys.VIRTUAL_FILE.getData(dataContext)
        if (targetDirectory != null && targetDirectory.isDirectory.not()) {
           // If the user selected a simulated folder entry (eg "Manifests"), there will be no target directory
            targetDirectory = targetDirectory.parent
        }
        targetDirectory!!
    
        val facet = AndroidFacet.getInstance(module)
        val moduleTemplates = facet.getModuleTemplates(targetDirectory)
        assert(moduleTemplates.isNotEmpty())
    
        val initialPackageSuggestion = facet.getPackageForPath(moduleTemplates, targetDirectory).orEmpty()
    
        val renderModel = RenderTemplateModel.fromFacet(
            facet,
            initialPackageSuggestion,
            moduleTemplates[0],
            "MyActionCommandName",
            ProjectSyncInvoker.DefaultProjectSyncInvoker(),
            true,
        ).apply {
            newTemplate = template { ... } // build your template
         }
    
         val configureTemplateStep = ConfigureTemplateParametersStep(
             model = renderModel,
             title = "Template name",
             templates = moduleTemplates
         )
    
         val wizard = ModelWizard.Builder()
                        .addStep(configureTemplateStep).build().apply {
              val resultListener = object : ModelWizard.WizardListener {
              override fun onWizardFinished(result: ModelWizard.WizardResult) {
                  super.onWizardFinished(result)
                  if (result.isFinished) {
                      // TODO do some stuff after creating files
                      //   (renderTemplateModel.createdFiles)
                  }
              }
           }
        }
    
         val dialog = StudioWizardDialogBuilder(wizard, "Template wizard")
                .setProject(e.project!!)
                .build()
         dialog.show()
    }

    Фух, это довольно много кода! Но с другой стороны, это снимает с нас необходимость думать про построения диалогов с разными параметрами, работу с генерацией кода и многим другим, так что сейчас разберемся.


    По логике программы, пользователь плагина нажимает Cmd + N на каком-то файле или package-е внутри какого-то модуля. Именно там мы и создадим пачку файлов, которые нам нужны. Поэтому необходимо определить, внутри какого же модуля и какой папки работаем.


    Чтобы это сделать, воспользуемся возможностями AnActionEvent-а.


    val dataContext = e.dataContext
    
    val module = LangDataKeys.MODULE.getData(dataContext)!!
    
    var targetDirectory = CommonDataKeys.VIRTUAL_FILE.getData(dataContext)
    if (targetDirectory != null && targetDirectory.isDirectory.not()) {
        // If the user selected a simulated folder entry (eg "Manifests"), there will be no target directory
        targetDirectory = targetDirectory.parent
    }
    targetDirectory!!

    Как я уже рассказывал в своей статье с теорией плагиностроения, AnActionEvent представляет собой контекст исполнения вашего Action-а. Внутри этого класса есть свойство dataContext, из которого при помощи специальных ключей мы можем доставать нужные данные. Чтобы посмотреть, какие ещё ключи есть, обратите внимание на классы PlatformDataKeys, LangDataKeys и другие. Ключ LangDataKeys.MODULE возвращает нам текущий модуль, а CommonDataKeys.VIRTUAL_FILE – выбранный пользователем в Project View файл. Немного преобразований и мы получаем директорию, внутрь которой нужно добавлять файлы.


    val facet = AndroidFacet.getInstance(module)

    Чтобы двигаться дальше, нам требуется объект AndroidFacet. Facet — это, по сути, свойства модуля, которые специфичны для того или иного фреймворка. В данном случае мы получаем специфичное для Android описание нашего модуля. Из facet-а можно достать, например, package name, указанный в AndroidManifest.xml вашего android-модуля.


    val moduleTemplates = facet.getModuleTemplates(targetDirectory)
    assert(moduleTemplates.isNotEmpty())
    
    val initialPackageSuggestion = facet.getPackageForPath(moduleTemplates, targetDirectory).orEmpty()

    Из facet-а мы достаём объект NamedModuleTemplate – контейнер для основных “путей” android-модуля: путь до папки с исходным кодом, папки с ресурсами, тестами и т.д. Благодаря этому объекту можно найти и package name для подстановки в будущие шаблоны кода.


    val renderModel = RenderTemplateModel.fromFacet(
        facet,
        initialPackageSuggestion,
        moduleTemplates[0],
        "MyActionCommandName",
        ProjectSyncInvoker.DefaultProjectSyncInvoker(),
        true,
    ).apply {
        newTemplate = template { ... } // build your template
    }

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


    • AndroidFacet модуля, в котором мы создаем файлы;
    • первый предлагаемый пользователю package name (его можно будет использовать в параметрах шаблона);
    • объект, хранящий пути к основным папкам модуля, — NamedModuleTemplate;
    • строковую константу для идентификации WriteCommandAction (внутренний объект IDEA, предназначенный для операций модификации кода) – она нужна для того, чтобы у вас сработал Undo;
    • объект, отвечающий за синхронизацию проекта после создания файлов, — ProjectSyncInvoker;
    • и, наконец, флаг — true или false, — который отвечает за то, можно ли открывать все созданные файлы в редакторе кода или нет.

    val configureTemplateStep = ConfigureTemplateParametersStep(
        model = renderModel,
        title = "Template name",
        templates = moduleTemplates
    )
    
    val wizard = ModelWizard.Builder()
        .addStep(configureTemplateStep)
        .build().apply {
            val resultListener = object : ModelWizard.WizardListener {     
              override fun onWizardFinished(result: ModelWizard.WizardResult) {         
                  super.onWizardFinished(result)         
                  if (result.isFinished) {             
                      // TODO do some stuff after creating files 
                      //   (renderTemplateModel.createdFiles)         
                  }     
              } 
        }
    }
    
    val dialog = StudioWizardDialogBuilder(wizard, "Template wizard")
                .setProject(e.project!!)
                .build()
    dialog.show()

    Финал!


    Для начала создаем ConfigureTemplateParametersStep, который прочитает переданный объект template-а и сформирует UI страницы wizard-диалога, потом пробрасываем step в модель Wizard-диалога и наконец-то показываем сам диалог.


    А ещё мы добавили специальный listener на событие завершения диалога, так что после создания файлов можем ещё и как-то их модифицировать. Достучаться до созданных файлов можно через renderTemplateModel.createdFiles.


    Самое сложное – позади! Мы показали диалог, который взял на себя работу по построению UI из модели шаблона и обработку рецепта внутри шаблона.


    Остаётся только откуда-то получить сам шаблон. И рецепт.


    Откуда взять модель шаблона


    Исходная задача, которую я решал – дать коллегам возможность хранить шаблоны не в виде кода, а в виде отдельных ресурсов. Поэтому мне был нужен какой-то промежуточный формат данных, которые я потом сконвертирую в необходимые Android Studio для построения диалога.


    Мне показалось, что самый простой формат – это yaml-конфиг. Почему именно yaml? Потому что: а) выглядит проще XML, и б) внутри IDEA уже есть подключенная библиотечка для его парсинга – SnakeYaml, позволяющая в одну строчку прочитать весь файл в Map<String, Any>, который можно дальше крутить как угодно.


    В данный момент конфиг шаблона выглядит так:


    yaml-конфиг шаблона
    requiredParams:
      name: HeadHunter BaseFragment
      description: Creates HeadHunter BaseFragment
    
    optionalParams:
      revision: 1
      category: fragment
      formFactor: mobile
      constraints:
        - kotlin
      screens:
        - fragment_gallery
        - menu_entry
      minApi: 7
      minBuildApi: 8
    
    widgets:
      - stringParameter:
          id: className
          name: Fragment Name
          help: The name of the fragment class to create
          constraints:
            - class
            - nonempty
            - unique
          default: BlankFragment
    
      - stringParameter:
          id: fragmentName
          name: Fragment Layout Name
          help: The name of the layout to create
          constraints:
            - layout
            - nonempty
            - unique
          default: fragment_blank
          suggest: fragment_${className.classToResource()}
    
      - booleanParameter:
          id: includeFactory
          name: Include fragment factory method?
          help: Generate static fragment factory method for easy instantiation
          default: true
    
      - booleanParameter:
          id: includeModule
          name: Include Toothpick Module class?
          help: Generate fragment Toothpick Module for easy instantiation
          default: true
    
      - stringParameter:
          id: moduleName
          name: Fragment Toothpick Module
          help: The name of the Fragment Toothpick Module to create
          constraints:
            - class
            - nonempty
            - unique
          default: BlankModule
          visibility: ${includeModule}
          suggest: ${className.classToResource().underlinesToCamelCase()}Module
    
    recipe:
      - instantiateAndOpen:
          from: root/src/app_package/BlankFragment.kt.ftl
          to: ${srcOut}/${className}.kt
      - instantiateAndOpen:
          from: root/res/layout/fragment_blank.xml.ftl
          to: ${resOut}/layout/${fragmentName}.xml
      - predicate:
          validIf: ${includeModule}
          commands:
            - instantiateAndOpen:
                from: root/src/app_package/BlankModule.kt.ftl
                to: ${srcOut}/di/${moduleName}.kt

    Вся конфигурация шаблона делится на 4 секции:


    • requiredParams – параметры, обязательные для каждого шаблона;
    • optionalParams – параметры, которые можно спокойно опустить при описании шаблона. В данный момент эти параметры ни на что не влияют, потому что мы не подключаем созданный на основе конфига шаблон через extension point.
    • widgets – набор параметров шаблона, которые зависят от пользовательского ввода. Каждый из этих параметров в конечном итоге превратится в виджет на UI диалога (textField-ы, checkbox-ы и т.п.);
    • recipe – набор инструкций, которые выполняются после того, как пользователь заполнит все параметры шаблона.

    Написанный мною плагин парсит этот конфиг, конвертирует его в объект шаблона Android Studio и пробрасывает в RenderTemplateModel.


    В самой конвертации практически не было ничего интересного кроме парсинга “выражений”. Я имею в виду строчки вот такого вида:


    suggest: ${className.classToResource().underlinesToCamelCase()}Module

    Нужно было прочитать эту строчку, понять, есть ли в ней использование каких-то переменных, проводятся ли какие-то модификации над этими переменными. Я не придумал ничего лучше, чем парсинг таких выражений в последовательность команд:


    sealed class Command {
    
        data class Fixed(
            val value: String
        ) : Command()
    
        data class Dynamic(
            val parameterId: String,
            val modifiers: List<GeminioRecipeExpressionModifier>
        ) : Command()
    
        data class SrcOut(
            val modifiers: List<GeminioRecipeExpressionModifier>
        ) : Command()
    
        data class ResOut(
            val modifiers: List<GeminioRecipeExpressionModifier>
        ) : Command()
    
        object ReturnTrue : Command()
    
        object ReturnFalse : Command()
    
    }

    Каждая команда знает, как себя вычислить, какой она внесёт вклад в итоговый результат, требуемый в том или ином параметре. Над парсингом выражений пришлось немного посидеть: сначала я хотел выцепить отдельные кусочки ${...} с помощью регулярок, но вы же знаете, если вы хотите решить какую-то проблему с помощью регулярных выражений, то у вас появляется ещё одна проблема. В итоге я распарсил строчку посимвольно.


    Что ещё хорошо в своём собственном формате конфига – можно добавлять новые ключи и строить на них свою дополнительную логику. Так, например, появилась новая команда для рецептов – instantiateAndOpen, — которая сначала создаёт файл из текста ftl-шаблона, а потом открывает созданный файл в редакторе кода. Да-да, в FreeMarker-ных шаблонах уже были команды instantiate и open, но это были отдельные команды.


    recipe:
      # Можно писать вот так
      - instantiate:
          from: root/src/app_package/BlankFragment.kt.ftl
          to: ${srcOut}/${className}.kt
      - open:
          file: ${srcOut}/${className}.kt
    
      # А можно одной командой:
      - instantiateAndOpen:
          from: root/src/app_package/BlankFragment.kt.ftl
          to: ${srcOut}/${className}.kt

    Какие ещё есть плюсы в Geminio


    Основной плюс – после того, как вы создали папку для шаблона с рецептом внутри, и Android Studio создала для этого шаблона Action, вы можете как угодно менять ваш рецепт и файлы с шаблонами кода. Все изменения применятся сразу же, вам не нужно будет перезапускать IDE для того, чтобы проверить шаблон. То есть цикл проверки шаблона стал в разы короче.


    Если бы вы создавали шаблон из плагина, то вы бы не избежали этой проблемы с перезапуском IDE – в случае ошибки ваш шаблон бы просто не работал.


    Roadmap


    Я был бы рад сказать, что уже сейчас плагин поддерживает все возможности, которые были у FreeMarker-ных шаблонов, но… нет. Далеко не все возможности нужны прямо сейчас, а до некоторых мы обязательно доберёмся в рамках улучшения других плагинов. Например:


    • нет поддержки enum-параметров, которые бы отображались на UI в виде combobox-ов;
    • не все команды из FreeMarker-ных шаблонов поддерживаются в рецептах – например, нет автоматического добавления зависимостей в build.gradle, merge-а XML-ресурсов;
    • новые шаблоны страдают от той же проблемы, что и FreeMarker-ные шаблоны – нет адекватной валидации, которая бы точно сказала, где именно случилась ошибка;
    • и нет никаких подсказок IDE при описании шаблона.

    Заключение


    Заканчивать нужно на позитивной ноте. Поэтому вот немного позитива:


    • несмотря на то, что Google прекратил поддержку FreeMarker-ных шаблонов, мы всё равно создали инструмент для тотальной шаблонизации
    • дистрибутив плагина можно скачать в нашем репозитории;
    • я буду рад вашим вопросам и постараюсь на них ответить.

    Всем успешной автоматизации.


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


    HeadHunter
    HR Digital

    Комментарии 7

      0
      Круто получилось. Когда разбирался с template тоже думал о подобном, но успокоился, когда FreeMarker стал работать и оставалось править только в файлах темлейтов, если что-то изменилось.
      Вопрос: вы для темлейтов отдельный модуль делаете или где-то в common храните?
        0
        Привет!
        Свои шаблоны мы храним в отдельном, открытом репозитории — github.com/hhru/android-style-guide/tree/master/tools/geminio

        Этот репозиторий мы подключаем в качестве submodule-я к нашему основному репозиторию, так что, по сути, всё хранится в отдельном месте.
          0

          То есть вы его собрали как артефакт и через dependencies подключаете?

            0
            Нет, видимо я недостаточно точно выразился.
            Я имел в виду Git submodule.

            В нашем основном репозитории хранится указание на нужный коммит репозитория с шаблонами. Когда мы скачиваем проект из github-а, мы выполняем команды

            git submodule init
            git submodule update


            И код из репозитория добавляется внутрь нашего основного проекта. Из IDE это выглядит так, будто код шаблонов лежит прямо в нашей кодовой базе, вы даже можете менять текст шаблонов в своём проекте, но это не повлияет на код шаблонов в отдельном репозитории.
        +1

        Несколько лет пользовались File Templates и с выходом новой студии прошли все стадии принятия горя, что их поддержки больше нет. Спасибо за плагин, он помог сохранить все наши шаблоны и решить проблему, не тратя время на написание какого-то нового решения. В целом за день я смогла перевести 6 группы шаблонов для разных модулей и экранов <3


        Хочу оставить пару фича-реквестов и поделиться, какие проблемы встретила, потока адаптировала наши шаблоны под работу с Geminio, надеюсь это поможет остальным.


        1. Всё началось конечно же с того, что я поставила плагин, всё настроила по инструкции, попыталась сгенерить первый класс из шаблона и у меня ничего не заработало, а логов в студии было не видно. Поэтому решила запустить плагин из IDEA и посмотреть что же не так. На моей дорогой Ubuntu ничего не собралось. Но ошибка к счастью была довольно очевидная
          Specified localPath '/Applications/Android Studio.app' doesn't exist or is not a directory
          Потребовалось явно указать путь до студии в build.gradle файлах и всё отлично заработало и теперь можно было смотреть логи


          intellij {
          localPath = "/opt/android-studio/"
          }

        2. В моих шаблонах использовались глобальные переменные из globals.xml.ftl, но их значения не подтягивались и видимо пока работа с ними не предусмотрена.
          Часть переменных я вынесла в локальные внутри файлов шаблонов через:
          <#assign viewClassName = "${screenName}View">
          Ну а часть оформила в виде невидимых полей внутри формы.


          - stringParameter:
            id: viewClassName
            name: Имя файла экрана
            help: The name of view class
            default: ${screenName}View
            suggest: ${screenName}View
            visibility: false

          Наверное именно перенос глобальных переменных занял в адаптации шаблонов под плагин наибольшее количество времени.


        3. И мой второй фича-реквест будет о том, что иногда в условных выражениях очень нужен оператор отрицания
          Например, раньше наш файл recipe.xml.ftl содержал вот такую логику


          <#if !isMergedStateHolder>
              <instantiate from="src/app_package/java/StateHolder.kt.ftl" to="${srcOut}/${stateHolderClassName}.kt"/>
          </#if>

          Как это перенести, я не смогла найти, проверила парсинг выражений, и вроде как возможно только задать "выполни эти команды, если true", а иногда хочется и если false. Тут выйти из ситуации несложно, просто переформулируешь пункт на противоположный, поэтому теперь у нас isSeparateStateHolder).
          Но это не помогает для выражений с видимостью элементов формы, там тоже не хватает отрицания.


        4. И последнее чему я не нашла аналог, это как создать пустые директории без файлов, раньше мы использовали


          <mkdir at="${moduleName}" />
          <mkdir at="${moduleName}/src/main/java/${slashedPackageName(packageName)}/${moduleName}" />
          <mkdir at="${moduleName}/src/main/res" />

          В заключении хотела бы еще раз сказать спасибо за такой прекрасный плагин, и по ощущениям подход с хранением шаблонов внутри проекта гораздо удобнее старого коробочного хотя бы тем, что каждый проект может "подправить" что-то под себя значительно быстрее. Потому что теперь даже не надо искать папку с шаблонами внутри файлов студии, а все в твоем же проекте под рукой.


          0
          Спасибо большое за такой подробный комментарий. Я очень рад, что наше решение пригодилось вашей команде!

          По пунктам:

          1. Забыл упомянуть в статье, что мы всё тестировали только на MacOs, на других системах не пробовали даже. Похоже, что нужно дописать в инструкцию шаги, что делать, если у вас другая операционная система, спасибо.

          2. Globals специально не переносили. Всегда раздражало переключаться постоянно между template и global-ом. Но сейчас их стало не хватать самим, не очень удобным кажется добавление таких «переменных» через stringParameter. Возможно, добавлю секцию globals с возможностью туда внедрять нужные переменные с expression-ами.

          3. Да, оператора отрицания тоже стало не хватать, добавлю в ближайшее время.

          4. А вы это использовали для создания новых модулей? У нас есть планы докрутить функционал Geminio для похожих целей, тоже планировали заняться этим в ближайшее время)

          Ещё раз спасибо за подробный фидбек!

            +1

            Да, для создания пустых модулей с заполненными под нашу архитектуру gradle файлами. Это не так круто, как создавать модули плагином, чтобы еще и все зависимости автоматически в проекте прописать от нового модуля, но лучше, чем модули, которая создает студия с миллионном ненужных ресурсов.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое