[Groovy & Grails, Разработка под Android, Kotlin, Gradle] Gradle Plugin: Что, зачем и как?

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
30-Июн-2021 17:33


Доброго времени, читатель! В предыдущей статье мы рассмотрели, как эффективно использовать стандартные инструменты Gradle в проектах для решения повседневных задач и немного коснулись подкапотного пространства.Под катом статьи проведём тур по Gradle-плагинам, разберёмся, для чего вы можете писать кастомные плагины уже сейчас, проникнемся процессом разработки на примере реализации плагина для Kotlin-кодогенерации и научимся обходить некоторые Gradle-грабли. В первой части статьи проведём небольшой теоретический экскурс и рассмотрим способы реализации плагинов, а во второй части двинемся к практической задаче и проникнемся разработкой плагина для Kotlin-кодогенерации. Для реализации Gradle-задумок по ходу статьи я буду использовать Kotlin. Заваривайте ваш любимый напиток и поехали.
Краткое введение в Gradle-плагиныИтак, Gradle-плагины представляют собой контейнеры, которые могут содержать в себе настраиваемую логику и различные задачи для сценариев сборки проекта. Плагины полностью автономны и позволяют хранить логику для её повторного использования в нескольких проектах или Gradle-модулях. Они замечательно подходят для любых задач, требующих работы с исходным кодом. Такими задачами могут быть кодогенерация / генерация документации / проверка кода / запуск задач на CI / деплой и многое другое. С точки зрения кода, плагин представляет собой реализацию примитивного интерфейса с единственным методом apply:
class MyPlugin : Plugin<Project> { 
  override fun apply(project: Project) {
    //…
  }
}
Project-ом является Gradle-проект (или Gradle-модуль), куда подключается плагин. Сам по себе интерфейс Project больше похож на God Object, поскольку в нём доступен сразу весь Gradle API, что, в принципе, достаточно удобно. Gradle предлагает разработчикам несколько способов реализации плагинов (как и практически всего в Gradle). Дальше рассмотрим каждый способ по-отдельности и определим его плюсы и минусы. Реализация плагина в build.gradle(.kts)Самый простой вариант – реализовать плагин прямо внутри файла конфигурации. Да, просто берём и пишем класс по примеру выше в build.gradle(.kts). Если честно, трудно понять, какой пользой обладает такое решение, ведь Gradle уже предоставляет возможность обратиться project-у из файла конфигурации и накрутить его нужной логикой. А вот недостатков получаем сразу несколько.Во-первых, подключить такой плагин можно только в текущий скрипт, в связи с чем теряется и суть плагина в возможности его повторного использования. Во-вторых, плагин будет компилироваться каждый раз при конфигурации проекта, что негативно повлияет на производительность сборки. На мой субъективный взгляд, от такого варианта можно отказаться и при необходимости дописать нужную логику прямо в скрипте конфигурации. Давайте перейдём к более жизнеспособным вариантам. Реализация плагина в buildSrcМодуль buildSrc компилируется и поставляется на этап конфигурации проекта в виде jar, поэтому реализовав плагин в buildSrc, получаем возможность его использования в Gradle-модулях проекта. Однако из-за известных проблем c инвалидацией кеша, реализация плагинов в buildSrc имеет право на жизнь в том случае, если для вашего проекта buildSrc представляет собой около-константный модуль, в котором редко происходят изменения.  Также такой вариант также является более компромиссным в сравнении со standalone-плагинами с точки зрения затрат на реализацию и вполне может подойти для небольших проектов.Допустим, такой вариант нам подходит. Выполним подготовительный шаг и подключим в buildSrc плагин Kotlin DSL. Он сделает всю грязную работу по подключению Kotlin, подключит Gradle API и ещё м.
plugins {
  `kotlin-dsl`
}
P.S. Поскольку для .kts скриптов используется embedded-версия Kotlin, которая лежит вместе с Gradle, то самостоятельно подключать свежую версию Kotlin для реализации плагинов не рекомендуется во избежание проблем с обратной совместимостью. На практике проблем не встречал, но потенциально может выстрелить.Кладём реализацию плагина в buildSrc/src/main/kotlin. Охапка дров, плагин готов. Теперь можно подключать плагин в проект с помощью apply
apply<MyPlugin>()
Нагляднее будет подключать плагин c помощью id, поэтому давайте его зададим. Плагин Kotlin DSL транзитивно подключает Java Gradle Plugin Development Plugin, который предоставляет такую возможность:
gradlePlugin { 
  plugins {
    register(“first-plugin”) {
      description = "My first plugin"
      displayName = "Does nothing"
      id = “ru.myorg.demo.my-plugin”
      implementationClass = "ru.myorg.demo.MyPlugin"   
    }
  }
}
В результате будет создан следующий файл:src/main/resources/META-INF/gradle-plugins/ru.myorg.demo.my-plugin.properties 
implementation-class=ru.myorg.demo.MyPlugin
, который Gradle бережно положит в jar и дальше сможет соотносить id плагина с его реализацией. Теперь подключить плагин к проекту можно так:
plugins {
  //...
  id("ru.myorg.demo.my-plugin")
}
Script-плагиныВ Gradle присутствует интересная возможность реализовать плагин в виде скрипта. Скриптовый плагин по структуре идентичен скриптам конфигурации build.gradle(.kts) и может быть реализован как на Groovy, так и на Kotlin. При реализации на Kotlin существует достаточно весомое ограничение – скрипты должны находиться либо в buildSrc, либо в standalone Gradle-проекте, чтобы быть скомпилированными. В случае с Groovy такого ограничения нет, и плагин можно реализовать, например, в рутовой папке проекта, а затем подключить в нужные модули с помощью apply from.Приятным бонусом script-плагинов является автоматическая генерация id. Это сделает за нас Gradle Plugin Development Plugin, исходя из названия скрипта.Важно: При реализации и использовании script-плагинов необходимо учесть следующие ограничения, связанные с версиями Gradle:
  • При реализации script-плагина в отдельном проекте и его подключении через композитную сборку, необходимо использовать Gradle версии 7.1 и выше из-за бага, при котором падала сборка после подключения плагина Kotlin DSL. Во всех проектах, куда подключается плагин с помощью композитной сборки, также необходимо использовать Gradle 7.1 и выше.
  • Script-плагины, написанные на Kotlin, можно подключать в проекты, использующие Gradle 6 и выше.
  • Script-плагины, написанные на Groovy, можно подключать в проекты, использующие Gradle 5 и выше. 
  • Script-плагины можно писать на Kotlin, начиная с Gradle 6.0, и на Groovу, начиная с Gradle 6.4.
