Конвейер готовится к поставке.

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

В жизни мобильного разработчика может настать день, когда в зоне его ответственности, кроме задач по верстке экранов и походов в сеть, появляется работа с выстраиванием релизных процессов, инфраструктурой и непрерывной поставкой (CI/CD). Когда мне первый раз предложили взять роль релиз-инженера, я не знал, что мне нужно делать. У меня не было четкого понимания, как работать с версионированием приложения, и как оно связано с релизным циклом. Как правило, вопросами поставки занимаются отдельные люди в команде, в то время как большинство пишет код - так было и у нас.

В этом посте я постарался систематизировать свои знания, связанные с процессами поставки мобильных приложений, а также поделиться практическими рекомендациями.

Большинство из них будет связано с нативной Android разработкой, но основная информация актуальна для разработки мобильного приложения на любом языке и платформе.

Disclaimer: Пост полезен тем, кто впервые попал на проект, в котором поставка работает на непрерывном потоке, и еще больше тем, кто впервые начинает новый проект и хочет поставить на рельсы “релизный поезд” с учетом будущего масштабирования.

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

Версионирование

Большинство Android разработчиков слышали про versionCode и versionName (для iOS - Version и Build), часть из них точно помнит, в чем их отличие, и только часть из них знает, когда и по какому правилу их менять. Давайте разбираться.

Как известно, у любого программного продукта есть версия. Сегодня практикуется так называемое семантическое версионирование. Наверняка вы видели название версии в формате Major.Minor.Patch. Рассмотрим что означает каждая цифра в классическом описании (которое более справедливо для разработки backend).

  • Major. Увеличивается, когда сделаны обратно-несовместимые изменения API.

  • Minor. Увеличивается, когда сделаны обратно-совместимые изменения API.

  • Patch. Увеличивается, когда вливаются исправления багов.


В общем случае мобильные приложения не предназначены для того чтобы предоставлять API, зато они часто зависят от какого-то базового API. В таких случаях релизный цикл приложения может быть завязан на общий релизный цикл продукта.

Однако приложение может не зависеть от стороннего API. Либо продуктом команды может быть библиотека. Тогда справедлив такой подход:

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

  • Minor. Увеличивается, когда добавлены изменения, условно не отменяющие, а дополняющие существующий функционал. Для библиотеки увеличивается при доработках с учетом обратной совместимости.

  • Patch. Увеличивается, когда вливаются исправления багов.

Важное отличие Patch от Major и Minor - увеличения Patch версии заранее не запланированы, они выпускаются вдогонку, когда уже состоялся релиз Major.Minor.0. Другими словами, Patch поднимается в релизной ветке, а не в основной (про ветвление поговорим ниже).

Особенности в Android:

Android Gradle Plugin дает возможность работать с версионированием через versionCode (int) и versionName (string)

VersionName нужен, чтобы пользователь/тестировщик отличал версию приложения; versionCode - чтобы версию сборки приложения отличала ОС и Google Play. Каждая новая версия должна содержать versionCode выше предыдущего. Это позволит Google Play предлагать более свежую сборку, а системе запретить (или предупредить) установку более старой версии вместо новой.

Фактически нет связи между этими параметрами - вы можете как угодно повышать первый и заполнять второй.

Мне нравится следующая формула, позволяющая не ломать голову над связью параметров и значением versionCode (трудности могут начаться, когда помимо основной git-ветки будут параллельно жить релизные). Его ограничение в допустимых значениях, введенное для избежания коллизий versionCode: значения minor и patch - [0, 99). Приводится фрагмент скрипта из build.gradle.kts в директории app-модуля:

/* Допустимые значения major - [0,21000); minor и patch - [0, 99) */
val major = 3
val minor = 7
val patch = 0

defaultConfig {
  versionCode = major * 10000 + minor * 100 + patch
  versionName = "${major}.${minor}.${patch}"
}


Релизный цикл и модели ветвления

Чтобы понимать последовательность действий, необходимых  для доведения своего кода до пользователя, разработчику, в первую очередь нужно понимать, какая модель ветвления используется в команде. Модель ветвления отвечает на вопрос “что и когда делать”: когда и откуда отводить рабочие ветки, когда создавать merge request, как делать hotfix; для каких веток настраивать процесс CD (continuous delivery).

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

Самые известные модели на сегодня это Gitflow, Feature Branch Workflow и Trunk-Based Development. Рассмотрим их отличия в упрощенном варианте (опущены моменты работы с release-ветками).

Git-flow - подразумевает наличие master и develop ветки + долгоживущих feature-веток. В ветке master хранится история релизов, а ветка develop служит для интеграции новых фич. Релизные ветки отводятся от develop и по окончанию релиза вливаются в master.

Feature Branch Workflow - подразумевает наличие stable-ветки + долгоживущих feature-веток, над которыми может трудиться несколько разработчиков. По мере готовности feature-ветка вливается в stable. Тестировщики работают со сборками из feature веток и разрешают вливать feature-ветку в stable. Релизные ветки отводятся от stable ветки.
Этот же подход часто называют Github flow, что приводит к путанице с описанным выше Git-flow. Подробнее тут.

Trunk-Based Development - подразумевает наличие stable-ветки (trunk) + коротко-живущих feature-веток, разрабатываемых как правило одним разработчиком и содержащих атомарные изменения. Тестировщики не влияют на влитие feature-веток и работают только со сборками из trunk.

Количество stable-веток

Срок жизни feature-веток

Когда вливается feature-ветка

Feature Branch Workflow 

1

Длинный

После завершения разработки и тестирования

Git-flow

2 (master и develop)

Trunk-Based Development

1

Короткий (меньше 1 дня)

После каждой “атомарной” итерации

Последний подход также предполагает обязательное наличие фича-тоглов (feature-toggle).

Feature-toggle - это флаг (в самом простом виде boolean) конфигурации приложения, скрывающий не готовый к релизу функционал. Они могут быть захардкожены, храниться в памяти устройства или прилетать из облака. Обычно во внутренних debug сборках добавляется технический экран, из которого можно включать фича-тоглы.

Пример экрана управления feature-toggles

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

Я считаю наиболее подходящим для мобильной разработки подход с использованием с Trunk-Based Development. У этого подхода есть два весомых преимущества перед другими: он простой, как табуретка: в любой ситуации нужно соблюдать только одно правило - в trunk-ветке всегда должен быть самый свежий и актуальный код (все фиксы сначала вливаются в trunk); из-за короткой жизни feature-ветки почти забываются долгие разруливания merge-conflict-ов при вливании feature-ветки в trunk. Но перечисленные плюсы рождают недостатки: повышенные требования к качеству и безопасности вливаемого кода, так как влитые “ломающие” изменения будут тормозить работу всем; необходимость выделять ресурсы на внедрение и использование фича-тоглов, особенно в начале пути.

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

А про связку trunk-based-development и фича-тоглов можно послушать в видео от коллег из QIWI.

Этапы релизного цикла и что делает релиз-инженер

Я начинал пост с того, что часто первое назначение разработчика релиз-инженером может ввести его в недоумение.

Хорошая новость в том, что никаких специальных знаний для релиз-инженерства не нужно - достаточно уметь работать с git. Плохая новость в том, что нужно следить и управлять тем, когда один этап переходит в другой (рядовой разработчик обычно избавлен от этого).

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

  • Планирование
    Тут все понятно: прежде чем делать, команде нужно иметь понимание, что делать. Я бы включил в этот этап написание аналитики и изучение документации, а также подготовку дизайна и вообще все, что идет перед следующим этапом.

  • Разработка + тестирование
    Включил в один этап, так как тестирование может идти параллельно с разработкой.

  • Фича-фриз и включение фича-тоглов
    Останавливается разработка нового функционала. Релизный инженер отводит релизную ветку, включает фича-тоглы выпускаемого условно-готового протестированного функционала. Через инструменты CD (continuous delivery) происходит сборка и раскатка релиз-кандидата на тестировщиков.

  • Регресс и исправление багов
    Тестируется весь функционал, который мог быть затронут в релизе. В случае обнаружении ошибок разработчики исправляют ошибки (bug fixing), но не добавляют новый функционал. MR с исправлением ошибок сначала вливается в trunk и только потом делается cherry-pick в релизную ветку (правило “код в trunk самый актуальный”). После каждого исправления через инструменты CD (continuous delivery) происходит сборка и раскатка обновленного релиз-кандидата на тестировщиков.

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

Более полное видео про так называемый “релизный поезд” сделали те же коллеги из hh.

Релизы и версионирование руками разработчика

На схеме ниже я попытался привести пример общей схемы работы с релизными ветками и версиями с использованием модели Trunk-Based Development.

Вариант версионирования в Trunk-Based Development.


Здесь видим известные Android-разработчикам логотипы - Google Play и Firebase. Обычно приходится работать с обоими сервисами или их аналогами. Первый, как известно, распространяет новые версии приложения пользователям, а также позволяет проводить beta-тестирование на ограниченном круге пользователей. Второй (а точнее Firebase App Distribution) позволяет распространять промежуточные сборки для внутреннего тестирования.

Сборки в Firebase App Distribution поставляются как можно чаще - все заинтересованы в том, чтобы новый функционал получил обратную связь как можно раньше. Сборки в Google Play, наоборот, попадают в идеале один раз в релиз или чуть больше, если пришлось срочно исправлять критическую ошибку (hot fixing).

Обратите внимание на изменения версий в примере: minor версия поднимается в момент отведения релизной ветки; patch версия поднимается после каждого исправления (или набора исправлений) в релизной ветке. В разных компаниях это может отличаться, но этот подход кажется мне наиболее логичным и соответствующим семантическому версионированию. Сборки конфигурируются через build variants, которые являются комбинациями build flavors и build types. Через них можно задавать и комбинировать различные константы, правила сборки и подписи  в зависимости от назначения сборки. Подробнее о них можно почитать в документации

Зачем подписывать приложения и что нужно помнить?

Любая сборка для Android, apk или app-bundle имеет электронную подпись (даже если вы просто нажимаете на зеленый треугольник в  Android Studio - в таком случае используются ключи подписи по-умолчанию, сгенерированные на вашем устройстве). Подпись - это неотъемлемая часть безопасности ОС для пользователя. Например, с помощью нее устройство не позволит установить приложение с тем же package name, но другой подписью (без отдельного разрешения от пользователя). Здесь мы не будем подробно раскрывать этот вопрос, почитать можно также в документации.
Но всем нужно понимать важность безопасного хранения ключей подписи для production сборок и паролей к ним - они не должны храниться и генерироваться локально у кого-либо (да, в том числе у тимлида). Желательно генерировать их в облаке и хранить там же в зашифрованном виде, соответственно, релизные сборки могут собираться и подписываться только в облаке.

Как сделать, чтобы при сборке в приложение не попадали лишние константы и библиотеки?

Нужно следить за тем, чтобы в сборку попадало только то, что для нее нужно. К примеру, любопытствующему, исследующему обфусцированный код вашего production приложения, ни к чему показывать адреса develop-стендов вашей компании.

flavorDimensions("sign")
productFlavors {

    /**
     * Строки в buildConfigField должны быть обернуты в кавычки
     */
    fun buildConfigFieldStringValue(source: String): String = "\"$source\""

    create("dev") {
        isDefault = true
        // разные подписи для dev и prod сборок
        dimension = "sign"
        signingConfig = signingConfigs.findByName("dev")

        buildConfigField(
            "String",
            "BACKEND_URL",
            buildConfigFieldStringValue("https://mybackend-dev.url")
        )
    }

    create("prod") {
        // разные подписи для dev и prod сборок
        dimension = "sign"
        signingConfig = signingConfigs.findByName("prod")

        buildConfigField(
            "String",
            "BACKEND_URL",
            buildConfigFieldStringValue("https://mybackend-prod.url")
        )
    }
}

Таким образом, во время сборки будет сгенерирована строковая константа BACKEND_URL с правильным значением (скрипты для запуска сборки приведены ниже).

Это же относится к библиотекам. Например, если ваше приложение будет распространяться через несколько экосистем и потребует разных зависимостей для разных типов сборок. В примере показано, как “подсунуть” разные имплементации библиотек приема push-уведомлений в сборки для Google и Huawei (рекомендую делать сразу так, это избавит вас от переписывания, когда до вас сверху дойдет решение публиковаться в новые магазины).

// app/build.gradle.kts
// Создается новый flavorDimension, отвечающий за провайдера сервисов
flavorDimensions("sign", "provider")
productFlavors {

    create("google") {
        dimension = "provider"
        isDefault = true
    }

    create("huawei") {
        dimension = "provider"
        isDefault = false
    }
}

dependencies {
		// Создается 1 api модуль и 2 модуля impl, содержащие имплементацию api
    implementation(project(":core:push-messaging:api"))
    "googleImplementation"(project(":core:push-messaging:firebase-impl"))
    "huaweiImplementation"(project(":core:push-messaging:hms-impl"))
}

Более подробно про этот подход можете послушать, например в этом видео.

Тогда разные имплементации будут “подсовываться” во время сборки, для этого в команде сборки нужно указать dimension:

// Запускается сборка aab-артефакта с dimentions prod и google 
./gradlew finuslugi-app:bundleProdGoogleRelease
// Запускается сборка aab-артефакта с dimentions prod и huawei 
./gradlew finuslugi-app:bundleProdHuaweiRelease

Как отличать частые сборки, которые прилетают в Firebase для внутреннего тестирования?

Для сборок в Firebase можно делать синтетический versionName для того, чтобы отличать частые сборки друг от друга - в примере добавляется –dev- и дата сборки.

flavorDimensions("sign", "provider")
productFlavors {
    create("dev") {
        val buildDate = java.text.SimpleDateFormat("dd.MM.yy-HH:mm").format(Date())
        versionNameSuffix = "-dev-$buildDate"
    }
}

Также к сборкам в Firebase можно прикреплять changelog. Обычно это описание недавно влитых MR, его делают на основе тэгов (git-tags). Ниже прикрепляю более простой вариант сбора списка влитых MR, влитых за последний день (настраиваемо). На практике этого хватает, чтобы тестировщики могли ориентироваться в сборках.

git log --pretty=format:"%an : %s" --since="1 day ago" --no-merges >> release-notes.txt || true  


Что лучше не полениться сделать с первых дней?

Рекомендую с первых дней сделать для сборок в Firebase другой packageName, чтобы позже была возможность на одно устройство ставить production и develop сборки. (см. applicationIdSuffix в документации)

Также лучше сделать разные проекты для production и develop сборок в Firebase с добавлением разных конфигурационных файлов с разными credentials для разных buildTypes. Это позволит без дополнительных костылей отделять события события аналитики, крашей (Firebase Crashlytics) и тоглы в Firebase Remote Config.

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

Заключение

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

Что можно изучать дальше?

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

- Уже несколько раз упомянутый канал коллег из hh.ruОхэхэнные истории

- Интереснейшее видео с конференции PodlodkaДетектим и автоматизируем рутинные задачи в Android” от Сергея Боиштяна.

Благодарю Сергея Боиштяна за обратную связь, которая помогла сделать этот пост (надеюсь) более последовательным и понятным.