Розбираємо Kotlin — «тортик», що може перебувати у декількох світах одночасно (спойлер — до 10)

Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!

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

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

У попередній частині ми з тобою вивчали Ktor, отримували цікаві факти про котів з інтернету за його допомогою та розширяли його функціонал за допомогою плагінів. Ця стаття буде як «міст» між Ktor та наступною темою — Kotlin/JS, цікавинкою для фронтендерів та тих, хто просто працює з JavaScript/TypeScript.

На відміну від попередньої статті ми сильніше заглибимось у технічні деталі, тому буде... Ще цікавіше, щойно розберемось з Gradle. Він — єдина гірчинка, яка може повернути вас із мрій про запуск вашої прошивки для систем ППО як на Patriot, так і на «Буках», до жорстокої реальності, де все ще треба для початку сконфігурувати. Цим кодинг мені нагадує кулінарію: щоб спробувати щось дійсно смачне, спочатку приготуй.

Розказувати про те, наскільки смачний певний торт можна нескінченно, особливо коли мова про Kotlin Multiplatform, але давайте ми вже його приготуємо, тоді ви і самі спробуєте, і похизуєтесь перед колегами — бо вам точно буде, чим хизуватись. Записуйте рецепт. Вам знадобиться:

  • IntelliJ IDEA Community (або Ultimate, у моєму випадку);
  • Оцей GitHub-репозиторій (починати новий мультиплатформенний проєкт з нуля завжди легше, але мігрувати набагато швидше і не сильно складніше, а час — це гроші);
  • І моя попередня стаття. Повірте, вона бодай настрій вам підніме.

Кладемо усе на стіл

Клонуємо GitHub-репозиторій, занурюємось у src/main/kotlin. Там лежить модель факту про котів та Main.kt. Запустімо код, що у Main.kt:

SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
{"fact":"A cat will tremble or shiver when it is in extreme pain.","length":56}
A cat will tremble or shiver when it is in extreme pain.

Process finished with exit code 0

Ні, цей факт якийсь сумний

Like humans, cats tend to favor one paw over another

А тепер мені навіть цікаво, чи моя Кицюня шульга?

Наша задача — зробити з цього коду функцію, яку з легкістю зможе використати і наша дівчина-iOSниця, і Васька-фронтендер, і всілякі Бодьки-павуки у своїх мультиплатформенних бібліотеках, і навіть нативну бібліотеку (так, сішники, Kotlin працює з C).

Що там по Gradle

Але спочатку треба готовий код бодай скомпілювати у щось рідніше, аніж Java-байткод. На щастя, у нас нема ні легасі-бабць на борту, ні чогось Java-специфічного

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile  
  
plugins {  
    kotlin("jvm") version "1.8.21"  
    kotlin("plugin.serialization") version "1.8.21"  
}  
  
group = "com.bpavuk"  
version = "1.0-SNAPSHOT"  
  
repositories {  
    mavenCentral()  
}  
  
val ktor_version = "2.3.2"  
  
dependencies {  
    implementation("io.ktor:ktor-client-core:$ktor_version") // ядро, core
    implementation("io.ktor:ktor-client-cio:$ktor_version") // двигун, engine
    implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") // плагін ContentNegotiation
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") // Serialization API
}  
  