Лучше всегда держать версию Gradle в актуальном состоянии и не забивать голову лишними вопросами.Попробуем script-плагин в делеДавайте проведём небольшой эксперимент. Попробуем вынести в script-плагин на Kotlin общую конфигурацию Android-модулей и сделаем это в buildSrc. Подключаем Android Gradle Plugin:
implementation("com.android.tools.build:gradle:$agpVersion")
//4.1.2
Теперь напишем сам плагинbuildSrc/src/main/kotlin/android-common.gradle.kts:
plugins {
  id("com.android.library")
  id("kotlin-android")
}
android {  
  compileSdkVersion(androidCompileSdkVersion)
  buildToolsVersion(androidBuildToolsVersion)
  defaultConfig {
    minSdkVersion(androidMinSdkVersion)
    targetSdkVersion(androidTargetSdkVersion)
  }   
  sourceSets["main"].java.srcDirs("src/main/kotlin")
  sourceSets["test"].java.srcDirs("src/test/kotlin")
}
Жмём Gradle Sync и получаем следующее время конфигурации: BUILD SUCCESSFUL IN 2m 6s
Почему так много? Я начал разбираться и первым делом зашёл в папку buildSrc/build, немного покопался и увидел там следующее:
Оказалось, что для плагинов, которые были объявлены в блоке plugins {} также производится кодогенерация Kotlin-аксессоров для использования в .kts-скриптах. На выходе получаем несколько сотен сгенерированных файлов для плагина com.android.library.Кстати, именно это происходит при первом подключении Kotlin DSL в Gradle-проект, но лишь за тем отличием, что кодогенерация осуществляется внутри .gradle директории на вашем рабочем устройстве. Из этой грустной истории можно сделать вывод, что c подключением сторонних плагинов в script-плагине необходимо быть вдвойне аккуратными, а лучше и вовсе отказаться от этой идеи в пользу классов. Потеря в удобстве, на мой взгляд, незначительная. Попробуем реализовать тот же функционал с помощью класса:
class AndroidLibraryPlugin: Plugin<Project> { 
  override fun apply(target: Project) = target.applyAndroid()
  private fun Project.applyAndroid() { 
    plugins.run {
      apply("com.android.library")
      apply("kotlin-android")
    }
    android {     
    //...
    }
  }
}
В коде выше android {}  будет самописным Kotlin-екстеншном:
fun Project.android(action: BaseExtension.() -> Unit) =
  (extensions["android"] as BaseExtension).run(action)
Получаем следующее время конфигурации: BUILD SUCCESSFUL in 48sи никакого лишнего кодгена. Not bad, not terrible. Замеры проводил на рабочем проекте, поэтому ваши результаты могут отличаться. А чтобы плагин было удобно подключать, генерируем ему idи в buildSrc самостоятельно напишем Kotlin-екстеншн:
val PluginDependenciesSpec.`android-library-common`: PluginDependencySpec
  get() = id("ru.myorg.demo.android-library-common")
Подключаем:
plugins {
  `android-library-common`
}
Получилось ничем не хуже, и такой вариант вполне безопасно использовать. Небольшой, но важный оффтоп про Kotlin-екстеншны для подключения плагиновВ ходе статьи можно заметить, что некоторые плагины удобным образом подключаются с помощью Kotlin-екстеншнов. Дело в том, что они хитро вшиты в сам Gradle, и написать собственный екстеншн получится только при реализации плагина в buildSrc. Если же плагин реализован в standalone-проекте, то при попытке использования самописного екстеншна получаем ошибку компиляции. Проблема актуальна вплоть до Gradle 7.1. Очень ждём, что это будет исправлено будущих релизах. Теперь давайте двинемся дальше и рассмотрим полноценные standalone-плагины.Standalone-плагин на примере кодогенерацииStandalone-плагин подразумевает реализацию в отдельном Gradle-проекте. Такой способ является наиболее предпочтительным, поскольку позволяет избежать всех недостатков buildSrc и script-плагинов. Если вы не хотите создавать отдельный git-репозиторий и планируете использовать плагин внутри одного проекта, то можно воспользоваться возможностью композитной сборки. Планомерно приступим к небольшой практической задаче. Создадим новый проект и подключим всё, что нужно:
plugins { 
  `kotlin-dsl`
}
repositories {
  mavenCentral()
}
dependencies {
  implementation("com.squareup:kotlinpoet:1.7.2")
  implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10")
}
В зависимости добавим Kotlin Poet, с помощью которого будет реализована кодогенерация и Kotlin Gradle Plugin, который поможет интегрировать сгенерированный код в компиляцию JVM-проекта. Теперь давайте определимся с параметрами плагина и с тем, как их передавать.Конфигурация плагинаДля примера в качестве параметра сделаем файл с каким-нибудь сообщением. Это сообщение будет использоваться в качестве значения переменной в сгенерированном классе. Также в параметрах будем передавать рутовую директорию для кодогенерации и название пакета для сгенерированного класса.Для передачи параметров в плагин в Gradle используются extension-контейнеры, которые, по своей сути, ничем не отличаются от обычных классов. В нашем случае extension-контейнер может выглядеть следующим образом:
open class CodegenPluginExtension(project: Project) {
  private val objects = project.objects
  private val packageNameProp: Property<String> = objects.property<String>()
    .convention("ru.myorg.demo")
  private val messageFileProperty = objects.fileProperty() 
  private val outputDirProperty = objects.directoryProperty()
    .convention(project.layout.buildDirectory.dir("src-gen"))
  var messageFile: RegularFile 
      get() = messageFileProperty.get() 
      set(value) = messageFileProperty.set(value)
  var outputDir: Directory   
      get() = outputDirProperty.get()   
      set(value) = outputDirProperty.set(value) 
  var packageName: String   
      get() = packageNameProp.get()   
      set(value) = packageNameProp.set(value)
}
Контейнер должен быть abstract или open классом, поскольку Gradle будет хитро создавать его инстанс с помощью рефлексии. Для хранения параметров вместо стандартных переменных следует использовать специальные Gradle properties. Они обеспечивают оптимизацию потребления памяти благодаря ленивой инициализации и дают возможность сконфигурировать плагин в случае, если входные параметры заранее неизвестны и будут установлены позже (например, после выполнения другой Gradle-таски). Для проперти также можно указать дефолтное значение на случай, если к моменту обращения в ней не лежит никакого значения.Создать и зарегистрировать extension-контейнер можно так:
override fun apply(target: Project) {
  //...
  val extension: CodegenPluginExtension =
  target.extensions.create("simpleCodegen", target)
}
Последним параметром передается аргумент (или аргументы) для конструктора. Идеально, если единственным аргументом будет project, но спроектировать можно как угодно. Созданный extension можно использовать в скриптах конфигурации build.gradle(.kts) следующим образом:
simpleCodegen { 
  //...
}
Теперь самое время создать саму Gradle-таску, которая будет выполнять кодогенерацию:
open class CodegenTask : DefaultTask() { 
  @get:InputFile
  lateinit var messageFile: File 
  @get:Input
  lateinit var packageName: String 
  @get:OutputDirectory 
  lateinit var outputDir: File 
  @TaskAction 
  fun invoke() { 
    val messageProvider = FileMessageProvider(messageFile)
    val simpleFileGenerator = SimpleMessageClassGenerator(messageProvider) 
    simpleFileGenerator.generate(packageName, outputDir) 
  }
}
Аннотацией @TaskAction помечена функция, с которой таска начинает своё выполнение. Геттеры и сеттеры для Kotlin-пропертей помечены специальными Gradle-аннотациями, чтобы таска имела возможность выполняться инкрементально, то есть не выполняться, когда на то нет необходимости. Кому любопытно, полный исходный код доступен на моём Github.Теперь давайте сделаем так, чтобы сгенерированный файл успешно компилировался. Директорию, в которой осуществляется кодогенерация, добавим в основной sourceSet. Таким образом явно объявим, что в директории хранится исходный код, который должен быть скомпилирован.
(target.extensions["sourceSets"] as SourceSetContainer)["main"]
  .java
  .srcDir(extension.outputDir)
