Мій досвід створення застосунку на KMP, або Як робити CI/CD для кросплатформи

💡 Усі статті, обговорення, новини про DevOps — в одному місці. Приєднуйтесь до DevOps спільноти!

Привіт, я — Вова Стельмащук, Team Lead Android-застосунку Hily в українській продуктовій IT-компанії appflame. Змушую Android працювати, роблю кілька цікавих сайтів та мрію вивчити JavaScript.

Вільний час присвячую пет-проєктам. Так я можу тестувати нові технології й підходи ще до того, як вони потрапляють у продакшн, та застосовувати інсайти на реальних проєктах, і це — одна з найулюбленіших частин моєї роботи.

Серед таких пет-проєктів — mixdrinks. Як Android-розробник, я не міг оминути мобільну платформу, тому вирішив створити для нього застосунок. Але цього разу захотілося поекспериментувати й зробити щось нестандартне:

  • замість того щоб використовувати фреймворки від Google та Apple для застосунків Android та iOS відповідно, я вибрав Kotlin Multiplatform (KMP) і поставив за мету витиснути з цієї технології максимум;
  • аби мати єдину кодову базу для Android та iOS-застосунку, я скомпілював Compose UI в iOS. І так, Compose можна запускати не тільки на Android. Він гарно працює і на iOS, а також на desktop. Принцип роботи нагадує Flutter: щоб відображати компоненти, Compose UI використовує нативний доступ до canvas.

Насправді Kotlin Multiplatform + Compose для iOS — це нова технологія. Підтримка iOS зараз у бета-версії (подивитися на план із технології можна тут), і, якщо вірити офіційній дорожній карті, перехід на stable відбудеться тільки цього року. (upd: KMM Compose для iOS вже stable)

Тому так трапилося, що я став одним з адептів і опинився серед перших, хто налаштував CI/CD для KMP-застосунку з нуля.

У цьому блозі я детально розповім, як зробив CI/CD для мобільного кросплатформного застосунку на KMP, а також чому використовувати KMP на реальних продуктах — не найкраща ідея. Мій досвід буде корисним передусім фахівцям, які працюють із KMP, цікавляться GitHub Actions або просто хочуть автоматизувати деплой свого проєкту.

Overview schema

Якщо ж вам зручніше ознайомитися зі стислою версією матеріалу, у мене є окрема стаття, яку опублікували в спільноті Mobile App Development на Medium. Отож вибирайте бажаний формат — і приємного читання!

Що таке CI/CD

Сьогодні світ швидкий як ніколи. Людей дратує, коли доводиться довго очікувати на завантаження сторінок сайту, а в контексті мобільних застосунків миттєве завантаження критичне для утримання користувачів. Тому розробники повинні стабільно пришвидшувати розробку та доставку застосунків. Окрім цього, ми маємо максимально швидко передавати нову функціональність у продакшн-оточення, щоб перевірити, чи все зробили правильно.

Зараз затримки команди розробки через ручні дії під час кожного релізу виглядають, м’яко кажучи, нелогічно. Кожна неавтоматизована частина процесу розробки або доставки — це гальма для вашого бізнесу та додаткові ризики через ручну роботу. Виливати білд у маркет мануально — монотонне завдання. А окрім того, можна легко натиснути не ту кнопку, запустити не ту Gradle job чи пропустити один крок — і щось піде не за планом.

Тому автоматизація релізного процесу — обов’язкова для кожного проєкту, незалежно від вашого релізного циклу. Бо якщо ви релізитеся щотижня, то завдяки автоматизації просто заощаджуєте час, а якщо раз на квартал — позбуваєтеся клопоту тримати всі нюанси в голові. Адже що рідше відбуваються релізи, то вищий ризик щось забути чи зробити не так.

На допомогу приходить CI/CD (Continuous Integration and Continuous Delivery) — неперервна інтеграція та доставка. Це набір інструментів і практик, які дозволяють швидко розробляти, тестувати та доставляти програмне забезпечення до користувачів. CI/CD давно вже став стандартом у розробці. У більшості проєктів він є за замовчуванням: хтось колись написав yml для GitHub Actions, GitLab Pipelines абощо — і відтоді всі цим користуються.

Але ці інструменти не з’явилися самі по собі — їх також писали програмісти, які були першими адептами технології.

Як налаштувати CI/CD

1. Визначити вимоги до автоматизації