tasks.test {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {  
    kotlinOptions.jvmTarget = "1.8"
}

По суті нам треба прибрати tasks.test, tasks.withType<KotlinCompile>, тимчасово закоментувати dependencies та замінити kotlin("jvm") на kotlin("multiplatform"), ну і заодно оновити до Kotlin 1.9, потім засинкувати і додати у кінець файлу цей блок коду (обережно: воно велике, не намагайтесь зрозуміти прям тут і зараз!):

kotlin {  
    jvm {  
        jvmToolchain(8)  
    }
    linuxX64()
    linuxArm64()
    iosArm64()  
    iosX64()  
    iosSimulatorArm64()  
  
    sourceSets {  
        val commonMain by getting {  
            dependencies {  
                implementation(kotlin("stdlib"))  
            }  
        }  
        val commonTest by getting  
  
        val iosX64Main by getting  
        val iosArm64Main by getting  
        val iosSimulatorArm64Main by getting  
  
        val iosMain by creating {  
            dependsOn(commonMain)  
            iosX64Main.dependsOn(this)  
            iosArm64Main.dependsOn(this)  
            iosSimulatorArm64Main.dependsOn(this)  
        }
  
        val iosX64Test by getting  
        val iosArm64Test by getting  
        val iosSimulatorArm64Test by getting  
        val iosTest by creating {  
            dependsOn(commonTest)
            iosX64Test.dependsOn(this)
            iosArm64Test.dependsOn(this)
            iosSimulatorArm64Test.dependsOn(this)
        }
    }  
}

І не поспішайте кидатись помідорами за такий великий шмат незрозумілого скрипту! Зараз я все поясню, зрештою, це не кінець світу

jvm {
    jvmToolchain(8)
}
linuxX64()
linuxArm64()
iosArm64()
iosX64()
iosSimulatorArm64()

Ось тут ми кажемо які платформи доступні та додатково їх налаштовуємо. Наприклад, для джави ми обираємо 8-му версію, а з платформ Apple доки додаємо тільки iOS — айфони (iosArm64), та симулятори (iosX64 для Intel-маків та iosSimulatorArm64 для M-чипів). Вибачте, маководи, доки тільки через симулятори. Здогадайтесь, де тут Linux — хто вгадає того пригощу тортом.

sourceSets {  
    val commonMain by getting {  
        dependencies {  
            implementation(kotlin("stdlib"))  
        }  
    }  
    val commonTest by getting  

    val iosX64Main by getting  
    val iosArm64Main by getting  
    val iosSimulatorArm64Main by getting  

    val iosMain by creating {  
        dependsOn(commonMain)  
        iosX64Main.dependsOn(this)  
        iosArm64Main.dependsOn(this)  
        iosSimulatorArm64Main.dependsOn(this)  
    }

    val iosX64Test by getting  
    val iosArm64Test by getting  
    val iosSimulatorArm64Test by getting  
    val iosTest by creating {  
        dependsOn(commonTest)
        iosX64Test.dependsOn(this)
        iosArm64Test.dependsOn(this)
        iosSimulatorArm64Test.dependsOn(this)
    }
}  

Знайомтесь, сурки сурс-сети (Gradle Source Sets) — вони тримають у собі код для певної платформи. Kotlin Multiplatform може створити деякі сети за вас (JS, наприклад), там же ми можемо прописати усі необхідні залежності (зверніть увагу, як виблискує блок dependencies у commonMain. Не пригадуєте, який шмат коду ми закоментували?).

Але деяких сетів просто нема: наприклад, iosMain — сету, котрий об’єднує код для симуляторів та айфону (отут-то я хочу дати JetBrains по одному місцю). Сети, яких нема, ми створюємо через by creating, а які є — налаштовуємо через by getting.

Засинкуємо...

Some Kotlin/Native targets cannot be built on this linux_x64 machine and are disabled:
    * In project ':':
        * targets 'iosArm64', 'iosX64', 'iosSimulatorArm64' (can be built with one of the hosts: macos_x64, macos_arm64)
To hide this message, add 'kotlin.native.ignoreDisabledTargets=true' to the Gradle properties.

Твою дивізію, Apple! Хоча, це дуже на тебе схоже. Добре, не зважаймо. Маководи, залиште коментар з результатами у кінці.

Краще звернімо увагу на панель Project Files — там пропали значки:

Хороший знак — сорс-сети працюють. Давайте клацнемо правою кнопкою миші по src та створимо теку commonMain/kotlin та перетягнемо туди всі файли з src/main/kotlin.

Відкриваємо Main.kt таааа... чуміємо від купи помилок:

Залежності... Пригадую, як я грав у Have a Nice Death, гра про те як Смерть почав вигоряти, там є локація «відділ залежностей». Місцина — одні лабіринти. А ми досі кутузимось з Gradle, котрий теж є лабіринтом для новачків у мультиплатформі, та при міграції проєктів не легше. Повертаємось у build.gradle.kts та знаходимо сет commonMain.

Змінюємо на оце:

val commonMain by getting {
    dependencies {
        implementation(kotlin("stdlib"))
        implementation("io.ktor:ktor-client-core:$ktor_version") // ядро, core
        implementation("io.ktor:ktor-client-cio:$ktor_version") // двигун, engine
        implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") // плагін ContentNegotiation
        implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") // Serialization API
    }
}

Синкуємо... Бам, все чудово. ./gradlew linuxX64Binaries працює на відмінно, мій внутрішній пінгвін щасливий. Gradle тепер офіційно налаштований.

Можна перетворювати код у бібліотеку.

Абра-бібліотеко-дабра

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

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

Користуємось перевагами усіх світів одночасно, або expect-actual схема у дії

Почнімо з фабрики, котра поверне нам наш клієнт, заодно познайомимось із тим, як писати код під конкретну платформу. Створимо функцію getClient, але на відміну від звичайних функцій давайте зробимо її мультиплатформенною.

Але чому саме її? Тому, що нам треба видавати клієнти, ідентичні за поведінкою, але з різними двигунами. У файлі ClientFactory.kt:

internal expect fun getClient(): HttpClient 

У IntelliJ ця функція засвітиться червоним, натиснемо Alt+Enter. Перед нами вискочить вікно, у якому можна обрати модулі, у які модулі хоче вставити платформ-специфічний код компілятор. Обираємо три модулі — iosMain, jvmMain та linuxX64Main. Усе буде заповнено TODO-хами. У цих трьох платформах підтримується двигун CIO, тому всюди код буде однаковий:

internal actual fun getClient(): HttpClient = HttpClient(CIO) {  
    install(ContentNegotiation) { // серіалізація  
        json(  
            Json {  
                ignoreUnknownKeys = true  
            }  
        )  
    }  
    install(HttpCookies) // cookies  
    install(UserAgent) {  
        agent = "My cool secret user agent"  
    }  
    install(DefaultRequest) { // запити за замовчуванням  
        url {  
            host = "catfact.ninja"  
            protocol = URLProtocol.HTTPS  
        }  
    }  
}

Якби я був якоюсь сутністю, що існує водночас в декількох світах, на місці свого творця (еволюції, природи, Бога, Аллаха, кому як) я би також подумав про те, що за поведінка очікується загалом, прописав би свій expect, а коли діло йде до реалізації очікувань у кожному світі був би свій actual, котрий має доступ до всього, що є у рідному світі, але має притримуватися такої самої поведінки, як і у інших світах.

Тут проявляється головна відмінність від Flutter та React Native — замість того, щоб принести цілий світ у чужий світ, Kotlin Multiplatform легко користується перевагами свого світу, у якому він запускається, але Stdlib таки приносить маленьку частину свого світу.

Об’єднуємо світи без колапсу часопросторового континууму

Настав час скористатись кодом із common-світу, який вже сам адаптується до кожної з платформ. Для цього можна написати клас CatFacts, котрий вже візьме та поєднає Linux, iOS та Java.

У сеті commonMain створюємо клас CatFacts:

public class CatFacts {
    private val client = getClient()

    public suspend fun getCatFact(maxLength: Int? = null): Fact = client.get("fact") {  // get-запит до API
        url {
            if (maxLength != null) parameters.append("max_length", "$maxLength")
        }
    }.body()
}

Фсьо! Тепер користувач може просто написати у себе в програмі CatFacts().getCatFact() та отримати факт з будь-якої з трьох платформ. Чи ні?..

Якщо щось є та це щось неопубліковане — цього не існує

У Gradle треба прописати один цікавий сніпет, він змусить нас завжди вказувати область видимості. Це також дозволить опублікувати бібліотеку у Maven-репозиторіях.

kotlin {
    explicitApi() // оцей-во флаг
    jvm {
        ...
    }
    ...
}
plugins {
    ...
    // у кінці блоку plugins
    id("org.gradle.maven-publish") // цей плагін
}
// та цей блок у кінці файлу
publishing {  
    repositories {  
        maven {  
          
        }  
    }  
}

І все! Після синку у вікні Gradle з’явиться папка publishing

Нас цікавить саме publishToMavenLocal. Щойно опублікуємо, зможемо її використати. В мене це зайняло півгодинки бо я недавно чистив Gradle-кеш, та для компіляції Linux-таргету треба LLVM (не переживай, твій розум залишиться на місці цілим та неушкодженим, плагін Gradle для Kotlin Multiplatform навіть встановить його сам).

Рідні платформи — наскільки вони рідні?

Щойно опублікується, відкриваємо візард нового проєкту та створюємо Kotlin/Native CLI застосунок. Його код буде тут. Додаємо залежність у Gradle:

sourceSets {
    val nativeMain by getting {
        dependencies {
            implementation("com.bpavuk:catfacts-sdk:1.0-SNAPSHOT")
        }
    }
    val nativeTest by getting
}

Та синкуємо. Залишається тільки прописати у Main.kt:

fun main() = runBlocking {
    val sdk = CatFacts()

    println(sdk.getCatFact().fact)
}

Руки чешуться запустити. Запускаю... Ііііііі........ Чорт:

Uncaught Kotlin exception: kotlin.IllegalStateException: TLS sessions are not supported on Native platform.

Так, я забув про те що Kotlin/Native доки не підтримує TLS (HTTPS). Але це легко виправити. Ідемо у catfacts-sdk, сет linuxX64Main. Там редагуємо ClientFactory.linuxX64.kt:

install(DefaultRequest) {  // запити за замовчуванням
    url {
        host = "catfact.ninja"
        protocol = URLProtocol.HTTP // міняємо ось цю лінію
    }
}

І build.gradle.kts:

group = "com.bpavuk"  
version = "1.1-SNAPSHOT" // міняємо версію з 1.0 на 1.1

Але стійте, чи це дійсно правильний фікс? Хоч усе й працює, але... Просто отак відмовлятись від HTTPS? Звісно ні, треба просто замінити двигун. У двигуна CIO є одне «але» — нема підтримки HTTPS на нативних платформах.

Отже, треба інший двигун. Який іще двигун може запускатись на Linux, але працює з HTTPS? Тільки cURL. Тільки хардкор без вебсокетів.

Тепер подумаємо про айфони — вони також нативна платформа. Там підійде Darwin. Він таргетиться під усі еплові платформи, бо використовує NSURLSession під капотом. Тепер залишається тільки додати залежності. Відкриваємо build.gradle.kts і додаємо сорс-сет linuxX64Main:

val linuxX64Main by getting {
    dependencies {
        implementation("io.ktor:ktor-client-curl:$ktor_version") // двигун, engine
    }
}

Та доповнюємо iosMain:

val iosMain by creating {
    ...
    iosSimulatorArm64Main.dependsOn(this)

    dependencies {
        implementation("io.ktor:ktor-client-darwin:$ktor_version") // двигун, engine
    }
}
Синкуємо, міняємо двигуни у файлах ClientFactory.linuxX64.kt та його копії з сорс-сету iosMain. Спочатку Linux:
internal actual fun getClient(): HttpClient = HttpClient(Curl) { // тут Curl
    ...
}

Тепер його iOS-клон:

internal actual fun getClient(): HttpClient = HttpClient(Darwin) { // тут премія відомого вченого за найдебільніше падіння програми
    ...
}

Всьо! Тепер можна публікувати у мавен-локал. Але якщо ви випадково вже опублікували 1.1, то можна сміло стрибнути на 1.2. Я взагалі у процесі написання статті дійшов до 1.4:

version = "1.4-SNAPSHOT"

Гадаю, завдяки цій біганині у Gradle ви вже знаєте, де додавати залежності. Мені для білду прикладу у Linux довелось іще встановити libcurl. Внизу будуть зручні копіпасти для встановлення цієї бібліотеки:

Fedora

sudo dnf install libcurl-devel

Debian та базовані

sudo apt install libcurl4-openssl-dev

Arch та базовані

sudo pacman -Syu libcurl

SUSE (снюс?)

sudo zypper install libcurl-devel

Запускаємо... Іііі... Працює!

> Task :runDebugExecutableNative
Cats have 30 teeth (12 incisors, 10 premolars, 4 canines, and 4 molars), while dogs have 42. Kittens have baby teeth, which are replaced by permanent teeth around the age of 7 months.

До речі, наш екзекьютабл буде валятись десь тут

Давайте побавимось трохи. Додамо аргументи у main:

fun main(args: Array<String>) = runBlocking {
    ...
}

І використаємо:

println(sdk.getCatFact(args.firstOrNull()?.toIntOrNull()).fact)

Бачте! Тепер завдяки нашому SDK користувачі Linux та iOS-розробники вже можуть на своїх платформах отримувати котячі факти:

fun main(args: Array<String>) = runBlocking {
    val sdk = CatFacts()

    println(sdk.getCatFact(args.firstOrNull()?.toIntOrNull()).fact)
}

І наш kexe буде працювати з аргументом:

$ ./build/bin/native/releaseExecutable/catfacts-sdk-example.kexe 20
Cats have 3 eyelids.
$ ./build/bin/native/releaseExecutable/catfacts-sdk-example.kexe 30
Cats have supersonic hearing
$ ./build/bin/native/releaseExecutable/catfacts-sdk-example.kexe 500
Approximately 40,000 people are bitten by cats in the U.S. annually.

Окей, дівчина-iOSниця нарадуватись не може, Бодька-павук вже ліпить віджет на своєму Arch Linux у панчохах жовто-синього кольору перемоги, а про когось забули... Точно! Фронтендер!

Агооов! Нативки це звісно харашо, але JS’у як бути? (Спойлер — добре бути)

Нічого поганого! Нормально бути. Просто додамо підтримку іще однієї платформи. Заходимо у build.gradle.kts нашого SDK та прямо над сорс-сетами пишемо:

...
iosSimulatorArm64()
js(IR) {        // нова лінія
    browser()   // нова лінія
    nodejs()    // нова лінія
}
explicitApi()

sourceSets { ...

Прибираємо залежність двигуна CIO з commonMain і додаємо нові сорс-сети:

sourceSets {
    ...
    val jsMain by getting {
        dependencies {
            implementation("io.ktor:ktor-client-js:$ktor_version") // двигун, engine
        }
    }

    val jvmMain by getting {
        dependencies {
            implementation("io.ktor:ktor-client-cio:$ktor_version") // двигун, engine
        }
    }
}

Оскільки CIO не має версії під JS, його присутність у commonMain наробить помилок, тому його треба перенести в ті сорс-сети там, де він потрібен.

У залежностях jsMain тепер інший двигун: Js. Цей двигун — єдиний, що підтримує JavaScript. Після синку заходимо у ClientFactory.kt, сет commonMain. Він буде світитись червоним у IntelliJ, бо не має свого actual у jsMain. Alt+Enter, Enter, у вискочившому вікні помічаємо галочкою сет jsMain.

Вийде файл ClientFactory.js.kt, там буде актуал-функція, але з TODO:

internal actual fun getClient(): HttpClient {
    TODO("Not yet implemented")
}

Увесь той код ми багато разів повторювали, тому наводжу зручну копіпасту:

internal actual fun getClient(): HttpClient = HttpClient(Js) { // єдина відмінність від інших функцій - двигун
    install(ContentNegotiation) {  // серіалізація
        json(
            Json {
                ignoreUnknownKeys = true
            }
        )
    }
    install(HttpCookies) // cookies
    install(UserAgent) {
        agent = "My cool secret user agent"
    }
    install(DefaultRequest) {  // запити за замовчуванням
        url {
            host = "catfact.ninja"
            protocol = URLProtocol.HTTPS
        }
    }
}

Все! Тепер наш SDK підтримує JS. Напевне, ви дуже втомились, поки читали цей лонгрід. Тримати в голові водночас багато платформ складно, але ми разом досягли цього.

За бажання? можете додати підтримку Windows та macOS. Наразі наш проєкт підтримує JVM, JS, iOS та Linux.

Вишенька на торті, що перебуває у 4 світах та не ламає часопростір

Kotlin Multiplatform — найбільший у світі ефорт у мультиплатформенній розробці та одна з найгнучкіших імплементацій цього підходу. Навіть Netflix її використовує.

Так, в залежності від платформи у неї свої проблеми, але вони здебільшого тривіальні, як-от вибір двигуна. Звісно, якщо вам конче треба вебсокети на Linux, але так само конче, то раджу подивитись у сторону fat jar’ів. Унизу як завжди цікавинки:

Невеличке прохання

Мене сильно вабить можливість писати під iOS і при цьому не переписувати логіку, але жорстка політика Apple змушує мене купити Mac. З причин, які я відкрию у наступній статті я не можу собі його дозволити, і, словом, буду радий якщо ви бодай зайдете на мій buy me a coffee.

Щойно я куплю Mac mini, зможу писати статті про інтероп Kotlin зі Swift, різноманітні use cases та про те, як підтримувати порядок у штормі з платформ, а ви можете цьому посприяти. Навіть якщо ви просто скинете цю статтю знайомим я вже буду неймовірно вдячний.

Дякую спільноті за щиру реакцію на попередню статтю, у мене великі історії та записи зазвичай виходять жахливими.

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному2
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

Як впізнати автора по заголовку :)

Статтю поки не читав, але вона точно в моєму списку.

Дякую за приємні слова :), з третьою частиною у мене проблєми бо я більше на C пишу аніж на JS, і хочеться розповісти про Kotlin/JS (я дійсно часто пишу на С та смикаю сішні функції через Kotlin/Native). Часом не знаєте шлях якомога швидше вивчити JS на рівні «можу створити пару реакт-компонентів, але не факт що все про них розумітиму»?

UPD: ChatGPT допоміг мені з JS-частиною, все чудово

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