Compose Multiplatform, або Слон у коробці з-під телефона
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
Привіт! Давно я тобі не писав, любий читачу.
У мене є чергова пригода (вже четверта) — остання та найдовша для проєкту Cat Facts. Ти колись мріяв писати UI під компи на Jetpack Compose? Була потрібна якась дрібна самописна утиліта, чи ще щось? Ні? А як щодо написання одного застосунка — як для компів, так і для телефонів? Сьогодні ми саме це і зробимо. На жаль, не під айфони, для цього потрібен макбук (щоб тебе чорти побрали, Apple), але великий проривом є бодай той факт, що це можливо, та ще й зручно для тих, хто прийшов з екосистеми Android.
У попередній частині ми натягнули сову на глобус, викликавши наш SDK через Kotlin/JS та експортувавши в сам JS, і я дав обіцянку показати Compose for Web. Але JetBrains сказала: «Не сьогодні, ми зараз портуємо його з проблемного Kotlin/JS на приємніший Kotlin/Wasm». Тому лишається тільки чекати. Але що заважає тим часом підготувати базу, яку можна буде легко портувати?
Базуємо
Як завжди, спочатку підготуймо найбазованішу базу для нашого застосунку. JetBrains виклала темплейт, котрий дозволить нам з легкістю та розмахом почати розробку. Ну що ж, використаймо його. Клонуємо як забажається, відкриваємо IntelliJ Ultimate або Android Studio і очікуємо на Gradle Sync. Доки чекаємо, можемо з’їздити на Говерлу, або Європою покататися, або приділити час сім’ї. Прилетіти вже старим до свого будинку на летючому таксі, підійти до покинутого компа, зібраного ще в далекому 2021 році, десь років 12 тому, та почекати ще пʼять хвилин на Gradle Sync. Можна продовжувати. Запустимо Gradle-таску androidApp
, подивимось, що там таке:
На desktopApp
у нас те саме:
А тепер дозволь поглянути на код. Як завжди, почнімо з Android. Там один-єдиний клас MainActivity
:
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MainView() } } }
MainView()
, швидше за все, просто мультиплатформенна імплементація, що розташована в модулі shared
, бо де ж іще її можна взяти?
Тепер перейдемо до точки входу на компах:
fun main() = application { Window(onCloseRequest = ::exitApplication) { MainView() } }
Pretty, isn’t it? Тепер розберемо все до піщинки — усередині application {...}
можна писати композабли, своєрідний аналог setContent{...}
. Є декілька цікавих функцій, що доступні на десктопі, наприклад, exitApplication()
. Після закриття цього вікна програма просто захлопнеться, а процес вб’є операційна система. Параметр onCloseRequest
дуже класно використовувати, щоб передавати стейт вище навігаційним деревом, якщо програма любить відкривати велику хмару вікон (трішки не за темою: якщо користувачу треба бігати серед тисячі вікон — це не дуже хороший знак у плані UX).
Шортуємо
Пане, а ви любите шорткати? Я настільки люблю, що навіть IdeaVim собі поставив, і — о, яке то було горе — дізнався, що в цьому темплейті не наведено прикладів шорткатів! Але ми ж можемо їх зробити, хіба ні? Я б хотів виходити з програми та змінювати її розмір від великого до маленького, не доторкаючись до миші, але це все не матиме ніякого сенсу в Android, бо які там вікна можуть бути? Хіба що в WSA та хромбуках, але то інша тема. Я, як найсправжніший шорткатофан, поліз в імплементацію @Composable Window
задля способу перехоплювати key events. І він доволі-таки легкий! Не буду тягнути, ось повний код (а ти принеси, будь ласка, скальпель, не голими руками ж працювати!):
fun main() = application { val mainWindowState = rememberWindowState(size = DpSize(400.dp, 400.dp)) Window( onCloseRequest = ::exitApplication, onKeyEvent = { keyEvent -> when { keyEvent.isCtrlPressed && keyEvent.key == Key.Q && keyEvent.type == KeyEventType.KeyUp -> { exitApplication() true } keyEvent.isCtrlPressed && keyEvent.key == Key.S && keyEvent.type == KeyEventType.KeyDown -> { mainWindowState.size = DpSize(200.dp, 200.dp) true } keyEvent.isCtrlPressed && keyEvent.key == Key.B -> { mainWindowState.size = DpSize(600.dp, 600.dp) true } else -> false } }, state = mainWindowState ) { MainView() } }
На самій горі ми одразу натрапляємо на кастомний стейт mainWindowState
, який можна передати будь-якому вікну через параметр state
. Подібним патерном можна задавати дефолтну поведінку застосунку, наприклад, дефолтний розмір вікна, або ж сказати, де певному вікну варто з’явитися.
Ну а тепер сама логіка, що буде оперувати над стейтом командами з клавіатури. Захотілось-таки шорткати: Ctrl+Q — вихід, Ctrl+B — більше вікно (bigger), Ctrl+S — менше (smaller). Отже, нам потрібно щось, що на кожному клавіатурному івенті буде перевіряти натиснуту клавішу. Найпростіше це зробити, пройшовшись івентом з when
(як моє ліниве м’яке місце і зробило). Але ви також помітили ці дивні Boolean
у кінці кожної гілки when
? Наша лямбда після виконання має казати, чи пропагувати івент нижче, чи ні, і ці булеві значення дають знати, чи перехоплений був шорткат (true
— так, далі його не передаємо, false
— ні, передаємо його нижче). А так, у принципі, ніяких нюансів. Оброблюємо, як хочемо! За це я і люблю Compose.
Ну що ж, подивимося, що ми там наробили та як це працює?
Працює на відмінно! А тепер нарешті загляньмо у
shared
-модуль, більш ніж упевнений, що й у тебе, й у мене руки чешуться там всілякого наворотити!
Мультуємо
Заходимо в модуль shared, бачимо чотири сорс-сети — по одному на платформу і commonMain
. Звична картина для модуля мультиплатформи. І в платформ-специфічних сетах є ті самі загадкові MainView
, щоправда, у iosMain
ця функція зветься дещо інакше — mainViewController
.
Там ніц цікавого, краще зайдемо в commonMain
, де на нас чекає функція App
(читай: main
для commonMain
):
@OptIn(ExperimentalResourceApi::class) @Composable fun App() { MaterialTheme { var greetingText by remember { mutableStateOf("Hello, World!") } var showImage by remember { mutableStateOf(false) } Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Button(onClick = { greetingText = "Hello, ${getPlatformName()}" showImage = !showImage }) { Text(greetingText) } AnimatedVisibility(showImage) { Image( painterResource("compose-multiplatform.xml"), null ) } } } }
У нас тут просто кнопка, що тригерить вхід / вихід логотипу Compose та змінює текст всередині кнопки на ім’я платформи. Зробімо щось цікавіше. Не дарма ж ми Cat Facts SDK мультиплатформенним робили.
Заходимо у build.gradle.kts
модуля shared
, біжимо до сорс-сету commonMain
та додаємо залежність:
val commonMain by getting { dependencies { ... implementation("com.bpavuk:catfacts-sdk:1.5.3") } }
І сінк впаде, от прям стовідсотково впаде через те, що немає SDK для айосу та нема репозиторію, у якому потрібні артефакти є. Я його нікуди, окрім maven local, не публікував, тому прибираємо підтримку iOS (сподіваюсь, тимчасово, бо це те місце, де Compose прям неочікувано бачити) та додаємо maven local у список репо проєкту. З першим ти зможеш розібратись і самотужки, ти ж точно читав попередні статті. А ось друге не так і очевидно робиться. Заходимо в settings.gradle.kts
нашого проєкту та у depencencyResolutionManagement
додаємо mavenLocal()
:
dependencyResolutionManagement { repositories { mavenCentral() google() maven("<a href="https://maven.pkg.jetbrains.space/public/p/compose/dev">https://maven.pkg.jetbrains.space/public/p/compose/dev</a>") // опусти очі трошки нижче mavenLocal() // тутечки // я сказав "трошки" } }
Що ж, синк тепер вдалий. Можемо почати користуватись нашим багатостраждальним SDK. Повертаємось до функції App()
. Тепер скопіпастуємо туди такий код і запускаємо:
val sdk = CatFacts()
@Composable fun App() { val coroutineScope = rememberCoroutineScope() MaterialTheme { var catFact by remember { mutableStateOf("") } Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween ) { AnimatedContent( catFact, transitionSpec = { slideInVertically() + fadeIn(tween()) togetherWith ExitTransition.None } ) { fact -> Text( fact, modifier = Modifier .fillMaxWidth() .padding(16.dp), textAlign = TextAlign.Center ) } Button( onClick = { coroutineScope.launch { catFact = sdk.getCatFact().fact } }, modifier = Modifier.padding(bottom = 80.dp) ) { Text("Show my cat fact!") } } } }
На Desktop усе працює пречудово, тим часом Android падає з незрозумілою помилкою:
android.system.GaiException: android_getaddrinfo failed: EAI_NODATA (No address associated with hostname)
І якщо замінити двигун у коді нашого SDK на OkHttp
, то можна отримати:
java.lang.SecurityException: Permission denied (missing INTERNET permission?)
І тут до мене доходить! Я настільки загрався у створення бібліотек під мультиплатформу, що навіть забув про те, що необхідність в інтернеті треба прописувати в маніфесті застосунку!
Добре, тепер усе працює. Поясню все крок за кроком:
… val coroutineScope = rememberCoroutineScope() …
На таку хитрість нам треба піти, бо в Compose Desktop немає ViewModel
-класів — вони ніяк Compose не стосуються, тільки AndroidX (Ілон Маск був би задоволений). На цьому скоупі ми виконуємо все, що потребує корутинної асинхронщини, наприклад, виклик SDK. Далі просто створюємо колонку на все вікно / екран, що автоматом робить усі елементи UI всередині рівновіддаленими та елайнить їх по центру:
Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween ) { ... }
Копіюємо анімацію переходу тексту з телефонів на Windows Phone (земля їм пухом, застосунок Xbox там — незамінна річ для юного поціновувача мобільних ігор):
… var catFact by remember { mutableStateOf("") } Column(...) { AnimatedContent( catFact, transitionSpec = { slideInVertically() + // fadeIn(tween()) togetherWith ExitTransition.None } ) { fact -> Text( fact, modifier = Modifier .fillMaxWidth() // без цього анімація може стрибати вбік // при великій зміні довжини факту, бо ширина сильно міняється .padding(16.dp), textAlign = TextAlign.Center ) } }
Та додаємо кнопку для отримання факту про котика на зручній для руки позиції:
Column(...) { ... Button( onClick = { coroutineScope.launch { catFact = sdk.getCatFact().fact } }, modifier = Modifier.padding(bottom = 80.dp) ) { Text("Show my cat fact!") } }
Результат на Android:
На Desktop:
І знаєте, що найкайфовіше? Абсолютно ні-я-ко-ї різниці між API Jetpack та Multiplatform! За досвідом з Kotlin Multiplatform можна сказати, що додавати платформи легко, коли вони допилені, і ми не втрачаємо доступ до платформ-специфічних API, та ще й на додачу отримуємо найкращий UI-фреймворк у світі!
А от щодо ресурсів, тут я нічого особливо не скажу. У останній версії Compose Desktop 1.5.0 з’явилися певні API, наприклад, painterResource
, але у них немає інтероперабельності з API Android. Звичайний painterResource
Android бере ID типу Int
, який ми добуваємо з класу R. Desktop у свою чергу очікує шлях до ресурсу, що розташований у папці resources
. Зі string-ресурсами взагалі біда. Був один плагін для Gradle, але він не працює на Desktop із Kotlin вище 1.8.0 та Compose вище 1.4.0, а там API анімацій не надто стабільне. А ось з iOS у цього плагіна все чудово. Але ж ми — не ми, якщо «милиці» не запхаємо! Тому не здаємось!
Пишемо «милиці»
Отже, план для кінцевого застосунку:
- задній фон — розмите зображення кошеняти (доведеться розмивати самотужки, бо у Compose це можна зробити тільки з Android 12 і далі, на решті платформ це працює без проблем);
- кнопка — у стилі Metro із Windows 10 Mobile;
- перший факт — має сам завантажуватись, щойно користувач відкриє застосунок.
Отже, наша перша милиця — для отримання зображення котика. Шукаємо фотографію котика без копірайту. Мені зайшов оцей красень. Завантажуємо його, розмиваємо, як вам подобається (я буду розмивати через Compose, дуже хочу погратися з цим модифікатором), кладемо у теку commonMain/resources/MR/images
та перейменовуємо в [email protected]
. І зараз нам знадобиться отой плагін, про який я говорив. Йдемо у build.gradle.kts
проєкту, у кінець додаємо таке:
buildscript { dependencies { classpath("dev.icerock.moko:resources-generator:0.23.0") } }
Тепер заходимо в модулі desktopApp
та shared
, у їхніх білд-файлах вмикаємо цей плагін:
plugins { id("dev.icerock.mobile.multiplatform-resources") }
Та додаємо ці залежності у модуль shared
:
val commonMain by getting { dependencies { ... api("dev.icerock.moko:resources:0.23.0") } }
Також у всіх сорс-сетах усіх модулів треба вручну прописати залежність від commonMain
:
val sourceSetMain by getting { dependsOn(commonMain) // або, якщо світить червоненьким: dependsOn(commonMain.get()) }
У кінці build.gradle.kts
кожного з модулів додаємо оцей блок конфігурації:
multiplatformResources { multiplatformResourcesPackage = "<a href="http://com.icerockdev.app/">com.icerockdev.app</a>" }
Він визначить пакет, де буде лежати клас MR
(читай: не Multiplatform R), яким ми користуватись не будемо.
Після синхронізації мають з’явитись таски на кшталт generateMRcommonMain
. Усього в проєкті чотири сорс-сети, отже мають бути чотири таски (iOS я у себе вимкнув, отже, у тебе має бути пʼять за умови наявності опіумного яблучного маку).
Запускаємо таску для коммон-сету нашого коду. Тепер можна і справжні «милиці» писати.
У кінці App.kt
додаємо експект на функцію, що поверне Painter
з котиком:
@Composable expect fun getCatImage(): Painter
Тепер біжимо писати actual-декларації у таких файлах, як main.android.kt
та main.desktop.kt
, кожний файл у своєму сорс-сеті. Ось під десктоп:
@Composable actual fun getCatImage(): Painter = painterResource("images/background_cat.jpg")
Та під Android:
@Composable actual fun getCatImage(): Painter = painterResource(R.drawable.background_cat)
Усе ж цей плагін виявився корисним, щоправда генерація expect/actual-методів для отримання ресурсів зламана.
У App.kt
увесь код у блоці MaterialTheme {...}
(той самий, що я замінив трьома крапками) обгорнемо у Box, нагорі боксу напишемо:
Image( painter = getCatImage(), contentDescription = null, modifier = Modifier .blur(32.dp), // прибери це, якщо заблюрив зображення якимось софтом contentScale = ContentScale.Crop )
Тепер запускаємо й на телефоні, і на компі:
Бачиш факти? І я ні. Кнопка вибивається зі стилю? Таааак... І це жах. Але зараз усе виправимо:
Text( fact, modifier = Modifier .fillMaxWidth() .padding(16.dp), textAlign = TextAlign.Center, color = Color.White, // тут щось новеньке fontWeight = FontWeight.Light // і тут )
Текст уже можна прочитати, але кнопка не Metro. Тож замінимо на Metro. У кінці, перед expect-ми, додаємо оцю красу:
private object NoRippleTheme : RippleTheme { @Composable override fun defaultColor() = Color.Unspecified @Composable override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f,0.0f,0.0f,0.0f) } @Composable fun MetroButton( onClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = MutableInteractionSource(), content: @Composable (RowScope.() -> Unit) ) { val rememberedInteractionSource = remember { interactionSource } val isPressed by rememberedInteractionSource.collectIsPressedAsState() CompositionLocalProvider(LocalRippleTheme provides NoRippleTheme) { Button( onClick = onClick, modifier = Modifier .padding(bottom = 80.dp) .border( width = 4.dp, color = Color.White ) .animateContentSize(tween()) .padding(if (isPressed) 0.dp else 2.dp) .then(modifier), colors = ButtonDefaults.buttonColors( backgroundColor = Color.Transparent, contentColor = Color.White ), elevation = ButtonDefaults.elevation( defaultElevation = 0.dp, pressedElevation = 0.dp, disabledElevation = 0.dp, hoveredElevation = 0.dp, focusedElevation = 0.dp ), interactionSource = rememberedInteractionSource, ) { content() } } }
Та заміняємо нашу єдину Button
на MetroButton
і трішки рихтуємо текст:
Text( text = "Show my cat fact!", fontWeight = FontWeight.Light, color = Color.White )
Анімація трішечки рвана, але мені ліньки ліпити кастомний лейаут лише заради того, щоб текст плавно тут переміщався.
Наш застосунок, можна сказати, готовий. Якщо тільки прибрати виліт за відсутності інтернету та вивести водночас відповідний текст, то в нас на руках буде і продакшен-ready версія. Дуже круто і те, що залишиться тільки дочекатись повноцінного Kotlin/Wasm, і його можна буде перенести у веб! Повноцінний! Не урізаний, а повноцінний вебзастосунок, і SDK там можна буде використовувати і без тих жахливих врапперів, які ми писали у третій частині.
Замість висновків
Ця стаття остання, але тільки для цього циклу, від мене статті ще будуть. Решту частин можна знайти тут. Сам собою Kotlin Multiplatform сируватий, але вже є багато сприятливих умов для розробки бібліотек та фреймворків, на яких можна буде базувати готові до продакшену програми та застосунки. Нелінива людина має зараз шанс вписати своє ім’я в історію, або стати героєм того мему про маленьку бібліотеку, яка підтримується однією людиною та на якій тримається вся екосистема світу, — тут як пощастить.
Але факт залишається фактом: давня мрія розробників потроху стає реальністю. Я вже можу написати набір кнопочок та лейаутів на Compose та використати його у JVM, Android, iOS, а скоро й у вебі. Уже можу написати SDK для взаємодії з API найрізноманітніших рівнів складності. Уже можу написати експлойт Dirty Pipe/Dirty COW або програму з використанням API Vulkan на Kotlin/Native завдяки його взаємодії з C. Це прорив, розумієш? Написати все це можеш і ти, головне — встроми лопату та спускайся вниз у цю кролячу діру. Ось цікаві місця, на яких ти можеш почати копати:
І найголовніше — конспектуй усе це. Роби пети, бався, шукай помилки та вади, пиши собі корисні утиліти, постарайся притягнути на прод, щоб замінити ту логіку, яку ти пишеш, просто дублюючи алгоритм декількома мовами, мікробібліотекою, котру буде ще й легше оновлювати. Присилай статті на [email protected], на Medium, словом, розвивай проєкт! Він реально класний та наближає мрію про мультиплатформену розробку чи не щотижня. На iOS/Android-фронті він уже може замінити Flutter, готується великий стрибок тигра у вебі під назвою Kotlin/Wasm.
По суті, ми зробили те, що донедавна було майже неможливим, — написали кросплатформу та не втратили доступ до нативних API. Так, місцями спіткнулись, але, як я казав, проєкт сируватий і «доготовлюється» ледь не щотижня. Буде щось місцями незрозуміло? Не соромся, пиши у слак Kotlin або мені в телеграмі — і там, і там тобі допоможуть. Бажаю удачі з мультиплатформеними застосунками!
До речі, увесь код застосунку є на GitHub.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів