
Привет, Хабр! Меня зовут Сергей, и я 3 года работаю Android/iOS разработчиком. Написал с нуля приложение для фитнес-клубов (Google Play/App Store — если интересно, код под NDA, его не могу показать) и дорабатываю малоизвестный банк. За это время накопилось много интересных решений, которые переношу из проекта в проект. Приходилось пробовать разные подходы и создавать своих «Франкенштейнов», поскольку работающего как мне нужно кода и статей найти не удавалось. И так было до тех пор, пока не выкристаллизовались оптимальные для меня варианты. В своих статьях расскажу о них. Кроме того, мне всегда интересно сравнивать подход в iOS и Android в аналогичных вопросах. Надеюсь, мои статьи помогут тем, кто хочет перейти с одной платформы на другую.
Любое приложение начинается с загрузки. И как оказалось, экран загрузки совсем не так прост, как кажется. То, что мы там видим — картинку, версию приложения и прочее — совсем не стандартные решения. Поэтому я решил первую статью написать именно об этом. Итак, начнём.
Задача:
Под iOS и Android сделать экран загрузки, на котором будет показываться версия приложения и логотип. Когда будет готов экран загрузки — автоматизировать увеличение версии при разработке и смену номера релиза при публикации. В качестве логотипа будет картинка из интернета:

Реализация:
Создадим одинаковое приложение на Android и iOS, имитирующее бросок кубика, и в каждом реализуем экран загрузки. Все приложения сразу делаем с Git-репозиторием — в конце статьи приведу ссылки на репозитории с готовыми приложениями.
Дисклеймер:
Как установить Xcode и Android Studio и создавать в них приложение, думаю, большинство из вас знает, поэтому не буду описывать это. Но если вдруг не знаете, то вот вам ссылки на соответствующие статьи:
Курс по изучению Kotlin и Android Studio с нуля до Junior. #1 Введение. Установка Android Studio — тут базовые действия по установке Android Studio;
Android Studio для NDK под Windows — здесь уже продвинутые настройки Android Studio.
Как установить и сделать базовые настройки в Xсode почему-то статей на Хабре не нашёл. Кажется, как это делать очевидно, но если нужна такая статья — пишите в комментариях.
iOS (UIKit + Storyboards)
Создадим пустое приложение, выбрав при создании Storyboards вместо SwiftUI и в качестве языка — Swift (вариант с Objective-С опустим, так как он морально устарел). Более современный вариант со SwiftUI рассмотрю в следующей статье, а пока продолжим с тем, с чего я начинал.
Получился проект, состоящий из следующих файлов:

Добавим на главном экране Main.storyboard надпись (Label) и кнопку (Button), при нажатии на которую в надписи будем генерировать число от 1 до 6:

ЭКод:
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var label: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
@IBAction func roll(_ sender: Any) {
label.text = String(Int.random(in: 1..<7))
}
}
Экран запуска тут уже существует. Это LaunchScreen.storyboard, но есть один нюанс — этот экран статический, то есть как данные настроили в Xcode в конструкторе storyboard — так они и будут показаны на экране, без какой-либо возможности управления кодом.
Добавим на LaunchScreen наш логотип (через ImageView) и номер версии (через Label), в который пока явно напишем версию 1.2.3.

Если запустить приложение, то увидим, что экран загрузки корректно показывается и после загрузки приложения появляется главный экран. Основная задача выполнена и тут начинается самое интересное — автоматизация генерации номера версии из Git.
Изменим имя надписи с номером версии, например на APP_VERSION, и потом при сборке по этому имени будем находить надпись и менять текст в ней.

Нам номер версии нужен при запуске приложения, поэтому для генерации отлично подойдёт фаза сборки со специально подготовленным для неё скриптом.
Создаём новую фазу сборки с запуском скрипта:

Пропишем путь к нашему будущему скрипту:

Создаём новый файл скрипта.

Скрипт я назвал version.sh и поместил его рядом с остальными файлами проекта.

