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

01f9315fd14c9065e16d0f5b129e2a05

Привет! Меня зовут Антон, я 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 {
    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

	CFBundleShortVersionString
	$(BUNDLE_SHORT_VERSION_STRING)
	CFBundleVersion
	$(BUNDLE_VERSION)

Не забудьте добавить ссылку на 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.

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

© Habrahabr.ru