Привет, Хабр! Меня зовут Алексей, я ведущий разработчик платформенной команды и по совместительству лид архитектурной компетенции в проекте Альфа-Бизнес. Сегодня я расскажу, как можно автоматизировать повторяющуюся работу в андроид-разработке при помощи плагина для Android Studio.

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

Также мы привыкли автоматизировать свою собственную работу. Инструменты CI/CD позволяют быстрее доставлять ценность клиентам, чем ручное развертывание, линты помогают уменьшить время, которое мы тратим на прохождение задачи код ревью. 

В процессе написания кода тоже встречается много рутинной работы. Это может быть какой-то шаблонный код для написания тестов, например, создание моков и прокидывание в конструктор при создании экземпляра тестируемого класса или добавление нового экрана. Если вы используете подход MVI, что очень актуально для андроид-разработки, то сталкивались с тем, что для каждого экрана приходится создавать множество отдельных файлов с шаблонным кодом для редьюсера, экзекутора, стейта, экшены, сами файлы экрана, и это тоже хотелось бы делать «в один клик».

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

Задача

Сначала определимся со структурой файлов, которые мы хотим генерировать при создании модулей. Для проекта Альфа-Бизнес в каждой фиче нужно создавать два модуля с постфиксами -api и -impl. 

  • В api из дефолтного нам нужен класс-медиатор запуска флоуфрагмента фичи, реализация его будет в модуле impl.

  • Из основного и самого трудозатратно шаблонного кода нам нужно создать классы для di, они располагаются в отдельном пакете. 

  • На слое presentation у нас лежит два пакета view, в котором расположен FlowFragment — это фрагмент, который хостит все другие фрагменты фичи, а навигация лежит в пакете navigation. 

  • Можно также создать файлы для репозитория и api, так как они требуются практически в каждой фиче. 

У меня получилась такая структура модулей, которую мы хотим сгенерировать автоматически.

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

Настройка проекта

Для создания плагина нужно использовать IntelliJ IDEA, подойдет как enterprice так и community edition. Первое, что нам нужно сделать — это создать проект. Для этого выбираем new project -> IDE plugin, вводим название и нажимаем create.

После того как IDE создаст необходимые файлы с зависимостями, мы увидим следующую структуру файлов.

Первое, на что тут стоит обратить внимание, — это файл plugin.xml. В нём содержится необходимая метаинформация о вашем плагине. Подробнее о том, что можно указать в plugin.xml можно прочитать в документации

После того как уберём лишние строки и заполним необходимые на данном этапе поля, в файле останется такая структура.

<idea-plugin>
   <id>com.example.plugin</id>
   <name>TestPlugin</name>
   <vendor email="alexey.minay.dev@gmail.com">TestPluginVendor</vendor>
   <depends>com.intellij.modules.platform</depends>
   <depends>org.jetbrains.android</depends>
   <extensions defaultExtensionNs="com.intellij">
   </extensions>
</idea-plugin>

После откроем файл build.gradle.kts и настроим его для создания плагина для Android Studio.

intellij {
   version.set("2022.3.1.22")
   type.set("AI") // Target IDE Platform

   plugins.set(listOf("org.jetbrains.android"))
}

Список актуальных версий можно взять по ссылке.

После всех настроек синхронизируем проект и можно приступать к написанию плагина.

Предоставление плагина в Android Studio

Первое, что нам нужно сделать, так это добавить класс, который будет наследником абстрактного класса WizardTemplateProvider. В нём нужно будет переопределить метод getTemplate и предоставить туда все наши шаблоны. В нашем случае это будет moduleTemplate, который мы напишем позже.

class MyWizardTemplateProvider : WizardTemplateProvider() {
   override fun getTemplates(): List<Template> {
       return listOf(
           moduleTemplate
       )
   }
}

Теперь добавим этот провайдер в plugin.xml.

<extensions defaultExtensionNs="com.android.tools.idea.wizard.template">
   <wizardTemplateProvider implementation="com.example.plugin.MyWizardTemplateProvider"/>
</extensions>

Создание файла Template

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

val moduleTemplate
   get() = template {
       name = "Фиче модули -api и -impl"
       minApi = 24
       description = "Создание фичемодулей"

       category = Category.Folder
       formFactor = FormFactor.Mobile
       useGenericAndroidTests = false
       useGenericLocalTests = false

       screens = listOf(
           WizardUiContext.ActivityGallery,
           WizardUiContext.MenuEntry,
           WizardUiContext.NewModule
       )

       recipe = { data: TemplateData ->

       }
   }