Перед тим як налаштовувати CI/CD, треба визначити, що саме ми хочемо автоматизувати і які критерії характеризуватимуть виконану роботу. Тому ще на початку роботи із CI/CD я виділив ключові аспекти мого пет-проєкту:

  • автоматична збірка Android-застосунку — як за графіком, так і за запитом (наприклад, коли потрібно вручну запустити збірку);
  • автоматична збірка iOS-застосунку за тими ж принципами, що й Android;
  • публікація Android-застосунку в Google Play — щоб нові версії могли виходити без зайвих мануальних дій;
  • публікація iOS-застосунку в App Store.

2. Вибрати сервіс для CI/CD

Зараз є багато різних сервісів та інструментів, де можна зробити CI/CD для проєкту. Усі свої open source проєкти я зберігаю на GitHub, тому для CI/CD використав GitHub Action — відповідно, мені не треба інтегрувати сторонній сервіс. Також GitHub Action дає можливість створити рішення, яке відповідає моїм вимогам до автоматизації.

Якщо робити CI/CD для кросплатформного застосунку, то треба перевіряти, чи може сервіс надати всі потрібні операційні системи для запуску наших автоматизацій. У моєму випадку для автоматизації потрібні дві операційні системи — macOS для збірки iOS і Ubuntu для збірки Android. Файл yml, у якому я описав усю конфігурацію CI/CD для цього проєкту, можна переглянути тут.

3. Врахувати специфіку версіонування мобільних застосунків

У Google Play та App Store кожен застосунок для Android та iOS повинен мати два обовʼязкові поля, які відповідають за версію застосунку та визначають, як відбуватиметься їхнє оновлення. Ці поля потрібно вказувати під час розробки:

  1. version code, який має збільшуватися з кожним новим релізом. Якщо спробувати завантажити в маркетплейс версію з меншим або тим самим номером, що вже є, — він просто не пропустить нову версію;
  2. version name — версія, яку користувач бачить у магазині застосунків та в налаштуваннях свого пристрою.

Якщо ви, як і я, провели роки в розробці мобільних застосунків, то використання такого підходу виглядає логічним, адже дає можливість відрізняти релізи один від одного та відстежувати зміни в релізах.

Однак насправді версіонування ускладнює автоматизацію CI/CD, бо потребує додаткових дій для правильної роботи. Наприклад, необхідно окремо генерувати version code на основі version name за формулою: major * 10000 + minor * 100 + patch, навіть якщо попередній build не був доступний користувачам.

Будьмо чесними, кінцевим юзерам байдуже на номер версії вашого застосунку, і його не потрібно версіонувати, як enterprise-рішення, котре IBM постачає для JP Morgan Chase (назви компаній вигадані, збіги з реальністю випадкові). Якщо не вірите, спробуйте без підказки назвати версію застосунку monobank або «Нової пошти» на вашому телефоні. Правильно, перше, що ви згадаєте, — це їхній дизайн чи нова функціональність, а не номер версії.

Якби цих вимог від Google Play та App Store не було, ми могли б використовувати простіші підходи. Наприклад, для своїх пет-проєктів на web я створюю версію на основі commit hash, з якого зібрав docker image — це швидко та просто (бо немає обмежень Google Play та App Store). Відповідний yml-файл для GitHub Action можна переглянути тут.

Але цей підхід не підходить для мобільної розробки, адже маркетплейси не дозволяють використовувати будь-який довільний рядок замість version code. А commit hash — це рядок, а не число, що зростає. Через ці обмеження доводиться вигадувати спеціальні механізми для правильного версіонування.

На жаль, на політику Google чи Apple ми вплинути не можемо, а писати версії руками теж не дуже хочеться, тому я автоматизував процес. Використовую версії виду Semver і написав job, який бере крайній tag у репозиторії та автоматично збільшує йому patch-частину. Крім того, цей job створює version code і version name та зберігає їх у job output, щоб можна було використовувати на наступних етапах CI/CD. У коді це виглядає ось так:

prepare_deploy:
  runs-on: Ubuntu-22.04
  steps:   
    — uses: actions/checkout@v3
      with:
        fetch-depth: 0
    — name: Set env
      id: last_tag
      run: echo "LAST_TAG=$(git tag --sort=committerdate | tail -1)" >> $GitHub_OUTPUT
    — uses: actions-ecosystem/action-bump-semver@v1
      id: bump-semver
      with:
        current_version: ${{ steps.last_tag.outputs.LAST_TAG }}
      level: patch

    — run: |
        git config user.name GitHub-actions
        git config user.email [email protected]
        git tag ${{ steps.bump-semver.outputs.new_version }}
        git push --tags
    — name: "Set output"
      id: set-output
      run: |
        echo "mix_drinks_mobile_version_name=${{ steps.bump-semver.outputs.new_version }}" >> $GitHub_OUTPUT
 	
        IFS='.' read -r major minor patch <<< "${{ steps.bump-semver.outputs.new_version }}"
 	
        mix_drinks_mobile_version_code=$((major * 10000 + minor * 100 + patch))
 	echo "mix_drinks_mobile_version_code=${mix_drinks_mobile_version_code}" >> $GitHub_OUTPUT
    — name: "Print output"
      run: |
        echo -e "Version name is: \n ${{ steps.set-output.outputs.mix_drinks_mobile_version_name }}"
 	
        echo -e "Version code is: \n ${{ steps.set-output.outputs.mix_drinks_mobile_version_code }}"
  outputs:
    output_write_mix_drinks_mobile_version_name: ${{ steps.set-output.outputs.mix_drinks_mobile_version_name }}
    output_write_mix_drinks_mobile_version_code: ${{ steps.set-output.outputs.mix_drinks_mobile_version_code }}

4. Зібрати Android-застосунок

Щоб зібрати та опублікувати Android-застосунок, нам потрібно виконати Gradle job — androidApp:bundleRelease.

Для цього я використав офіційний GitHub Action від Gradle — gradle-build-action. Перед виконанням додаю в змінні оточення versionCode та versionName з попереднього кроку. Також я зробив відповідні зміни в самих Gradle script, щоб усе працювало автоматично.

versionCode = System.getenv("MIXDRINKS_MOBILE_APP_VERSION_CODE")?.toIntOrNull()?: 1
versionName = System.getenv("MIXDRINKS_MOBILE_APP_VERSION_NAME")?: "0.0.1"

Для автоматичного завантаження в Google Play я використав GitHub Action — r0adkll/upload-google-play, а також зробив сервісний акаунт, щоб CI/CD мав права завантажувати застосунок від мого імені. Для цього:

  1. перейдіть за посиланням та увімкніть Google Play Android Developer API для свого проєкту;
  2. виконайте офіційні інструкції Google зі створення service account;
  3. отриманий JSON-файл із ключами зберігайте в GitHub Action Secrets, щоб CI/CD міг використовувати його для завантаження нових версій застосунку.

5. Зібрати iOS-застосунок

Тут усе трішки складніше — xcodebuild не дає технічної можливості проставити значення CFBundleShortVersionString та CFBundleVersion через змінні оточення. Тому я зробив хак із модифікацією файлу Info.plist* як окремий крок на CI/CD. Перед виконанням, так само як для Android, додаю в змінні оточення version code та version name з попереднього кроку. У коді виглядає ось так:

run: |
  /usr/libexec/Plistbuddy -c "Set CFBundleVersion $MIXDRINKS_MOBILE_APP_VERSION_CODE" iOSApp/iOSApp/Info.plist
  /usr/libexec/Plistbuddy -c "Set CFBundleShortVersionString $MIXDRINKS_MOBILE_APP_VERSION_NAME" iOSApp/iOSApp/Info.plist

*Info.plist — файл в iOS-проєкті, який виконує роль маніфесту, є аналогом AndroidManifest.xml для OS Android або manifest.json для WebExtension.

З автоматизацією завантаження застосунку в App Store все також складніше, ніж із Google Play. Apple не дає створити сервісний акаунт, тому немає можливості зробити авторизаційний токен, який буде жити роками. Але можна зробити звичайний токен на основі Team Private Key. Для цього:

  1. зайдіть в App Store Connect і створіть private key;
  2. збережіть його зміст у GitHub Action Secret;
  3. використовуйте action, yuki0n0/action-appstoreconnect-token для генерації токена, що дозволить взаємодіяти з Rest API App Store Connect.
 name: Get appstore token
   id: asc
   uses: yuki0n0/[email protected]
   with:
     issuer id: ${{ secrets.MIXDRINKS_IOS_APPSTORE_ISSUER_ID }}
     key id: ${{ secrets.MIXDRINKS_IOS_APPSTORE_ADMIN_API_KEY_ID }}
     key: ${{ secrets.MIXDRINKS_IOS_APPSTORE_ADMIN_API_PRIVATE_KEY_RAW }}

