Spec-Zone.ru › Kotlin 2

Делитесь большей частью логики между iOS и Android

Теперь, когда вы реализовали общую логику с помощью внешних зависимостей, можно добавить более сложную логику. Сетевые запросы и сериализация данных — это самые популярные сценарии использования Kotlin Multiplatform для совместного использования кода. Узнайте, как реализовать их в первом приложении, чтобы использовать полученные знания в будущих проектах после завершения этого вводного курса.

Обновленное приложение будет получать данные через интернет из API LaunchLibrary 2 и отображать описания последних космических запусков.

Итоговое состояние проекта можно найти в двух ветках нашего репозитория на GitHub, в которых используются разные решения для корутин:

  • ветка main содержит реализацию с KMP-NativeCoroutines,

  • ветка main-skie содержит реализацию с SKIE.

Добавьте дополнительные зависимости

В проект нужно добавить следующие мультиплатформенные библиотеки:

  • kotlinx.coroutines — для использования корутин при выполнении параллельных операций.

  • kotlinx.serialization — для десериализации JSON-ответов API SpaceX в объекты классов сущностей, используемые для обработки сетевых операций.

  • Ktor — фреймворк для отправки и получения данных по HTTP.

Обновите каталог версий Gradle

Добавьте следующие записи в gradle/libs.versions.toml, а затем синхронизируйте файлы Gradle, чтобы эти ссылки стали доступны в коде конфигурации сборки:

[versions]
coroutinesVersion = "1.11.0"
ktorVersion = "3.5.2"
# A Kotlin version should already be set in the catalog
kotlin = "2.4.20"

[libraries]
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorVersion" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorVersion" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorVersion" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktorVersion" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorVersion" }

[plugins]
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Добавьте зависимости в соответствующие наборы исходного кода

Добавьте ссылки на библиотеки в соответствующие наборы исходного кода в файле sharedLogic/build.gradle.kts:

plugins {
    // ...
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            // ...
            // The Kotlin Multiplatform Gradle plugin adds
            // platform-specific coroutines artifacts automatically
            implementation(libs.kotlinx.coroutines)
            // Main Ktor dependency
            implementation(libs.ktor.client.core)
            // Dependencies that allow Ktor to use serialization
            // with a specific format
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.kotlinx.json)
        }
        androidMain.dependencies {
            // Provides the Android engine for Ktor
            implementation(libs.ktor.client.android)
        }
        iosMain.dependencies {
            // Provides the Darwin engine for Ktor
            implementation(libs.ktor.client.darwin)
        }
    }
}

Синхронизируйте файлы Gradle, нажав кнопку Синхронизировать изменения Gradle.

Настройте запросы к API

Для получения данных вы будете использовать Launch Library API, а именно список всех запусков из конечной точки /2.3.0/launches.

Создайте модель данных

В каталоге sharedLogic/src/commonMain/.../greetingkmp создайте новый файл RocketLaunch.kt и добавьте класс данных, который хранит данные из API SpaceX:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class RocketLaunch(
    @SerialName("id")
    val id: String,
    @SerialName("name")
    val missionName: String,
    @SerialName("net")
    val launchDateUTC: String,
    @SerialName("status")
    val status: LaunchStatus,
)

@Serializable
data class LaunchStatus(
    @SerialName("id")
    val id: Int,
    @SerialName("name")
    val name: String,
)

@Serializable
data class LaunchListResponse(
    @SerialName("results")
    val results: List<RocketLaunch>,
)
  • Класс RocketLaunch помечен аннотацией @Serializable, чтобы плагин kotlinx.serialization мог автоматически создать для него сериализатор по умолчанию.

  • Аннотация @SerialName позволяет переопределить имена полей, чтобы объявлять свойства с более понятными именами в классах данных.

Подключите HTTP-клиент

  1. В каталоге sharedLogic/src/commonMain/.../greetingkmp создайте новый класс RocketComponent.

  2. Добавьте свойство httpClient для получения информации о запусках ракет с помощью HTTP GET-запроса:

    import io.ktor.client.HttpClient
    import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
    import io.ktor.serialization.kotlinx.json.json
    import kotlinx.serialization.json.Json
    
    class RocketComponent {
        private val httpClient = HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    prettyPrint = true
                    isLenient = true
                    ignoreUnknownKeys = true
                })
            }
        }
    }
    
    • Плагин Ktor ContentNegotiation и сериализатор JSON десериализуют результат GET-запроса.

    • Здесь сериализатор JSON настроен так, чтобы выводить JSON в более удобном для чтения виде с помощью свойства prettyPrint. Это обеспечивает большую гибкость при чтении некорректного JSON с помощью isLenient, а также позволяет игнорировать ключи, не объявленные в модели запуска ракеты, с помощью ignoreUnknownKeys.

  3. Добавьте в RocketComponent приостанавливающую функцию getDateOfLastSuccessfulLaunch(), которая будет асинхронно получать информацию о запусках ракет:

    import io.ktor.client.request.get
    import io.ktor.client.call.body
    
    class RocketComponent {
        // ...
    
        private suspend fun getDateOfLastSuccessfulLaunch(): String {
            val rockets: List<RocketLaunch> = httpClient.get("https://api.spacexdata.com/v4/launches").body()
    
            // Initialized with a stub date for now
            val date: String = "October 5, 2026"
    
            return "$date"
        }
    }
    
    • httpClient.get() также является приостанавливающей функцией, поскольку ей нужно асинхронно получать данные по сети, не блокируя потоки.

    • Приостанавливающие функции можно вызывать только из корутин или других приостанавливающих функций. Поэтому getDateOfLastSuccessfulLaunch() помечена ключевым словом suspend. Сетевой запрос выполняется в пуле потоков HTTP-клиента.

  4. После вызова HTTP-запроса добавьте вызов, который получает последний успешный запуск из списка (список запусков отсортирован по дате от самой ранней к самой поздней):

    class RocketComponent {
        // ...
    
        private suspend fun getDateOfLastSuccessfulLaunch(): String {
            val response: LaunchListResponse =
                httpClient.get("https://lldev.thespacedevs.com/2.3.0/launches/previous/?mode=list&limit=10&format=json").body()
            val lastSuccessLaunch = response.results.first { it.status.id == 3 }
            val date: String = "October 5, 2026"
    
            return "$date"
        }
    }
    
  5. Преобразуйте дату и время запуска в формате UTC в местную дату и присвойте результат переменной date. Затем верните отформатированный результат:

    import kotlinx.datetime.TimeZone
    import kotlinx.datetime.toLocalDateTime
    import kotlin.time.ExperimentalTime
    import kotlin.time.Instant
    
    class RocketComponent {
        // ...
    
        private suspend fun getDateOfLastSuccessfulLaunch(): String {
            val response: LaunchListResponse =
                httpClient.get("https://lldev.thespacedevs.com/2.3.0/launches/previous/?mode=list&limit=10&format=json").body()
            val lastSuccessLaunch = response.results.first { it.status.id == 3 }
            val date = Instant.parse(lastSuccessLaunch.launchDateUTC)
                .toLocalDateTime(TimeZone.currentSystemDefault())
    
            return "${date.month} ${date.day}, ${date.year}"
        }
    }
    

    Дата будет отображаться в формате «MMMM DD, YYYY», например «OCTOBER 5, 2022».

  6. Добавьте в этот же класс еще одну приостанавливающую функцию, launchPhrase(), которая создаст сообщение с помощью функции getDateOfLastSuccessfulLaunch():

    class RocketComponent {
        // ...
    
        suspend fun launchPhrase(): String =
            try {
                "The last successful launch was on ${getDateOfLastSuccessfulLaunch()} 🚀"
            } catch (e: Exception) {
                println("Exception during getting the date of the last successful launch $e")
                "Error occurred"
            }
    }
    

Создайте поток корутин

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

  1. Откройте файл Greeting.kt в каталоге shared/src/commonMain/kotlin.

  2. Добавьте свойство rocketComponent в класс Greeting. В этом свойстве будет храниться сообщение с датой последнего успешного запуска:

    class Greeting {
        private val rocketComponent = RocketComponent()
        //...
    }
    
  3. Измените функцию greet(), чтобы она возвращала Flow:

    import kotlinx.coroutines.delay
    import kotlinx.coroutines.flow.Flow
    import kotlinx.coroutines.flow.flow
    import kotlin.time.Duration.Companion.seconds
    
    class Greeting {
        // ...
        fun greet(): Flow<String> = flow {
            emit(if (Random.nextBoolean()) "Hi!" else "Hello!")
            delay(1.seconds)
            emit("Guess what this is! > ${platform.name.reversed()}")
            delay(1.seconds)
            emit(daysPhrase())
            emit(rocketComponent.launchPhrase())
        }
    }
    
    • Здесь Flow создается с помощью функции-конструктора flow(), которая оборачивает все операторы.

    • Flow выдает строки с интервалом в одну секунду. Последний элемент выдается только после получения ответа от сети, поэтому точная задержка зависит от вашего подключения.

Вы обновили API общего модуля, изменив тип возвращаемого значения функции greet() на Flow. Теперь нужно обновить нативные части проекта, чтобы они могли правильно обрабатывать результат вызова функции greet().

Обновите нативный интерфейс Android

Поскольку и общий модуль, и приложение для Android написаны на Kotlin, использовать общий код в Android просто.

Добавьте модель представления

Модели представления — популярный шаблон разработки Android, который помогает управлять данными и другими компонентами приложения, сохраняемыми в течение жизненного цикла Activity в Android. Теперь, когда приложение становится сложнее, пришло время добавить в него модель представления. Она будет хранить данные, полученные из API SpaceX, и предоставлять их интерфейсу.

Создайте класс модели представления в коде платформы Android:

  1. В каталоге sharedUI/src/commonMain/.../greetingkmp создайте новый класс Kotlin MainViewModel:

    import androidx.lifecycle.ViewModel
    
    class MainViewModel : ViewModel() {
        // ...
    }
    

    Этот класс расширяет класс ViewModel Android, чтобы соответствовать ожиданиям платформы относительно жизненного цикла и изменений конфигурации.

  2. Создайте значение greetingList типа StateFlow и его резервное свойство:

    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.flow.StateFlow
    
    class MainViewModel : ViewModel() {
        private val _greetingList = MutableStateFlow<List<String>>(listOf())
        val greetingList: StateFlow<List<String>> get() = _greetingList
    }
    
    • Здесь StateFlow реализует интерфейс Flow, но содержит только одно значение, или состояние.

    • Закрытое резервное свойство _greetingList гарантирует, что клиенты этого класса могут обращаться только к свойству greetingList, доступному только для чтения.

  3. В функции init модели представления соберите все строки из потока Greeting().greet():

    import androidx.lifecycle.viewModelScope
    import kotlinx.coroutines.launch
    
    class MainViewModel : ViewModel() {
       private val _greetingList = MutableStateFlow<List<String>>(listOf())
       val greetingList: StateFlow<List<String>> get() = _greetingList
    
       init {
           viewModelScope.launch {
               Greeting().greet().collect { phrase ->
                    //...
               }
           }
       }
    }
    

    Поскольку функция Flow.collect() является приостанавливающей, в области видимости модели представления используется корутина launch. Это означает, что корутина launch будет выполняться только на соответствующих этапах жизненного цикла модели представления.

  4. В завершающем лямбда-выражении collect добавьте собранное значение phrase в список фраз в _greetingList с помощью функции update():

    import kotlinx.coroutines.flow.update
    
    class MainViewModel : ViewModel() {
        //...
    
        init {
            viewModelScope.launch {
                Greeting().greet().collect { phrase ->
                    _greetingList.update { list -> list + phrase }
                }
            }
        }
    }
    

Используйте поток модели представления

  1. В sharedUI/src/commonMain/.../greetingkmp откройте файл App.kt и обновите его, заменив предыдущую реализацию на новую модель представления:

    import androidx.lifecycle.compose.collectAsStateWithLifecycle
    import androidx.compose.runtime.getValue
    import androidx.lifecycle.viewmodel.compose.viewModel
    
    @Composable
    @Preview
    fun App(mainViewModel: MainViewModel = viewModel()) {
        MaterialTheme {
            val greetings by mainViewModel.greetingList.collectAsStateWithLifecycle()
    
            Column(
                modifier = Modifier
                    .safeContentPadding()
                    .fillMaxSize(),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                greetings.forEach { greeting ->
                    Text(greeting)
                    HorizontalDivider()
                }
            }
        }
    }
    
    • Функция collectAsStateWithLifecycle() вызывает greetingList, чтобы получить значение из потока ViewModel и представить его в виде состояния Compose с учетом жизненного цикла.

    • При создании нового потока состояние композиции изменится и отобразит прокручиваемый Column с расположенными вертикально приветственными фразами, разделенными линиями.

Добавьте разрешение на доступ к интернету

Для доступа к интернету приложению Android требуется соответствующее разрешение. Поскольку все сетевые запросы выполняются из общего модуля, имеет смысл добавить разрешение на доступ к интернету в его манифест.

Обновите файл androidApp/src/main/AndroidManifest.xml, добавив разрешение на доступ:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>
    ...
</manifest>

Запустите приложение

Чтобы увидеть итоговый результат, повторно запустите конфигурацию запуска androidApp:

Final result for Android

Обновите нативный интерфейс iOS

Для части проекта, предназначенной для iOS, вы используете шаблон модель — представление — модель представления, как и в приложении Android, чтобы связать интерфейс с модулем sharedLogic.

Модуль уже импортирован в файл ContentView.swift с помощью объявления import SharedLogic.

Добавьте ViewModel

В iosApp/ContentView.swift создайте класс ViewModel для ContentView, который будет подготавливать данные и управлять ими. Вызовите функцию startObserving() внутри вызова task() для поддержки конкурентного выполнения:

import SwiftUI
import SharedLogic

struct ContentView: View {
    @ObservedObject private(set) var viewModel: ViewModel

    var body: some View {
        ListView(phrases: viewModel.greetings)
            .task { await self.viewModel.startObserving() }
    }
}

extension ContentView {
    @MainActor
    class ViewModel: ObservableObject {
        @Published var greetings: Array<String> = []
        
        func startObserving() {
            // ...
        }
    }
}

struct ListView: View {
    let phrases: Array<String>

    var body: some View {
        List(phrases, id: \.self) {
            Text($0)
        }
    }
}
  • ViewModel объявлена как расширение ContentView, поскольку они тесно связаны.

  • ViewModel содержит свойство greetings, представляющее собой массив фраз String.

SwiftUI связывает модель представления (ContentView.ViewModel) с представлением (ContentView):

  • ContentView.ViewModel объявлена как ObservableObject. Обертка @ObservedObject для свойства viewModel в ContentView подписывает представление на модель представления.

  • В свойстве модели представления greetings используется обертка @Published. Она позволяет SwiftUI автоматически обновлять представление при изменении этого свойства.

Теперь нужно реализовать функцию startObserving() для обработки потоков.

Выберите библиотеку для обработки потоков в iOS

В этом руководстве для работы с потоками в iOS можно использовать SKIE или библиотеку KMP-NativeCoroutines. Это решения с открытым исходным кодом, которые поддерживают отмену и обобщения для потоков, чего компилятор Kotlin/Native пока не предоставляет по умолчанию:

  • Библиотека KMP-NativeCoroutines помогает обрабатывать приостанавливающие функции и потоки в iOS, генерируя необходимые обертки. KMP-NativeCoroutines поддерживает функциональность Swift async/await, а также Combine и RxSwift. Для использования KMP-NativeCoroutines в проекты iOS нужно добавить зависимость SwiftPM или CocoaPod.

  • Библиотека SKIE расширяет API Objective-C, создаваемый компилятором Kotlin: SKIE преобразует потоки в аналог Swift AsyncSequence. SKIE напрямую поддерживает async/await Swift без ограничений по потокам и с автоматической двусторонней отменой (для Combine и RxSwift требуются адаптеры). SKIE предлагает и другие возможности для создания удобного для Swift API на основе Kotlin, включая преобразование различных типов Kotlin в соответствующие типы Swift. Кроме того, для работы SKIE не требуется добавлять дополнительные зависимости в проекты iOS.

