Когда программировал под одну ОС, а хочешь начать программировать на другой
Когда программировал под одну ОС, а хочешь начать программировать на другой

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

Любое приложение начинается с загрузки. И как оказалось, экран загрузки совсем не так прост, как кажется. То, что мы там видим — картинку, версию приложения и прочее — совсем не стандартные решения. Поэтому я решил первую статью написать именно об этом. Итак, начнём.

Задача:

Под iOS и Android сделать экран загрузки, на котором будет показываться версия приложения и логотип. Когда будет готов экран загрузки — автоматизировать увеличение версии при разработке и смену номера релиза при публикации. В качестве логотипа будет картинка из интернета:

Реализация:

Создадим одинаковое приложение на Android и iOS, имитирующее бросок кубика, и в каждом реализуем экран загрузки. Все приложения сразу делаем с Git-репозиторием — в конце статьи приведу ссылки на репозитории с готовыми приложениями.

Дисклеймер:

Как установить Xcode и Android Studio и создавать в них приложение, думаю, большинство из вас знает, поэтому не буду описывать это. Но если вдруг не знаете, то вот вам ссылки на соответствующие статьи:

Как установить и сделать базовые настройки в Xсode почему-то статей на Хабре не нашёл. Кажется, как это делать очевидно, но если нужна такая статья — пишите в комментариях.

iOS (UIKit + Storyboards)

Создадим пустое приложение, выбрав при создании Storyboards вместо SwiftUI и в качестве языка — Swift (вариант с Objective-С опустим, так как он морально устарел). Более современный вариант со SwiftUI рассмотрю в следующей статье, а пока продолжим с тем, с чего я начинал.

Получился проект, состоящий из следующих файлов:

Состав проекта iOS
Состав проекта iOS

Добавим на главном экране 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 на номер версии.

Если сейчас запустить приложение, то почти наверняка у вас появится ошибка:

Ошибка при запуске скрипта
Ошибка при запуске скрипта

Почему она вылезает? Причин может быть несколько:

  1. Мы забыли дать файлу version.sh права на запуск командой в терминале:
    sudo chmod 770 version.sh. Для выполнения этой команды нужно через терминал перейти в папку, в которой находится файл version.sh, и после выполнения команды ввести пароль администратора.

  2. Возможно, вы положили файл version.sh не в ту папку или не включили её в таргет — перепроверьте все пути в скрипте, в фазе сборки и где лежит сам файл.

  3. Также файл LaunchScreen.storyboard может оказаться в подпапке Base.lproj — в этом случае проще исправить путь к нему в version.sh.

  4. Скорее всего, у нас нет ни одного тега в 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:

Создание приложения без Activity
Создание приложения без Activity

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

Создание проекта на Kotlin и Groovy
Создание проекта на Kotlin и Groovy

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

Добавление Activity
Добавление Activity

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

Создание пустой Activity
Создание пустой Activity

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

Создание Activity
Создание Activity

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

Первый запуск
Первый запуск

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

Настройка MainActivity
Настройка 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 занимала весь экран.

Замена темы на NoActionBar
Замена темы на NoActionBar

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

Настройка LaunchActivity
Настройка LaunchActivity
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)


  1. house2008
    15.10.2023 04:53

    Спасибо) Что-то всё очень сложно)
    1. Версию выставлять через гит не очень практично, так как несколько разработчиков со своими фиче ветками всегда будут иметь разные версии приложения, то есть в TestFlight/Firebase версии будут идти хаотично, а не инкрементально.
    2. В скрипте не выставили "For install builds only", зачем гонять этот скрипт на каждый билд проекта локально и тратить несколько секунд впустую.
    3. Инрементить версию нужно не только проекта, но и его тестов и его эктеншенов. Поэтому мы используем fastlane - одной строкой кода он понимает что и где нужно заикрементить.


  1. Rusrst
    15.10.2023 04:53

    Вроде уже все на kts начинают мигрировать, а в нем вообще можно указать как константу версию в toml файле. Я конечно не пробовал версию приложения та прописывать, но не думаю что будут проблемы, надо этим заняться кстати....