- name: Create appstore version
   env:
     APP_VERSION: ${{ env.MIXDRINKS_MOBILE_APP_VERSION_NAME }}
     run: |
       JSON=`curl -sS -H "Authorization:Bearer ${{ steps.asc.outputs.token }}" https://api.appstoreconnect.apple.com/v1/appStoreVersions -d '{"data": {"type": "appStoreVersions","attributes": {"platform": "IOS","versionString": "${APP_VERSION}","copyright": "(c) 2020 MixDrinks","releaseType": "MANUAL","usesIdfa": false},"relationships": {"app": {"data": {"type": "apps","id": "6447103081"}}}}}' -H 'Content-Type: application/json'`

One more thing

GitHub Action повністю безкоштовний для open source проєктів. Для приватних проєктів є ліміт — 2000 хвилин на місяць. Це справжні 2000 хвилин тільки для job, які запускаються на Ubuntu. Для macOS кожна хвилина роботи буде рахуватися як 10 хвилин. Тобто якщо ваш macOS runner працює 1 хвилину, з вас буде знято 10 хвилин. Для Windows цей коефіцієнт 2, тобто за одну хвилину робити CI буде знято 2 хвилини з вашого балансу. Це зрозуміло, адже хостинг macOS або Windows у дата-центрах — набагато дорожче і складніше, ніж Ubuntu. Тому будьте уважні.

GitHub не дуже чітко повідомляє про ці правила — на мою думку, вони мають комунікувати про це прозоріше для користувачів. Детальну інформацію про minute multipliers можна знайти в офіційній документації.

Чому не раджу використовувати KMP на реальних продуктах

Я постарався підсвітити всі цікаві моменти створення CI/CD для проєкту на KMP. Якщо вам потрібна подібна автоматизація в проєкті, весь код доступний на GitHub — просто змініть назви змінних та секрети й використовуйте.

І ще одна порада наостанок: робіть CI/CD. Заощаджений час можна провести набагато цікавіше, ніж натискати кнопки в Google Play Console та App Store Connect. Але пам’ятайте, що KMP — це технологія, яку активно просувають JetBrains та Google. У перших немає мобільних проєктів, у других є звичка закривати ініціативи. Тому добре подумайте, перш ніж брати її в продакшн для реального продукту. Мій висновок із цього експерименту: я б не брав.

У Hily ми використовуємо нативні технології: Android SDK, Kotlin Coroutines, Jetpack Compose — всі ці інструменти напряму підтримуються Google та активно розвиваються.

У сучасному світі саме поняття «нативний» може бути розмите. Дехто вважає технологію нативною, якщо вона використовує прямий доступ до пам’яті, інші — якщо вона від тієї ж компанії, що робить операційну систему.

Я ж вважаю технологію нативною, якщо вона дозволяє повністю підтримувати фічі операційної системи з мінімальними обмеженнями та ризиками. Наприклад, ти не переживаєш, що буде з твоїм фреймворком після виходу нової версії на Android.

Окрім того, ці технології мають сильну підтримку — як від компаній, так і від спільноти програмістів. Тому ви також отримуєте перевагу у вигляді багатої інфраструктури: середовища розробки, готові рішення для CI/CD, інструменти для debug і profile та різних SAAS-рішень, що спрощують роботу.

Дякую за увагу! Якщо залишилися запитання, чекаю в коментарях — що знатиму, відповім. Також я відкритий до обговорення статті в LinkedIn та на інших платформах.

Більше власними думками про програмування регулярно ділюся на ютубі: тут — у довгому форматі, а тут — у короткому.

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
run: echo "LAST_TAG=$(git tag --sort=committerdate | tail -1)" >> $GitHub_OUTPUT

$GitHub_OUTPUT -> $GITHUB_OUTPUT

Підкажіть, як ви тестували скрипти під чаз розробки,
чи є якісь пісочниці для для деплою мок апок для .aab та .ipa?

Тестував все в «продакшені», з реальними сторами, ключами і всім іншим.

Просто вигружав білд не як публічно доступний а для beta групи.

Цікавий пост про сі, дякую)
Але щодо кмп доволі поверхнева аргументація, на мою думку. Ну тобто відносно тайтлу статті я очікував тут набір реальних проблем з продуктом, якимись фічами, тестуванням, чи observability. Одним словом, хотілось конкретику з цим тайтлом.

Я писав супер простий додаток, настільки що я міг собі дозволити на старті просто викачати всю базу даних з backend і далі робити все локально, тому складне що в цьому було це дійсно зробити авто deploy цього всього діла.

я більш чим впевений що додаток настільки простого рівення, можа написати на любій технології і буде ок

З приводу чи радити Kotlin Multiplatform — все залежить від контексту. Якщо є можливість обрати нативну розробку — безумовно завжди краще зупинитися на ній. Жоден крос-платформний фреймворк не зможе повноцінно її замінити.
Однак, якщо клієнт через певні обставини наполягає на крос-платформному рішенні, то серед існуючих варіантів KMP виглядає найбільш прийнятним. Особливо для Android-розробників.
На відміну від наприклад Flutter, KMP — це не окремий продукт, а природнє розширення екосистеми Kotlin. Усі ключові компоненти — корутини, серіалізація, Ktor, Compose Multiplatform і багато іншого — це не унікальні технології KMP, а частина сучасної Android-розробки. Вони розвиваються незалежно та активно використовуються навіть у чисто нативних проєктах. А той факт, що Google став офіційно підтримувати KMP (фактично в шкоду власному Flutter), лише підтверджує перспективність цього напрямку.
Більше того, правильно написаний Android-додаток на Kotlin з урахуванням усіх сучасних рекомендацій та гайдлайнів, який створений на самих актуальних версіях та використовує бібліотеки також написаними на Kotlin — це вже на 90% крос-платформний продукт. Якщо в майбутньому компанія вирішить відмовитися від нативу, перехід на KMP вимагатиме мінімальних витрат.

Мені здається, Ваша думка трохи упереджена, оскільки Ви Android-розробником і дивитесь на ситуацію через призму Android.
Ось ваш коментар переписаний під RN.
З приводу того, чи варто обирати React Native — все залежить від контексту. Якщо є можливість зупинитися на нативній розробці — це завжди найкращий варіант. Жоден крос-платформний фреймворк не здатен повноцінно замінити натив.
Однак, якщо клієнт через певні обставини наполягає саме на крос-платформному рішенні, серед існуючих варіантів React Native залишається одним із найбільш життєздатних. Особливо, якщо в команді вже є досвід веб-розробки, або якщо проєкт передбачає одночасну розробку веб- і мобільного застосунку.
На відміну від, скажімо, Flutter, React Native — це не окремий продукт, а природне продовження екосистеми JavaScript/TypeScript і React. Більшість технологій, які використовуються в React Native — це знайомі інструменти фронтенду: React-компоненти, hooks, Redux, styled-components, анімаційні бібліотеки тощо. Їх можна частково або повністю використовувати спільно між веб- і мобільною версією застосунку, що суттєво знижує поріг входу та пришвидшує розробку.
Крім того, React Native активно підтримується спільнотою, а також великими гравцями, такими як Meta, Shopify, Microsoft. Багато великих застосунків у продакшені частково або повністю написані на RN, що лише підтверджує його стабільність і перспективність.
Нарешті, якщо веб-застосунок вже написаний на React з сучасними підходами, його логіку, окремі UI-компоненти чи навіть цілі модулі можна перенести в мобільну версію з мінімальними змінами. Таким чином, React Native може стати логічним продовженням існуючої інфраструктури без необхідності дублювати зусилля.

Ого
Це прям гарний коментар. Дуже люблю коли інженер дивитися на світ з різних сторін.

Так, усі ваші аргументи щодо React Native мають сенс. Я не хотів принизити інші крос-платформні технології — вони існують, мають свої переваги та історії успіху.
Хоча мені подобається KMP, я завжди був і, мабуть, залишусь прихильником нативної розробки. Проте на жаль існують кейси, коли клієнт вирішує перейти на крос-платформу, і тоді вже доводиться обирати конкретне рішення. Хтось обирає React Native, хтось Flutter, а дехто навіть експериментує з WebView.
Саме в таких ситуаціях я звернув би увагу на KMP як на один з інструментів. І так, це обґрунтовано тим, що я є Android-розробником) До речі, як і автор цієї чудової та дуже корисної статті про CI/CD.
Моя реакція на його пораду

не використовувати KMP на реальних продуктах

була спрямована на те, щоб підкреслити переваги цієї платформи в певних випадках.

Так можна написати про багато технологій, тут вже написали React Native, думаю що можна так само написати про QT.
В сторону офіційної підтримки google чого завгодно, google це дуже велика компанія, я багатьма продуктами, яка багато що говорить. Ось наприклад стаття на google cloud, про підтримку пушів в react native, то що тепер google підтримує офіційно RN??
cloud.google.com/...​atform/docs/android-react

Підписатись на коментарі