Если мы запустим таску runIde, запустится новый инстанс Android Studio, в которой можно будет создать новый проект. При создании модуля можно будет увидеть наш шаблон.

Теперь приступим к созданию всех необходимых параметров, которые будут отображаться при выборе шаблона. Опишу для примера пару из них. Первым делом создаём строковую константу, которую будем конкатенировать к названиям файлов. Для этого используем stringParameter, в котором зададим имя шаблона.

val classPrefix = stringParameter {
   name = "Введите префикс для классов модуля"
   default = ""
}

val withDiFilesParam = booleanParameter {
   name = "Добавить классы di"
   default = true
}

val withDiSharedModuleParam = booleanParameter {
   name = "Создать SharedModule di"
   default = true
   enabled = { withDiFilesParam.value }
}

WithDiFilesParam — чекбокс: если пользователь плагина выбирает чекбокс, мы будем создавать шаблоны классов di. 

WithDiSharedModuleParam пригодится в случае, если наша фича не предоставляет наружу никаких интерфейсов. Я её сюда добавил, чтобы показать, как сделать чекбокс зависимым от других чекбоксов. В поле enabled мы передаем значение чекбокса withDiFilesParam. Остальные параметры создаются по похожему принципу. 

Теперь создадим чекбоксы в UI.

widgets(
   TextFieldWidget(classPrefix),
   Separator,
   CheckBoxWidget(withDiFilesParam),
   CheckBoxWidget(withDiSharedModuleParam),
   CheckBoxWidget(withFragmentFlow),
   CheckBoxWidget(withMediatorParam),
   CheckBoxWidget(withNavigation),
   CheckBoxWidget(withDataLayer),
)

Создание файла Recipe

Api создание шаблонов сильно ограничено и может не подходить для каких-то нетипичных задач. Например, не получится создать сразу два модуля. Можно добавить два отдельных шаблона для модуля api и impl, но это опять лишнее действие, от которого хочется избавить разработчиков. 

Ещё одна проблема связана с использованием version catalog в проекте. Зависимости вида implementation (libs.androidx.ktx) не получится добавить в проект, используя api темплейтов. Чтобы избежать этих проблем, можно воспользоваться api файловой системой, и создавать все файлы вручную.

fun RecipeExecutor.createModuleFiles(
   moduleData: ModuleTemplateData,
   withDiFiles: Boolean,
   classPrefix: String,
   withDiSharedModule: Boolean,
) {
   val moduleName = moduleData.name.split(":").last()

   val packageName = moduleData.packageName
   val lastPackage = packageName.split(".").last()
   val apiPackage = packageName.replace(lastPackage, "api.$lastPackage")

   val apiRootPath = moduleData.rootDir.absolutePath
       .replaceFirst(moduleName, "$moduleName-api")
   val apiSrcPath = moduleData.srcDir.absolutePath.replaceFirst(moduleName, "$moduleName-api")
       .replace("feature${File.separator}$lastPackage", "feature${File.separator}api${File.separator}$lastPackage")
       .replace("java", "kotlin")

   val implRootPath = moduleData.rootDir.absolutePath
       .replaceFirst(moduleName, "$moduleName-impl")
   val implSrcPath = moduleData.srcDir.absolutePath
       .replace("java", "kotlin")
       .replaceFirst(moduleName, "$moduleName-impl")

   moduleData.removeFiles()

   println(moduleData.projectTemplateData)

   this.addIncludeToSettings("$moduleName-api")
   this.addIncludeToSettings("$moduleName-impl")

   createBuildGradle(
       implRootPath = implRootPath,
       apiRootPath = apiRootPath,
       lastPackage = lastPackage,
       hasDi = withDiFiles,
       moduleName = moduleName,
       packageName = packageName
   )

   if (withDiFiles) {
       createDiFiles(
           srcPath = implSrcPath,
           packageName = moduleData.packageName,
           classPrefix = classPrefix,
           withDiSharedModule = withDiSharedModule,
       )
   }
}

Чтобы не засорять статью лишним кодом, я описал создание только части кода, которая нам была необходима, это файлы гредл и di.

В этом коде находим все пути, по которым нужно будет создавать файлы для наших модулей и потом просто удаляем всё, что генерируется автоматически, в том числе файлы build.gradle, так как их будем создавать сами с нуля. Также добавляем в settings.gradle новые модули. Код удаления файлов выглядит так:

private fun ModuleTemplateData.removeFiles() {
   resDir.deleteRecursivelyOrThrow()
   manifestDir.deleteRecursivelyOrThrow()
   rootDir.resolve("build.gradle").delete()
   rootDir.resolve("build.gradle.kts").delete()
   rootDir.resolve("proguard-rules.pro").delete()
   rootDir.resolve("libs").delete()
   rootDir.deleteRecursivelyOrThrow()
}