Вариант 1. Настройте KMP-NativeCoroutines

Рекомендуем использовать последнюю версию библиотеки. Проверьте репозиторий KMP-NativeCoroutines, чтобы узнать, доступна ли более новая версия плагина и совместима ли она с вашей версией Kotlin.

  1. Добавьте версию KMP-NativeCoroutines и ссылку на плагин в каталог версий Gradle:

    [versions]
    kmpNativeCoroutines = "1.0.5"
    
    [plugins]
    kmpNativeCoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmpNativeCoroutines" }
    
  2. В корневом файле build.gradle.kts проекта (не в файле shared/build.gradle.kts) добавьте плагин KMP-NativeCoroutines в блок plugins {}:

    plugins {
        // ...
        alias(libs.plugins.kmpNativeCoroutines) apply false
    }
    
  3. В файле sharedLogic/build.gradle.kts добавьте плагин KMP-NativeCoroutines в блок plugins {}:

    plugins {
        // ...
        alias(libs.plugins.kmpNativeCoroutines)
    }
    
  4. В этом же файле sharedLogic/build.gradle.kts включите экспериментальную аннотацию @ObjCName:

    kotlin {
        // ...
        sourceSets{
            all {
                languageSettings {
                    optIn("kotlin.experimental.ExperimentalObjCName")
                }
            }
            // ...
        }
    }
    
  5. Нажмите кнопку Синхронизировать изменения Gradle, чтобы синхронизировать файлы Gradle.

Пометьте поток с помощью KMP-NativeCoroutines

  1. Откройте файл Greeting.kt в каталоге sharedLogic/src/commonMain/kotlin.

  2. Добавьте аннотацию @NativeCoroutines к функции greet(). Это позволит плагину сгенерировать код, необходимый для правильной обработки потоков в iOS:

     import com.rickclephas.kmp.nativecoroutines.NativeCoroutines
    
     class Greeting {
         // ...
    
         @NativeCoroutines
         fun greet(): Flow<String> = flow {
             // ...
         }
     }
    

Импортируйте библиотеку с помощью SwiftPM в Xcode

Установите необходимые для работы с механизмом async/await компоненты пакета KMP-NativeCoroutines Swift.

  1. Выберите File | Open Project in Xcode.

  2. В Xcode щелкните правой кнопкой мыши проект iosApp в меню слева и выберите Add Package Dependencies.

  3. В строке поиска введите имя пакета:

    https://github.com/rickclephas/KMP-NativeCoroutines.git
    
    Importing KMP-NativeCoroutines
  4. В раскрывающемся списке Dependency Rule выберите пункт Exact Version и введите версию 1.0.5 в соседнее поле.

  5. Нажмите кнопку Add Package. Xcode загрузит пакет с GitHub и откроет еще одно окно для выбора продуктов пакета.

  6. Добавьте в приложение «KMPNativeCoroutinesAsync» и «KMPNativeCoroutinesCore», как показано ниже, затем нажмите Add Package:

    Add KMP-NativeCoroutines packages
  7. Вернитесь в IntelliJ IDEA и выберите пункт меню Tools | Swift Package Manager | Resolve Dependencies. Будет создан файл блокировки Package.resolved, используемый сборкой Kotlin. Его можно добавить в репозиторий, чтобы обеспечить единообразие версий пакетов Swift.

