Магическая шаблонизация для 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/
. После запуска проекта Android Studio парсила содержимое папки /templates, добавляла в интерфейс меню New –>
дополнительные action-ы, а при вызове action-а читала содержимое template.xml
, строила UI и так далее.
В целом понятно, почему в Google отказались от этого механизма. Создание нового шаблона на основе FreeMarker-ных recipe-ов раньше напоминало русскую рулетку: до запуска ты никогда не мог точно сказать, правильно ли его описал, все ли требуемые параметры заполнил. А потом, по реакции Android Studio, ты пытался определить, в какой конкретной букве ошибся. Находил ошибку, менял шаблон, и всё шло на новый круг. А число шаблонов растёт, растёт и количество мест в интерфейсе, куда хочется добавлять эти шаблоны. Раньше для добавления одного и того же шаблона в несколько мест интерфейса приходилось создавать дополнительные action-ы плагины. Нужно было упрощать.
Вот так и появился удобный Kotlin DSL для описания шаблонов. Сравните два подхода:
Вот так выглядел файл template.xml:
template_base_fragment.png
А ещё был файл recipe.xml.ftl:
<#if useSupport>
#if>
<#if includeModule>
#if>
Сначала мы создаём описание шаблона с помощью специального 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 {
return listOf(
baseFragmentTemplate
)
}
}
А затем добавляем созданный provider в качестве extension-а в plugin.xml файле:
Запустив 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 будет выглядеть вот так:
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
В данный момент конфиг шаблона выглядит так:
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
) : Command()
data class SrcOut(
val modifiers: List
) : Command()
data class ResOut(
val modifiers: List
) : 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-ных шаблонов, мы всё равно создали инструмент для тотальной шаблонизации
- дистрибутив плагина можно скачать в нашем репозитории;
- я буду рад вашим вопросам и постараюсь на них ответить.
Всем успешной автоматизации.