
Привет, Хабр! Меня зовут Сергей, и я 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)
 - Rusrst15.10.2023 04:53- Вроде уже все на kts начинают мигрировать, а в нем вообще можно указать как константу версию в toml файле. Я конечно не пробовал версию приложения та прописывать, но не думаю что будут проблемы, надо этим заняться кстати.... 
 
           
 
house2008
Спасибо) Что-то всё очень сложно)
1. Версию выставлять через гит не очень практично, так как несколько разработчиков со своими фиче ветками всегда будут иметь разные версии приложения, то есть в TestFlight/Firebase версии будут идти хаотично, а не инкрементально.
2. В скрипте не выставили "For install builds only", зачем гонять этот скрипт на каждый билд проекта локально и тратить несколько секунд впустую.
3. Инрементить версию нужно не только проекта, но и его тестов и его эктеншенов. Поэтому мы используем fastlane - одной строкой кода он понимает что и где нужно заикрементить.