Обработайте поток с помощью библиотеки KMP-NativeCoroutines

  1. В iosApp/ContentView.swift обновите функцию startObserving(), чтобы обрабатывать поток с помощью функции asyncSequence() из KMP-NativeCoroutines для функции Greeting().greet():

    func startObserving() async {
        do {
            let sequence = asyncSequence(for: Greeting().greet())
            for try await phrase in sequence {
                self.greetings.append(phrase)
            }
        } catch {
            print("Failed with error: \(error)")
        }
    }
    

    Здесь цикл и механизм await используются для перебора потока и обновления свойства greetings при каждом получении значения из потока.

  2. Убедитесь, что ViewModel помечена аннотацией @MainActor. Эта аннотация гарантирует, что все асинхронные операции внутри ViewModel выполняются в главном потоке, как того требует Kotlin/Native:

    // ...
    import KMPNativeCoroutinesAsync
    import KMPNativeCoroutinesCore
    
    // ...
    extension ContentView {
        @MainActor
        class ViewModel: ObservableObject {
            @Published var greetings: Array<String> = []
    
            func startObserving() async {
                do {
                    let sequence = asyncSequence(for: Greeting().greet())
                    for try await phrase in sequence {
                        self.greetings.append(phrase)
                    }
                } catch {
                    print("Failed with error: \(error)")
                }
            }
        }
    }
    

Вариант 2. Настройте SKIE

Чтобы настроить библиотеку, добавьте версию SKIE и ссылку на плагин в каталог версий Gradle:

[versions]
skie = "0.10.14"

[plugins]
skie = { id = "co.touchlab.skie", version.ref = "skie" }

SKIE может не поддерживать последнюю версию Kotlin. Если ваша версия Kotlin слишком новая, об этом будет сообщено при синхронизации Gradle вместе со списком версий, на которые можно безопасно перейти.

Затем добавьте его в список плагинов в файле sharedLogic/build.gradle.kts и нажмите кнопку Синхронизировать изменения Gradle:

plugins {
    //...
    alias(libs.plugins.skie)
}

Обработайте поток с помощью SKIE

Вы будете использовать цикл и механизм await для перебора потока Greeting().greet() и обновления свойства greetings при каждом получении значения из потока.

При использовании SKIE IntelliJ IDEA и Android Studio могут ошибочно сообщать об ошибках Swift при вызовах кода Kotlin. Это известная проблема библиотеки, которая не влияет на сборку и запуск приложения.

Убедитесь, что ViewModel помечена аннотацией @MainActor. Эта аннотация гарантирует, что все асинхронные операции внутри ViewModel выполняются в главном потоке, как того требует Kotlin/Native:

// ...
extension ContentView {
    @MainActor
    class ViewModel: ObservableObject {
        @Published var greetings: [String] = []

        func startObserving() async {
            for await phrase in Greeting().greet() {
                self.greetings.append(phrase)
            }
        }
    }
}

Используйте ViewModel и запустите приложение iOS

В iosApp/iOSApp.swift обновите точку входа приложения:

@main
struct iOSApp: App {
   var body: some Scene {
       WindowGroup {
           ContentView(viewModel: ContentView.ViewModel())
       }
   }
}

Запустите конфигурацию iosApp из IntelliJ IDEA, чтобы убедиться, что логика приложения синхронизирована:

Final results

Итоговое состояние проекта можно найти в двух ветках нашего репозитория на GitHub, в которых используются разные решения для корутин:

  • ветка main содержит реализацию с KMP-NativeCoroutines,

  • ветка main-skie содержит реализацию с SKIE.

Следующий шаг

В заключительной части руководства вы завершите работу над проектом и узнаете, что делать дальше.

Перейти к следующей части

См. также

  • Изучите различные подходы к композиции приостанавливающих функций.

  • Узнайте больше о взаимодействии с фреймворками и библиотеками Objective-C.

  • Пройдите руководство по сетевому взаимодействию и хранению данных.

Получите помощь

  • Kotlin Slack. Получите приглашение и присоединитесь к каналу #multiplatform.

  • Система отслеживания ошибок Kotlin. Сообщите о новой проблеме.

21 июля 2026 г.
Добавьте зависимости в проектЗавершите работу над проектом

© 2010–2026 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/multiplatform/multiplatform-upgrade-app.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API