Автоматизация версионирования в Kotlin Multiplatform: Решение для Android и iOS

от автора

Привет! Меня зовут Антон, я Android-разработчик. Недавно у меня появилась идея создать приложение, которое в будущем можно будет опубликовать в сторы. С самого начала я знал, что хочу, чтобы оно работало сразу на двух платформах — iOS и Android.

Передо мной стояло два пути: погрузиться в нативную разработку для iOS или воспользоваться кроссплатформенной технологией. Первый вариант, безусловно, интересен, но требует слишком много времени на освоение. А вот с кроссплатформенной разработкой у меня уже был опыт, поэтому решение далось легко — я выбрал Kotlin Multiplatform (KMP).

На одном из этапов разработки я задумался: как лучше организовать версионирование проекта, если почти весь код находится в shared-модуле? Мне нужно было задать версию приложения в коде, например, для механизма force update, но без дублирования значений в shared-коде, Gradle (на Android) и Info.plist (на iOS). Такой разнобой чреват ошибками — человеческий фактор никто не отменял. Конечно, можно делегировать эту задачу CI, но мне хотелось простого решения: изменил один раз — и все работает.

Мой pet-проект написан на Compose Multiplatform, что делает его практически независимым от платформ. Мне нравится концепция «написал один раз — запустил везде». Однако, изучая материалы по теме, я не нашел готового решения, которое бы полностью меня устроило. Встречались похожие подходы, но у каждого были свои недостатки. Поэтому, вдохновившись чужими наработками, я решил создать универсальное решение для обеих платформ.

Итак, начнем с того, что мы укажем в нашем gradle.properties файла версию

version=1.0.0

Затем мы создадим buildSrc «модуль», в котором будет храниться kotlin код, который мы сможем использовать в gradle-файлах различных модулей нашего проекта

plugins {     `kotlin-dsl` }  repositories {     google()     mavenCentral() }

Теперь мы можем создать файл с котлин функциями, чтобы переиспользовать их в различных build.gradle.kts файлах.

Расположите ваш класс по пути buildSrc/src/main/kotlin/.../VersionUtils.kt

Мой код, который я буду в дальнейшем использовать выглядит следующим образом.

import org.gradle.api.Project  fun Project.getAppVersionString(): String {     val (major, minor, patch) = getSanitizedVersionParts()     return "$major.$minor.$patch" }  fun Project.getNumericAppVersion(): Int {     val (major, minor, patch) = getSanitizedVersionParts()     return major * 1_000_000 + minor * 1_000 + patch }  private fun Project.getSanitizedVersionParts(): Triple<Int, Int, Int> {     val sanitizedVersionString = project.version.toString().let { appVersionString ->         appVersionString.indexOfOrNull("-")?.let { indexOfFirstDash ->             appVersionString.substring(0, indexOfFirstDash)         } ?: appVersionString     }      return sanitizedVersionString         .split('.')         .take(3)         .map { it.toInt() }         .let { Triple(it[0], it[1], it[2]) } }  private fun String.indexOfOrNull(substring: String): Int? {     val index = indexOf(substring)     return if (index >= 0) index else null }

Здесь мы извлекаем версию из gradle.properties и преобразуем ее как в числовой, так и в строковый формат. Теперь у нас есть возможность получать версию прямо в kts-файлах. Но как использовать ее непосредственно в коде? На этом этапе мы можем просто сгенерировать Kotlin-класс. Для этого создадим Gradle-таску, которая автоматически будет генерировать этот класс. Не забудьте импортировать ваши getNumericAppVersion и getAppVersionString.

tasks.register("generateVersionConfig") {     val configFile =         file(project.rootDir.toString() + "/shared/src/commonMain/kotlin/.../VersionConfig.kt")     outputs.file(configFile)     val content = """             // Не менять файл, он сгенерирован с помощью таски generateVersionConfig             object VersionConfig {                 const val VERSION_CODE = ${getNumericAppVersion()}                 const val VERSION_NAME = "${getAppVersionString()}"             }      """.trimIndent()      outputs.upToDateWhen {         configFile.takeIf { it.exists() }?.readText() == content     }     doLast {         configFile.writeText(content)     } }