В получившийся файл скрипта вставляем следующий код:
# 1.In Build Phases add "New Run Script Phase" with "$SRCROOT/version.sh"
# 2.Add this script to project
# 3.Add version.sh to main Target
# 4.Change permission of version.sh in Terminal: sudo chmod 770 version.sh
# 5.Set variables
git=/usr/local/bin/git
git_tag=$(git describe --tags --always --abbrev=0)
git_version=$(git rev-list HEAD --count --grep='^Merge .*$' --invert-grep)
# 6.Rewrite versions in Info.plist
if [ "${Build}" = "" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${git_version}" "$INFOPLIST_FILE"
else
Build=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$INFOPLIST_FILE")
Build=$(echo "scale=0; $Build + ${git_version}" | bc)
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $Build" "$INFOPLIST_FILE"
fi
if [ "${Version}" = "" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${git_tag}" "$INFOPLIST_FILE"
else
Version=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$INFOPLIST_FILE")
Version=$(echo "scale=2; $Version + ${git_tag}" | bc)
if [ "${CONFIGURATION}" = "Release" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $Version" "$INFOPLIST_FILE"
fi
fi
# 7.Rewrite label with that has user name "APP_VERSION" in LaunchScreen.storyboard with version from git
sourceFilePath="$PROJECT_DIR/$PROJECT_NAME/Base.lproj/LaunchScreen.storyboard"
sed -i .bak -e "/userLabel=\"APP_VERSION\"/s/text=\"[^\"]*\"/text=\"$git_tag.$git_version\"/" "$sourceFilePath"
Далее разберём сам код построчно:
В первой строке указываем, где установлен Git.
Во второй строке получаем текст из последнего тега Git.
В третьей строке считаем, сколько коммитов в Git, исключая мерж-коммиты.
Далее идёт блок if else fi, в котором прописывается версия сборки в проекте: точнее в файле Info.plist — в параметре CFBundleVersion.
В следующем блоке if else fie прописывается версия релиза в параметре CFBundleShortVersionString файла Info.plist.
В предпоследней строке запоминаем путь к LaunchScreen.storyboard.
В последней строке заменяем текст в APP_VERSION на номер версии.
Если сейчас запустить приложение, то почти наверняка у вас появится ошибка:

Почему она вылезает? Причин может быть несколько:
Мы забыли дать файлу version.sh права на запуск командой в терминале:
sudo chmod 770 version.sh. Для выполнения этой команды нужно через терминал перейти в папку, в которой находится файл version.sh, и после выполнения команды ввести пароль администратора.Возможно, вы положили файл version.sh не в ту папку или не включили её в таргет — перепроверьте все пути в скрипте, в фазе сборки и где лежит сам файл.
Также файл LaunchScreen.storyboard может оказаться в подпапке Base.lproj — в этом случае проще исправить путь к нему в version.sh.
Скорее всего, у нас нет ни одного тега в Git. Создаём тег в Git, например «1.1», и ещё раз запускаем приложение.
Если ни один из перечисленных выше пунктов не помог, то внимательно читаем, что пишется в логах сборки и экспериментируем.

Автоматически обновилась версия релиза и сборки, и всё вывелось на экран загрузки:

Укажем, что проект будет на Kotlin и Groovy:Минус такого подхода — то, что при каждой сборке создаётся копия и изменяется файл LaunchScreen.storyboard, но эти изменения можно отменить — это не баг, а фича.
Код проекта под iOS в репозитории: https://github.com/mobile-sergey/HabrVersion_iOS
Android (Kotlin + Gradle)
Создадим пустое приложение на Kotlin с интерфейсом на XML и скриптами на Gradle. Более современный вариант с Kotlin Compose и скриптами на Kotlin рассмотрю в следующей статье, а пока продолжим то, с чего я начинал.
Для начала создадим пустой проект без Activity:

Укажем, что проект будет на Kotlin и Groovy:

Добавим в приложение две Activity, добавим их через контекстное меню из галереи:

В галерее выберем Empty Views Activity (чтобы у нас были простые Activity на XML):

Первая Activity пусть будет MainActivity, с настройками по умолчанию. А в настройках у второй ставим флаг Launcher Activity, чтобы приложение запускалось, начиная с неё:

Запустим проект, увидим пустое окно Launch Activity:

Добавим текст (TextView) и кнопку (Button) в макет главного окна (activity_main.xml) и в классе MainActivity пропишем код, чтобы надпись изменялась при нажатии кнопки:

Осталось сделать экран загрузки и прописать, чтобы он показывался на пару секунд и автоматически переключался на главный экран:
package ru.habr.version
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView = findViewById<TextView>(R.id.textView)
val button: Button = findViewById(R.id.button)
button.setOnClickListener {
textView.text = (1..6).random().toString()
}
}
}
Настроим экран запуска: для начала в res/values/themes/themes.xml исправим DarkActionBar на NoActionBar, чтобы не появлялась полоска с названием приложения и Activity занимала весь экран.

Добавляем текст (TextView) с номером версии 1.2.3 и картинку (ImageView) в макет экрана загрузки (activity_launch.xml) и в классе LaunchActivity прописываем создание хендлера и его выполнение через указанную задержку с запуском MainActivity.

package ru.habr.version
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
@SuppressLint("CustomSplashScreen")
class LaunchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_launch)
val duration = 2000L
val handler = Handler(Looper.myLooper()!!)
val textVersion = findViewById<TextView>(R.id.textVersion)
textVersion.text = App.version
handler.postDelayed(
{
startActivity(Intent(this, MainActivity::class.java))
finish()
},
duration
)
}
}
Запустим приложение: сначала покажется экран загрузки приложения, и через 2 секунды откроется основной экран приложения.