Регистрируем кодген-таску, а саму компиляцию вежливо просим её подождать:
with(target.tasks) {
  val codegenTask = register<CodegenTask>("codegenSimpleClass") {
    group = "Code generation"
    description = "Generates Kotlin class with single message property"
    packageName = extension.packageName
    messageFile = extension.messageFile.asFile
    outputDir = extension.outputDir.asFile
  }
  target.tasks.withType<KotlinCompile>() {
    dependsOn(codegenTask)
  }
}
Всё готово к использованию. Получившийся плагин можно изучить под спойлером:Кодген Gradle-плагин
class CodegenPlugin : Plugin<Project> {
  override fun apply(target: Project) {
    val extension = target.extensions.create<CodegenPluginExtension>("simpleCodegen", target)
    with(target.tasks) {
      val codegenTask = register<CodegenTask>("codegenSimpleClass") {
        group = "Code generation"
        description = "Generates simple Kotlin class with single message property"
        packageName = extension.packageName
        messageFile = extension.messageFile.asFile
        outputDir = extension.outputDir.asFile
      }
      target.tasks.withType<KotlinCompile> {
        dependsOn(codegenTask)
      }
    }
    (target.extensions["sourceSets"] as SourceSetContainer)["main"]
      .java
      .srcDir(extension.outputDir)
  }
}
Воспользуемся композитной сборкой, чтобы подключить плагин в проект:settings.gradle.kts:
pluginManagement {
  includeBuild("../standalone-codegen-plugin")
}
и сконфигурируем:
simpleCodegen { 
  messageFile = layout.projectDirectory.file("message.txt") 
  packageName = "ru.myorg.example"
}
Прежде чем нажать на Build, положим в корень проекта файл с сообщением. Собираем проект и видим созданный в build/src-gen созданный класс. 
 Ура! Теперь сгенерированный класс можно использовать в проекте. Самое время протестировать реализованное.Тестирование плагинаВ первую очередь, ребята из Gradle советуют тестировать плагин ручками. То есть, подключаем плагин с помощью композитной сборки к проекту и пользуемся всеми радостями дебаггинга.Например, чтобы отдебажить конфигурацию плагина, выбираем интересующую нас таску из раздела Run Configurations и жмём Debug. Аналогично можно дебажить любые Gradle-таски.
Для автоматического тестирования Gradle-плагина предусмотрено три варианта: функциональные, интеграционные и модульные тесты. С модульными тестами всё стандартно – обкладываемся моками, подключив для этого нужные библиотеки, и проверяем работу реализованных компонентов. Модульный тест отвязан от знания Gradle и проверяет корректность работы какого-либо компонента в изоляции. Для модульных тестов по умолчанию создаётся sourceSet test , поэтому никаких подготовительных шагов не требуется.Для функциональных и интеграционных тестов выполним подготовительный шаг и создадим кастомные sourceSets. Никто не запрещает делать всё в одном сете с модульными тестами, однако подход с раздельными sourceSet позволяет подключать только необходимые для конкретного вида тестов зависимости и в целом изолировать тесты друг от друга. Чтобы запускать тесты из кастомных sourceSet, самостоятельно создадим соответствующие Gradle-таски. Для удобства запуска всех тестов сразу, свяжем их в стандартную таску check. В итоге конфигурация для интеграционных и функциональных тестов будет выглядеть следующим образом:Конфигурация для интеграционных и функциональных тестовbuild.gradle.kts (Gradle-проект с плагином):
val integrationTest: SourceSet by sourceSets.creating
val functionalTest: SourceSet by sourceSets.creating
val integrationTestTask = tasks.register<Test>("integrationTest") { 
  description = "Runs the integration tests."
  group = "verification" 
  testClassesDirs = integrationTest.output.classesDirs 
  classpath = integrationTest.runtimeClasspath 
  mustRunAfter(tasks.test)
}
val functionalTestTask = tasks.register<Test>("functionalTest") { 
  description = "Runs the functional tests." 
  group = "verification" 
  testClassesDirs = functionalTest.output.classesDirs 
  classpath = functionalTest.runtimeClasspath 
  mustRunAfter(tasks.test)
}
tasks.check { 
  dependsOn(integrationTestTask)
  dependsOn(functionalTestTask)
}
dependencies { 
  "integrationTestImplementation"(project) 
  "integrationTestImplementation"("junit:junit:4.12") 
  "functionalTestImplementation"("junit:junit:4.12")
}
Интеграционное тестирование плагинаТеперь приступим к разработке интеграционного теста, в котором проверим взаимодействие плагина с внешней средой, а именно с файловой системой. Здесь мы всё ещё ничего не знаем о Gradle и проверяем работу бизнес-логики. Создаём одноимённую для соответствующего sourceSet директорию в /src и реализуем тест. Интеграционный тестsrc/integrationTest/kotlin/{packageName}:
class SimpleMessageClassGeneratorTest { 
  @get:Rule
  val tempFolder = TemporaryFolder() 
  @Test 
  fun verifyCorrectKotlinFileCreated() {   
    val message = "Hello!"
    val messageProvider = MessageProvider { message }   
    val simpleMessageClassGenerator = SimpleMessageClassGenerator(messageProvider)
    val expectedKotlinClass = "package ru.myorg.demo\n" +
    "\n" +
    "import kotlin.String\n" +       
    "\n" +       
    "public class SimpleClass {\n" +       
    "  public val message: String = "Hello!"\n" +       
    "}\n"   
    simpleMessageClassGenerator.generate(
      packageName = "ru.myorg.demo",
      outputDir = tempFolder.root
    )   
    val generatedFile = File(
      tempFolder.root,
      "/ru/myorg/demo/SimpleClass.kt"
    )   
    assert(
      generatedFile.exists() &&
        generatedFile.readText() == expectedKotlinClass
    )
  }
Функциональное тестирование плагинаТеперь перейдём к самому интересному – функциональным тестам. Они позволяют проверить работу плагина целиком совместно с Gradle и его жизненным циклом. Для этого будем запускать настоящий Gradle Daemon. Конфигурация функциональных тестов практически ничем не отличается от конфигурации интеграционных тестов, за тем исключением, что больше не нужна зависимость на модуль с Gradle-плагином.Чтобы запустить Gradle Daemon, необходимо создать и сконфигурировать Gradle Runner. Для этого добавляем Gradle Runner API в classpath для функциональных тестов следующим образом:
gradlePlugin { 
  //...
  testSourceSets(functionalTest)
}
В тесте эмулируем структуру проекта, конфигурируем Gradle Runner, запускаем и смотрим что получилось. Сам тест лежит под спойлером:Функциональный тестsrc/functionalTest/kotlin/{packageName}:
class CodegenPluginTest {
  @get:Rule
  val tempFolder = TemporaryFolder()
  @Test
  fun canSuccessfullyPrintMessageToFileInProjectDir() {
    /**
     * Готовим сообщение message
     */
    val messageFileName = "message.txt"
    val messageFile = tempFolder.newFile(messageFileName)
    messageFile.bufferedWriter().write("Hello!")
    /**
     * Готовим build.gradle
     */
    val generatedPackageName = "ru.myorg.demo.example"
    val buildGradleFile = tempFolder.newFile("build.gradle.kts")
    buildGradleFile.printWriter()
      .use {
        it.print(
          "plugins {\n" +
              "  kotlin("jvm") version "1.5.10"\n" +
              "  id("ru.myorg.demo.codegen-plugin")\n" +
              "}\n" +
              "\n" +
              "simpleCodegen {\n" +
              "  messageFile = layout.projectDirectory.file("$messageFileName")\n" +
              "  packageName = "$generatedPackageName"\n" +
              "}\n" +
              "\n" +
              "repositories {\n" +
              "  mavenCentral()\n" +
              "}\n" +
              "\n" +
              "dependencies {\n" +
              "  implementation(kotlin("stdlib"))\n" +
              "}"
        )
      }
    /**
     * Запускаем Gradle Daemon и билдим проект.
     */
    GradleRunner.create()
      .withProjectDir(tempFolder.root)
      .withArguments("build")
      .withPluginClasspath()
      .build()
    /**
     * Смотрим, что создалось. Содержимое файла мы уже проверили в интеграционном тесте.
     */
    val outputFile =
      File(
        tempFolder.root,
        "/build/src-gen/" +
          generatedPackageName.replace('.', '/') +
          "/SimpleClass.kt"
      )
    assert(outputFile.exists())
  }
}
Отлично! Плагин протестировали, можно похвалить себя чем-то приятным. Осталось дело за малым – задеплоить плагин.Деплой плагина во внешний репозиторий Для этого воспользуемся плагином Maven Publish. Формируем публикацию и объявляем список репозиториев, в которые она сможет публиковаться:
plugins {
  //...
  `maven-publish`
}
publishing {
  publications {
    create<MavenPublication>("codegen-plugin") {
      artifactId = "codegen-plugin"
      groupId = "ru.myorg.demo"
      version = "1.0.0"
      from(components["kotlin"])
    }
  }
  repositories {
    maven {
      name = "remote"
      url = uri("https://some-remote-repo")
      credentials {
        username = project.ext["MY_REMOTE_REPO_USERNAME"] as String
        password = project.ext["MY_REMOTE_REPO_PASSWORD"] as String
      }
    }
  }
}
ИтогиGradle-плагины представляют собой действительно мощный инструмент для дополнения логики сборки необходимыми для вас задачами. К сожалению, в Gradle API по-прежнему много загадок и непонятных проблем, в том числе не до конца раскрытых в документации. Это создаёт препятствия на пути к удобству использования, однако команда Gradle над этим активно работает.На рабочих проектах мы широко используем Gradle-плагины для хранения общей логики сборки, выполнения специфической кодогенерации, а также выполнения различных инфраструктурных задач на CI. А какие задачи решаете вы с помощью Gradle-плагинов? Напишите в комментариях. Также я открыт к любым обсуждениям по материалу, буду рад замечаниям и предложениям.Всё изложенное в статье доступно на Github.Ниже представлю небольшой список opensource-плагинов, в исходниках которых можно подчерпнуть идеи для реализации: Спасибо за внимание!
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_groovy_&_grails, #_razrabotka_pod_android (Разработка под Android), #_kotlin, #_gradle, #_gradle, #_plugin, #_kotlin, #_sborka (сборка), #_android, #_blog_kompanii_yota (
Блог компании Yota
)
, #_groovy_&_grails, #_razrabotka_pod_android (
Разработка под Android
)
, #_kotlin, #_gradle
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 14:25
Часовой пояс: UTC + 5