createBuildGradle создаст файлы build.gradle.kts для api и impl модулей.

 fun RecipeExecutor.createBuildGradle(
   implRootPath: String,
   apiRootPath: String,
   lastPackage: String,
   moduleName: String,
   hasDi: Boolean,
   packageName: String
) {
   saveFile(
       absolutePath = implRootPath,
       relative = "build.gradle.kts",
       content = getBuildGradleTemplate(
           lastPackage = lastPackage,
           hasDi = hasDi,
           moduleName = moduleName,
           packageName = packageName
       )
   )

   saveFile(
       absolutePath = apiRootPath,
       relative = "build.gradle.kts",
       content = getApiBuildGradleTemplate(
           packageName = packageName
       )
   )
}

saveFile — утилитарная функция-расширение, необходимая для более удобного сохранения файлов. Её реализацию можно посмотреть в репозитории.

getBuildGradleTemplate — функция, возвращающая шаблон, который мы хотим записать в build.gradle.kts. Его содержание будет сильно зависеть от плагинов и зависимостей, которые используются на проекте. Так как у нас упрощенный пример, я сделал простую версию этого файла.

fun getBuildGradleTemplate(
    packageName: String,
    lastPackage: String,
    hasDi: Boolean,
    moduleName: String
): String {
    val prefix = lastPackage.lowercase()

    return """
        plugins {
            id("com.android.application")
            id("org.jetbrains.kotlin.android")
            ${"id(\"kotlin-kapt\")".appendIf(hasDi)}
        }

        android {
            compileSdk = 34

            resourcePrefix = "${prefix}_"
            namespace = "$packageName"
            
            defaultConfig {
                minSdk = 24
                targetSdk = 34
            }

            compileOptions {
                sourceCompatibility = JavaVersion.VERSION_1_8
                targetCompatibility = JavaVersion.VERSION_1_8
            }
            kotlinOptions {
                jvmTarget = "1.8"
            }
            buildFeatures {
                viewBinding = true
            }
        }

        dependencies {
            implementation(project(":${moduleName}-api"))
            ${"implementation(\"com.google.dagger:dagger:2.45\")".appendIf(hasDi)}
            ${"kapt(\"com.google.dagger:dagger-compiler:2.45\")".appendIf(hasDi)}
            implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
        }
    """.trimIndent()
}

Дальше создаём файлы для di. Нам понадобится значение чекбоксов, который мы задали при создании template.

if (withDiFiles) {
   createDiFiles(
       srcPath = implSrcPath,
       packageName = moduleData.packageName,
       classPrefix = classPrefix,
       withDiSharedModule = withDiSharedModule,
   )
}

Пример функции для создания компонента.

fun getDiComponentTemplate(
   diPackageName: String,
   classPrefix: String,
): String {
   return """
   package $diPackageName
  
   import dagger.Component
  
   @Component(
       dependencies = [${classPrefix}ComponentDependencies::class],
       modules = [${classPrefix}Module::class]
   )
   interface ${classPrefix}Component {
      
       @Component.Factory
       interface Factory {
           fun create(
               dependencies: ${classPrefix}ComponentDependencies,
           ): ${classPrefix}Component
       }
      
   }
""".trimIndent()
}

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

Установка плагина в Android Studio

Для начала запустим таску jar. 

После этого в каталоге build/libs появится jar файл с собранным плагином. 

Чтобы добавить плагин в Android Studio, выбираем file -> settings -> Plugins и выбираем Install Plugin from Disk.

На этом всё, теперь шаблоны можно создавать в пару кликов.


Мы рассмотрели наглядно, как можно создать свой шаблон модулей используя api wizard template, проблемы с которыми придется столкнуться (в основном это ограниченный функционал api) и как можно их обойти. Я описал не весь функционал, который нам нужно было покрыть для нужд проекта Альфа-Бизнес: мы не добавляли создание шаблонов медиаторов, навигаторов, репозиториев, андроид манифестов, но этого хватит для понимания принципа создания плагина и любой читатель сможет его доработать под свои запросы. Репозиторий в котором можно посмотреть полностью код из статьи.


Статьи, которые могут быть интересны:

Подписывайтесь на Телеграм-канал Alfa Digital. Рассказываем о работе в IT и Digital, делимся полезными советами, новостями, видео с митапов, краткими выжимками из статей и многим другим, иногда шутим.

Подписывайтесь на блог Альфа-Банка на Хабре, впереди много хороших статей.