P.S. Пока готовил это описание, наткнулся на статью на Хабре про создание экранов загрузки, в которой помимо моего описаны ещё 3 варианта создания экрана загрузки: Полное руководство по Splash Screen на Android.
Осталась самая малость — научиться автоматически генерировать номер версии и выводить его в LaunchActivity. Для этого нам понадобится написать пару функций в build.gradle (app) и потом их вызвать. В начале файла между plugins {} и android {} добавляем:
def getVersionCode = { ->
try {
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-list', '--count', 'HEAD', '--grep=^Merge .*$', '--invert-grep'
standardOutput = stdout
}
def versionCode = Integer.parseInt(stdout.toString().trim())
return versionCode
}
catch (ignored) {
return 1
}
}
def getVersionName = { ->
try {
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'describe', '--tags', '--abbrev=0'
standardOutput = stdout
}
def versionName = stdout.toString().trim()
return versionName
}
catch (ignored) {
return '0.0'
}
}
Это две переменные, в которых хранятся функции:
первая генерирует код версии из количества коммитов, не являющихся мержами в Git;
вторая генерирует номер релиза из последнего тега в Git.
Далее вместо номера сборки и номера релиза в блоке android {} подставляем вызов этих функций:
versionCode getVersionCode()
versionName getVersionName()
Также прямо под ними можно добавить строчку:
setProperty("archivesBaseName", "version $versionName.$versionCode")
Эта строчка будет менять имя выгружаемого файла .apk или .aab.
И ещё в конце файла build.gradle после dependencies{} я добавляю:
task printVersion() {
println("Version: ${project.android.defaultConfig.versionName}.${project.android.defaultConfig.versionCode}")
}
Это нужно для того, чтобы при сборке на вкладке Build видеть номер собираемой версии.
И не забываем нажать кнопку Sync Now, чтобы эти изменения применились.
Создаём в Git новый тег, например «1.0», и запускаем сборку — видим в Build надпись:
> Configure project :app
Version: 1.0.8
Осталось теперь вывести номер версии на экран Launch Activity. Тут всё совсем не очевидно. Надо создать модуль приложения, в котором получим номер версии:
class App : Application() {
override fun onCreate() {
super.onCreate()
version = getAppVersion()
}
fun getAppVersion(): String {
var pInfo: PackageInfo? = null
try {
val pm = packageManager
if (pm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pInfo = pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
pInfo = pm.getPackageInfo(packageName, 0)
}
}
} catch (ignored: Exception) {
}
if (pInfo == null) {
pInfo = PackageInfo()
pInfo.versionName = "0.0.0"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pInfo.longVersionCode = 0
} else {
pInfo.versionCode = 0
}
}
var version = pInfo.versionName + "."
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
version += pInfo.longVersionCode
} else {
version += pInfo.versionCode.toLong()
}
return version
}
companion object {
var version: String = ""
}
}
Потом в манифесте нужно привязать этот класс к приложению строкой:
android:name=".App"
И чтобы номер версии стал показываться, осталось вывести его в LaunchActivity:
val textVersion = findViewById<TextView>(R.id.textVersion)
textVersion.text = App.version
Теперь запускаем приложение и видим, что показывается текущая версия:

Код проекта под Android в репозитории.
Итак, мы посмотрели, что и в iOS, и в Android приходится использовать свои танцы с бубнами, чтобы получить тот результат, который нам нужен. Надеюсь, статья поможет в создании ваших приложений. Буду рад вашим комментариям и идеям. Уверен, что есть и другие решения, которые могут быть и лучше предложенных мной. Если формат сравнения iOS и Android вам понравился, то в следующей статье опишу создание загрузочных экранов на SwiftUI и Kotlin Compose.
Комментарии (2)
Rusrst
15.10.2023 04:53Вроде уже все на kts начинают мигрировать, а в нем вообще можно указать как константу версию в toml файле. Я конечно не пробовал версию приложения та прописывать, но не думаю что будут проблемы, надо этим заняться кстати....
house2008
Спасибо) Что-то всё очень сложно)
1. Версию выставлять через гит не очень практично, так как несколько разработчиков со своими фиче ветками всегда будут иметь разные версии приложения, то есть в TestFlight/Firebase версии будут идти хаотично, а не инкрементально.
2. В скрипте не выставили "For install builds only", зачем гонять этот скрипт на каждый билд проекта локально и тратить несколько секунд впустую.
3. Инрементить версию нужно не только проекта, но и его тестов и его эктеншенов. Поэтому мы используем fastlane - одной строкой кода он понимает что и где нужно заикрементить.