Начиная с 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 для описания шаблонов. Сравните два подхода:
Вот так выглядел файл 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>
Сначала мы создаём описание шаблона с помощью специального 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}.diimport toothpick.config.Moduleinternal 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 будет выглядеть вот так:
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.dataContextval 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>, который можно дальше крутить как угодно.
В данный момент конфиг шаблона выглядит так:
requiredParams: name: HeadHunter BaseFragment description: Creates HeadHunter BaseFragmentoptionalParams: revision: 1 category: fragment formFactor: mobile constraints: - kotlin screens: - fragment_gallery - menu_entry minApi: 7 minBuildApi: 8widgets: - 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()}Modulerecipe: - 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-ных шаблонов, мы всё равно создали инструмент для тотальной шаблонизации
- дистрибутив плагина можно скачать в нашем репозитории;
- я буду рад вашим вопросам и постараюсь на них ответить.
Всем успешной автоматизации.
Полезные ссылки
- Исходный код Geminio и его дистрибутив
- Исходный код Android Studio и код актуальных шаблонов
- Статья от RedMadRobot про FreeMarker-ные шаблоны
- Статья на Medium про добавление собственных шаблонов изнутри плагинов