Теперь в общем модуле у нас есть возможность получать версию приложения. Давайте разберемся, как это реализовать на нативных платформах, начиная с Android. Тут все довольно просто: открываем build.gradle.kts и обновляем versionCode и versionName. Не забываем про корректные импорты!

versionCode = getNumericAppVersion() versionName = getAppVersionString()

Но с IOS будет слегка сложнее. Тут я снова воспользовался возможностью генерацией файлов в gradle-таске

tasks.register("generateXcodeVersionConfig") {     val configFile =         file(project.rootDir.toString() + "/iosApp/Versions.xcconfig")     outputs.file(configFile)     val content = """         BUNDLE_VERSION=${getNumericAppVersion()}         BUNDLE_SHORT_VERSION_STRING=${getAppVersionString()}     """.trimIndent()      outputs.upToDateWhen {         configFile.takeIf { it.exists() }?.readText() == content     }     doLast {         configFile.writeText(content)     } }

Теперь идем в IOS проект, в файлик-конфигурации Info.plist и меняем CFBundleShortVersionString и CFBundleVersion

<key>CFBundleShortVersionString</key> <string>$(BUNDLE_SHORT_VERSION_STRING)</string> <key>CFBundleVersion</key> <string>$(BUNDLE_VERSION)</string>

Не забудьте добавить ссылку на Versions.xcconfig и сам файл Versions.xcconfig в iosApp.xcodeproj в секции Configurations как «Based on Configuration File».

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

Чтобы гарантировать запуск этих тасок перед сборкой на Android, можно добавить следующий код:

tasks.named("preBuild") {     dependsOn("generateXcodeVersionConfig")     dependsOn("generateVersionConfig") }

И такой для IOS

tasks.matching { it.name.startsWith("compileKotlinIos") }.configureEach {     dependsOn("generateXcodeVersionConfig")     dependsOn("generateVersionConfig") }

Понимаю, что для Android генерация Xcode-версии не обязательна, но так как этот процесс занимает считаные миллисекунды, я решил оставить его — лишним не будет.

Чтобы упростить и оптимизировать build.gradle.kts, я вынес всю логику в отдельный файл versions-tasks.gradle.kts и просто подключаю его в основном build.gradle.kts. Это делает код чище и упрощает поддержку.

apply(from = "versions-tasks.gradle.kts")

Полное содержимое versions-tasks.gradle.kts у меня вышло таким

import getAppVersionString import getNumericAppVersion  tasks.named("preBuild") {     dependsOn("generateXcodeVersionConfig")     dependsOn("generateVersionConfig") }  tasks.matching { it.name.startsWith("compileKotlinIos") }.configureEach {     dependsOn("generateXcodeVersionConfig")     dependsOn("generateVersionConfig") }  tasks.register("generateXcodeVersionConfig") {     val configFile =         file(project.rootDir.toString() + "/iosApp/Versions.xcconfig")     outputs.file(configFile)     val content = """         BUNDLE_VERSION=${getNumericAppVersion()}         BUNDLE_SHORT_VERSION_STRING=${getAppVersionString()}     """.trimIndent()      outputs.upToDateWhen {         configFile.takeIf { it.exists() }?.readText() == content     }     doLast {         configFile.writeText(content)     } }  tasks.register("generateVersionConfig") {     val configFile =         file(project.rootDir.toString() + "/shared/src/commonMain/kotlin/.../VersionConfig.kt")     outputs.file(configFile)     val content = """               // Не менять файл, он сгенерирован с помощью таски generateVersionConfig             object VersionConfig {                 const val VERSION_CODE = ${getNumericAppVersion()}                 const val VERSION_NAME = "${getAppVersionString()}"             }      """.trimIndent()      outputs.upToDateWhen {         configFile.takeIf { it.exists() }?.readText() == content     }     doLast {         configFile.writeText(content)     } }

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

Конечно, у такого подхода есть нюанс: обе платформы будут иметь одинаковую версию, что не совсем корректно с точки зрения их независимого обновления. Однако, поскольку у меня почти весь код общий, я решил этим пренебречь.

Использовать такой метод или нет — выбор за вами. Спасибо за прочтение!

Код примера можно найти здесь: GitHub.

Это моя первая статья, поэтому буду рад любым отзывам и предложениям!


ссылка на оригинал статьи https://habr.com/ru/articles/895362/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *