Spec-Zone.ru › Kotlin 2

Создание мультиплатформенного приложения с помощью Ktor и SQLDelight

В этом руководстве показано, как с помощью IntelliJ IDEA создать продвинутое мобильное приложение для iOS и Android на Kotlin Multiplatform. Это приложение будет выполнять следующие задачи:

  • Получать данные через интернет из общедоступной Launch Library с помощью Ktor

  • Сохранять данные в локальной базе данных с помощью SQLDelight.

  • Отображать список запусков космических ракет вместе с датой запуска, результатами и подробным описанием запуска.

В приложение войдёт модуль с общим кодом для платформ iOS и Android. Бизнес-логика и уровни доступа к данным будут реализованы только один раз в общем модуле, а пользовательский интерфейс обоих приложений будет нативным.

Emulator and Simulator

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

  • Ktor в качестве HTTP-клиента для получения данных через интернет.

  • kotlinx.serialization для десериализации ответов JSON в объекты классов сущностей.

  • kotlinx.coroutines для написания асинхронного кода.

  • SQLDelight для генерации кода Kotlin из SQL-запросов и создания типобезопасного API базы данных.

  • Koin для предоставления драйверов базы данных для конкретных платформ с помощью внедрения зависимостей.

В нашем репозитории GitHub вы найдёте шаблонный проект, а также исходный код готового приложения.

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

  1. В кратком руководстве выполните инструкции по настройке среды для разработки на Kotlin Multiplatform.

  2. В IntelliJ IDEA выберите Файл | Создать | Проект.

  3. На панели слева выберите Kotlin Multiplatform (в Android Studio шаблон находится на вкладке Общие мастера Создание проекта).

  4. В окне Новый проект укажите следующие параметры:

    • Название: SpaceTutorial

    • Идентификатор проекта: com.jetbrains.spacetutorial

  5. Выберите целевые платформы Android и iOS.

  6. Для iOS выберите параметр Не предоставлять общий доступ к пользовательскому интерфейсу. Для обеих платформ вы реализуете нативный пользовательский интерфейс.

  7. Указав все параметры и целевые платформы, нажмите Создать.

    Create Ktor and SQLDelight Multiplatform project

Добавление зависимостей Gradle

Чтобы добавить мультиплатформенную библиотеку в общий модуль, добавьте инструкции для зависимостей (implementation) в блок dependencies {} соответствующих наборов исходного кода в файле build.gradle.kts модуля.

Для библиотек kotlinx.serialization и SQLDelight также требуется дополнительная настройка.

Измените или добавьте строки в каталоге версий в файле gradle/libs.versions.toml, указав все необходимые зависимости:

  1. В блоке [versions] проверьте версию AGP и добавьте остальные параметры:

    [versions] agp = "9.0.1" material3 = "1.11.0-alpha07" # ... coroutinesVersion = "1.11.0" dateTimeVersion = "0.8.0" koin = "4.2.2" ktor = "3.5.2" sqlDelight = "2.3.2"
  2. В блоке [libraries] добавьте следующие ссылки на библиотеки:

    [libraries] ... koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "dateTimeVersion" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" }
  3. В блоке [plugins] укажите необходимые плагины Gradle:

    [plugins]
    # ...
    kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
    sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }
    
  4. После обновления каталога версий появится запрос на повторную синхронизацию проекта. Нажмите кнопку Синхронизировать изменения Gradle, чтобы синхронизировать файлы Gradle: Synchronize Gradle files

  5. В самом начале файла sharedLogic/build.gradle.kts добавьте следующие строки в блок plugins {}:

    plugins {
        // ...
        alias(libs.plugins.kotlinxSerialization)
        alias(libs.plugins.sqldelight)
    }
    
  6. Для общего набора исходного кода требуется основной артефакт каждой библиотеки, а также функция сериализации Ktor для использования kotlinx.serialization. Для наборов исходного кода iOS и Android также нужны драйверы SQLDelight и Ktor для соответствующих платформ.

    В том же файле sharedLogic/build.gradle.kts добавьте все необходимые зависимости:

    kotlin {
        // ...
    
        sourceSets {
            commonMain.dependencies {
                implementation(libs.kotlinx.coroutines.core)
                implementation(libs.ktor.client.core)
                implementation(libs.ktor.client.content.negotiation)
                implementation(libs.ktor.serialization.kotlinx.json)
                implementation(libs.sqldelight.runtime)
                implementation(libs.kotlinx.datetime)
                implementation(libs.koin.core)
            }
            androidMain.dependencies {
                implementation(libs.ktor.client.android)
                implementation(libs.sqldelight.android.driver)
            }
            iosMain.dependencies {
                implementation(libs.ktor.client.darwin)
                implementation(libs.sqldelight.native.driver)
            }
        }
    }
    
  7. Указав зависимости, ещё раз нажмите кнопку Синхронизировать изменения Gradle, чтобы обновить файлы Gradle.

После синхронизации Gradle настройка проекта завершена, и можно приступать к написанию кода.

Подробное руководство по мультиплатформенным зависимостям см. в разделе Зависимость от библиотек Kotlin Multiplatform.

Создание модели данных приложения

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

  • Общие сведения о запуске

  • Ссылки на изображения эмблем миссий

  • URL-адреса статей, связанных с запуском

К концу этого руководства в пользовательском интерфейсе будут использоваться не все эти данные. Модель данных нужна нам, чтобы показать сериализацию. Но вы можете поэкспериментировать со ссылками и эмблемами и расширить пример, добавив больше полезной информации!

Создайте необходимые классы данных:

  1. В каталоге sharedLogic/src/commonMain/kotlin/com/jetbrains/spacetutorial создайте пакет entity, а затем файл Entity.kt в этом пакете.

  2. Объявите все классы данных для основных сущностей:

    import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.time.Instant @Serializable data class LaunchStatus( @SerialName("id") val id: Int, @SerialName("name") val name: String, @SerialName(value = "description") val description: String ) @Serializable data class LaunchListResponse( @SerialName("results") val results: List<RocketLaunch>, ) @Serializable data class RocketLaunch( @SerialName("id") val id: String, @SerialName("name") val missionName: String, @SerialName("net") val launchDateUTC: String, @SerialName(value = "image") val image: Image, @SerialName(value = "status") val status: LaunchStatus, ) { var launchYear = Instant.parse(launchDateUTC).toLocalDateTime(TimeZone.UTC).year } @Serializable data class Image( @SerialName("thumbnail_url") val small: String, @SerialName("image_url") val large: String, )

Каждый сериализуемый класс должен быть помечен аннотацией @Serializable. Плагин kotlinx.serialization автоматически генерирует сериализатор по умолчанию для классов @Serializable, если только вы явно не укажете ссылку на сериализатор в аргументе аннотации.

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

Настройка SQLDelight и реализация логики кэширования

Библиотека SQLDelight позволяет генерировать типобезопасный API базы данных Kotlin из SQL-запросов. Во время компиляции генератор проверяет SQL-запросы и преобразует их в код Kotlin, который можно использовать в общем модуле.

Настройка SQLDelight

Зависимость SQLDelight уже добавлена в проект. Чтобы настроить библиотеку, откройте файл sharedLogic/build.gradle.kts и добавьте в его конец блок sqldelight {}. Этот блок содержит список баз данных и их параметры:

sqldelight {
    databases {
        create("AppDatabase") {
            packageName.set("com.jetbrains.spacetutorial.cache")
        }
    }
}

Параметр packageName задаёт имя пакета для сгенерированных исходных файлов Kotlin.

Когда появится соответствующий запрос, синхронизируйте файлы проекта Gradle или дважды нажмите Shift и найдите действие Синхронизировать все проекты Gradle и Swift Package Manager.

Для работы с файлами .sq установите официальный плагин SQLDelight.

Генерация API базы данных

Сначала создайте файл .sq со всеми необходимыми SQL-запросами. По умолчанию плагин SQLDelight ищет файлы .sq в папке sqldelight набора исходного кода:

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

  2. В каталоге sqldelight создайте каталог с именем com/jetbrains/spacetutorial/cache, чтобы сформировать вложенные каталоги для пакета.

  3. В каталоге cache создайте файл AppDatabase.sq (с тем же именем, что и у базы данных, указанным в файле build.gradle.kts). В этом файле будут храниться все SQL-запросы приложения.

  4. В базе данных будет таблица со сведениями о запусках. Добавьте следующий код в файл AppDatabase.sq, чтобы создать таблицу и определить несколько функций, которые вы будете использовать позже:

    import kotlin.Boolean;
    
    CREATE TABLE Launch (
        flightNumber TEXT NOT NULL,
        missionName TEXT NOT NULL,
        launchDateUTC TEXT NOT NULL,
        imageSmall TEXT NOT NULL,
        imageLarge TEXT NOT NULL,
        statusId INTEGER NOT NULL,
        statusName TEXT NOT NULL,
        statusDescription TEXT NOT NULL
    );
    
    insertLaunch:
    INSERT INTO Launch(flightNumber, missionName, launchDateUTC, imageSmall, imageLarge, statusId, statusName, statusDescription)
    VALUES(?, ?, ?, ?, ?, ?, ?, ?);
    
    removeAllLaunches:
    DELETE FROM Launch;
    
    selectAllLaunchesInfo:
    SELECT Launch.*
    FROM Launch;
    
  5. Сгенерируйте соответствующий интерфейс AppDatabase (позже вы инициализируете его с помощью драйверов базы данных). Для этого выполните следующую команду в терминале из корневого каталога проекта:

    ./gradlew generateCommonMainAppDatabaseInterface
    

    Сгенерированный код Kotlin сохраняется в каталоге sharedLogic/build/generated/sqldelight.

Создание фабрик драйверов базы данных для конкретных платформ

Чтобы инициализировать интерфейс AppDatabase, передайте ему экземпляр SqlDriver. SQLDelight предоставляет несколько реализаций драйвера SQLite для разных платформ, поэтому для каждой платформы необходимо создать отдельный экземпляр.

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

  1. Создайте интерфейс для драйверов базы данных. Для этого создайте пакет cache в каталоге sharedLogic/src/commonMain/kotlin/com/jetbrains/spacetutorial/.

  2. Создайте интерфейс DatabaseDriverFactory в пакете cache:

    package com.jetbrains.spacetutorial.cache
    
    import app.cash.sqldelight.db.SqlDriver
    
    interface DatabaseDriverFactory {
        fun createDriver(): SqlDriver
    }
    
  3. Создайте класс, реализующий этот интерфейс для Android: в каталоге sharedLogic/src/androidMain/kotlin создайте пакет com.jetbrains.spacetutorial.cache, а затем файл AndroidDatabaseDriverFactory.kt в этом пакете.

  4. В Android драйвер SQLite реализован классом AndroidSqliteDriver. В файле DatabaseDriverFactory.kt передайте сведения о базе данных и ссылку на контекст в конструктор класса AndroidSqliteDriver:

    package com.jetbrains.spacetutorial.cache
    
    import android.content.Context
    import app.cash.sqldelight.db.SqlDriver
    import app.cash.sqldelight.driver.android.AndroidSqliteDriver
    
    class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory {
        override fun createDriver(): SqlDriver {
            return AndroidSqliteDriver(AppDatabase.Schema, context, "launch.db")
        }
    }
    
  5. Для iOS создайте пакет cache в каталоге shared/src/iosMain/kotlin/com/jetbrains/spacetutorial/.

  6. В пакете cache создайте файл DatabaseDriverFactory.kt и добавьте следующий код:

    package com.jetbrains.spacetutorial.cache
    
    import app.cash.sqldelight.db.SqlDriver
    import app.cash.sqldelight.driver.native.NativeSqliteDriver
    
    class IOSDatabaseDriverFactory : DatabaseDriverFactory {
        override fun createDriver(): SqlDriver {
            return NativeSqliteDriver(AppDatabase.Schema, "launch.db")
        }
    }
    

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

Реализация кэша

Вы уже добавили фабрики драйверов базы данных для разных платформ и интерфейс AppDatabase для выполнения операций с базой данных. Теперь создайте класс Database, который будет обёрткой для интерфейса AppDatabase и будет содержать логику кэширования.

  1. В общем наборе исходного кода sharedLogic/src/commonMain/kotlin создайте класс Database в пакете com.jetbrains.spacetutorial.cache. Он будет содержать общую для обеих платформ логику.

  2. Чтобы предоставить драйвер для AppDatabase, передайте абстрактный экземпляр DatabaseDriverFactory в конструктор класса Database:

    package com.jetbrains.spacetutorial.cache
    
    internal class Database(databaseDriverFactory: DatabaseDriverFactory) {
        private val database = AppDatabase(databaseDriverFactory.createDriver())
        private val dbQuery = database.appDatabaseQueries
    }
    

    Для этого класса задан уровень видимости internal, то есть он доступен только внутри мультиплатформенного модуля.

  3. Внутри класса Database реализуйте обработку данных. Сначала создайте функцию getAllLaunches(), которая возвращает список всех запусков ракет. Функция mapLaunchSelecting() используется для преобразования результата запроса к базе данных в объекты RocketLaunch:

    import com.jetbrains.spacetutorial.entity.Image import com.jetbrains.spacetutorial.entity.LaunchStatus import com.jetbrains.spacetutorial.entity.RocketLaunch internal class Database(databaseDriverFactory: DatabaseDriverFactory) { private val database = AppDatabase(databaseDriverFactory.createDriver()) private val dbQuery = database.appDatabaseQueries internal fun getAllLaunches(): List<RocketLaunch> { return dbQuery.selectAllLaunchesInfo(::mapLaunchSelecting).executeAsList() } private fun mapLaunchSelecting( flightNumber: String, missionName: String, launchDateUTC: String, imageSmall: String, imageLarge: String, statusId: Long, statusName: String, statusDescription: String ): RocketLaunch { return RocketLaunch( id = flightNumber, missionName = missionName, launchDateUTC = launchDateUTC, image = Image( small = imageSmall, large = imageLarge ), status = LaunchStatus( id = statusId.toInt(), name = statusName, description = statusDescription ) ) } }
  4. Добавьте функцию clearAndCreateLaunches(), чтобы очистить базу данных и вставить новые данные:

    internal class Database(databaseDriverFactory: DatabaseDriverFactory) {
        // ...
    
        internal fun clearAndCreateLaunches(launches: List<RocketLaunch>) {
            dbQuery.transaction {
                dbQuery.removeAllLaunches()
                launches.forEach { launch ->
                    dbQuery.insertLaunch(
                        flightNumber = launch.id,
                        missionName = launch.missionName,
                        launchDateUTC = launch.launchDateUTC,
                        imageSmall = launch.image.small,
                        imageLarge = launch.image.large,
                        statusId = launch.status.id.toLong(),
                        statusName = launch.status.name,
                        statusDescription = launch.status.description,
                    )
                }
            }
        }
    }
    

Реализация службы API

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

Создайте класс для подключения приложения к API:

  1. В каталоге sharedLogic/src/commonMain/kotlin/com/jetbrains/spacetutorial/ создайте пакет network.

  2. В каталоге network создайте класс SpaceApi:

    package com.jetbrains.spacetutorial.network
    
    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 SpaceApi {
        private val httpClient = HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    ignoreUnknownKeys = true
                    useAlternativeNames = false 
                })
            }
        }
    }
    

    Этот класс выполняет сетевые запросы и десериализует ответы JSON в сущности из пакета com.jetbrains.spacetutorial.entity. Экземпляр Ktor HttpClient инициализирует и сохраняет свойство httpClient.

    В этом коде используется плагин Ktor ContentNegotiation для десериализации результата запроса GET. Плагин обрабатывает запрос и полезную нагрузку ответа как JSON, выполняя сериализацию и десериализацию по мере необходимости.

  3. Объявите функцию получения данных, возвращающую список запусков ракет:

    import com.jetbrains.spacetutorial.entity.RocketLaunch
    import com.jetbrains.spacetutorial.entity.LaunchListResponse
    import io.ktor.client.request.get
    import io.ktor.client.call.body
    
    class SpaceApi {
        // ...
    
        suspend fun getAllLaunches(): List<RocketLaunch> {
            return (httpClient.get("https://lldev.thespacedevs.com/2.3.0/launches/previous/?mode=list&format=json").body() as LaunchListResponse).results
        }
    }
    

Функция getAllLaunches имеет модификатор suspend, поскольку содержит вызов suspend-функции HttpClient.get(). Функция HttpClient.get() выполняет асинхронную операцию для получения данных через интернет и может вызываться только из сопрограммы или другой suspend-функции. Сетевой запрос будет выполняться в пуле потоков HTTP-клиента.

URL-адрес для отправки GET-запроса передаётся в качестве аргумента функции get().

Создание SDK

Ваши приложения для iOS и Android будут взаимодействовать с космическим API через общий модуль, который предоставит общедоступный класс SpaceSDK.

  1. В общем наборе исходного кода sharedLogic/src/commonMain/kotlin, в пакете com.jetbrains.spacetutorial создайте класс SpaceSDK. Этот класс будет фасадом для классов Database и SpaceApi.

    Чтобы создать экземпляр класса Database, предоставьте экземпляр DatabaseDriverFactory:

    package com.jetbrains.spacetutorial
    
    import com.jetbrains.spacetutorial.cache.Database
    import com.jetbrains.spacetutorial.cache.DatabaseDriverFactory
    import com.jetbrains.spacetutorial.network.SpaceApi
    
    class SpaceSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceApi) { 
        private val database = Database(databaseDriverFactory)
    }
    

    Внедрите подходящий драйвер базы данных в коде для конкретной платформы через конструктор класса SpaceSDK.

  2. Добавьте функцию getLaunches, которая использует созданную базу данных и API для получения и сохранения списка запусков:

    import com.jetbrains.spacetutorial.entity.RocketLaunch
    
    class SpaceSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceApi) {
        // ...
    
        @Throws(Exception::class)
        suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> {
            val cachedLaunches = database.getAllLaunches()
            return if (cachedLaunches.isNotEmpty() && !forceReload) {
                cachedLaunches
            } else {
                api.getAllLaunches().also {
                    database.clearAndCreateLaunches(it)
                }
            }
        }
    }
    

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

Клиенты SDK могут использовать флаг forceReload для загрузки актуальных сведений о запусках, добавив для пользователей жест обновления потягиванием.

Все исключения Kotlin являются непроверяемыми, тогда как в Swift есть только проверяемые ошибки (подробности см. в разделе Взаимодействие со Swift/Objective-C). Поэтому, чтобы код Swift мог обрабатывать ожидаемые исключения, функции Kotlin, вызываемые из Swift, должны быть помечены аннотацией @Throws со списком возможных классов исключений.

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

IntelliJ IDEA выполнит первоначальную настройку Gradle за вас, поэтому модули sharedUI и sharedLogic уже подключены к вашему приложению Android (androidApp).

Синхронизируйте файлы проекта Gradle, когда появится соответствующий запрос, или нажмите дважды Shift и найдите действие Синхронизировать все проекты Gradle и Swift Package Manager.

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

Чтобы получить доступ к интернету, приложению Android требуется соответствующее разрешение. Добавьте тег <uses-permission> в файл 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>

Добавление кода внедрения зависимостей

Внедрение зависимостей с помощью Koin позволяет объявлять модули (наборы компонентов), которые можно использовать в разных контекстах. В этом проекте вы создадите два модуля: один для приложения Android, а другой для приложения iOS. Затем вы запустите Koin для каждого нативного пользовательского интерфейса, используя соответствующий модуль.

Объявите модуль Koin, который будет содержать компоненты приложения Android:

  1. Добавьте зависимость Koin Android для исходного набора androidMain в файл sharedUI/build.gradle.kts:

    kotlin {
        // ...
        sourceSets {
            androidMain.dependencies {
                // ...
                implementation(libs.koin.androidx.compose)
            }
        }
    }
    
  2. Создайте каталог sharedUI/src/androidMain/kotlin для кода пользовательского интерфейса, предназначенного только для Android.

  3. В каталоге sharedUI/src/androidMain/kotlin создайте пакет com.jetbrains.spacetutorial.

  4. Удалите исходные наборы commonMain и commonTest из модулей sharedUI, поскольку пользовательский интерфейс Android не будет общим.

  5. В пакете sharedUI/src/androidMain/kotlin/com.jetbrains.spacetutorial создайте файл AppModule.kt.

    В этом файле объявите модуль Koin с двумя синглтонами: один для класса SpaceApi, а другой — для класса SpaceSDK:

    import com.jetbrains.spacetutorial.cache.AndroidDatabaseDriverFactory
    import com.jetbrains.spacetutorial.network.SpaceApi
    import org.koin.android.ext.koin.androidContext
    import org.koin.dsl.module
    
    val appModule = module {
        single<SpaceApi> { SpaceApi() }
        single<SpaceSDK> {
            SpaceSDK(
                databaseDriverFactory = AndroidDatabaseDriverFactory(androidContext()),
                api = get()
            )
        }
    }
    

    Конструктор класса SpaceSDK получает внедряемый класс AndroidDatabaseDriverFactory, специфичный для платформы. Функция get() разрешает зависимости в модуле: вместо параметра api для SpaceSDK() Koin передаст ранее объявленный синглтон SpaceApi.

  6. В файле androidApp/build.gradle.kts добавьте зависимость Koin Android для модуля androidApp:

    kotlin {
        // ...
        dependencies {
            // ...
            implementation(libs.koin.androidx.compose)
        }
    }
    
  7. В модуле androidApp, в каталоге src/main/kotlin/com/jetbrains/spacetutorial, создайте класс MainApplication, который будет запускать модуль Koin.

    Передайте модуль, объявленный в файле AppModule.kt, функции modules():

    package com.jetbrains.spacetutorial
    
    import android.app.Application
    import org.koin.android.ext.koin.androidContext
    import org.koin.core.context.GlobalContext.startKoin
    
    class MainApplication : Application() {
        override fun onCreate() {
            super.onCreate()
    
            startKoin {
                androidContext(this@MainApplication)
                modules(appModule)
            }
        }
    }
    
  8. Укажите созданный класс MainApplication в теге <application> файла AndroidManifest.xml:

    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
        ...
        <application
            ...
            android:name="com.jetbrains.spacetutorial.MainApplication">
            ...
        </application>
    </manifest>
    

Теперь вы готовы реализовать пользовательский интерфейс, который будет использовать данные, предоставляемые платформозависимым драйвером базы данных.

Подготовка модели представления со списком запусков

Вы реализуете пользовательский интерфейс Android с помощью Jetpack Compose и Material 3. Сначала вы создадите модель представления, которая использует SDK для получения списка запусков. Затем настроите тему Material и, наконец, напишете компонуемую функцию, которая объединит все это.

  1. В каталоге sharedUI/src/androidMain/kotlin, в пакете com.jetbrains.spacetutorial, создайте файл RocketLaunchViewModel.kt:

    package com.jetbrains.spacetutorial
    
    import androidx.compose.runtime.State
    import androidx.compose.runtime.mutableStateOf
    import androidx.lifecycle.ViewModel
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    
    class RocketLaunchViewModel(private val sdk: SpaceSDK) : ViewModel() {
        private val _state = mutableStateOf(RocketLaunchScreenState())
        val state: State<RocketLaunchScreenState> = _state
    
    }
    
    data class RocketLaunchScreenState(
        val isLoading: Boolean = false,
        val launches: List<RocketLaunch> = emptyList()
    )
    

    Экземпляр RocketLaunchScreenState будет хранить данные, полученные из SDK, и текущее состояние запроса.

  2. Добавьте в класс RocketLaunchViewModel функцию loadLaunches, которая вызовет функцию getLaunches из SDK в области корутин этой модели представления:

    import androidx.lifecycle.viewModelScope
    import kotlinx.coroutines.launch
    
    class RocketLaunchViewModel(private val sdk: SpaceSDK) : ViewModel() {
        //...
    
        fun loadLaunches() {
            viewModelScope.launch { 
                _state.value = _state.value.copy(isLoading = true, launches = emptyList())
                try {
                    val launches = sdk.getLaunches(forceReload = true)
                    _state.value = _state.value.copy(isLoading = false, launches = launches)
                } catch (_: Exception) {
                    _state.value = _state.value.copy(isLoading = false, launches = emptyList())
                }
            }
        }
    }
    
  3. В классе RocketLaunchViewModel добавьте блок init {} с вызовом loadLaunches(), чтобы запросить данные из API сразу после создания объекта RocketLaunchViewModel:

    class RocketLaunchViewModel(private val sdk: SpaceSDK) : ViewModel() {
        // ...
    
        init {
            loadLaunches()
        }
    }
    
  4. Теперь укажите модель представления в модуле Koin в файле AppModule.kt:

    import org.koin.core.module.dsl.viewModel
    
    val appModule = module {
        // ...
        viewModel { RocketLaunchViewModel(sdk = get()) }
    }
    

Создание темы Material

Вы создадите основной компонуемый элемент App() вокруг функции AppTheme, предоставляемой темой Material:

  1. Вы можете создать тему для приложения Compose с помощью конструктора тем Material. Выберите цвета и шрифты, затем нажмите Экспортировать тему в правом нижнем углу.

  2. На экране экспорта нажмите раскрывающийся список Экспорт и выберите пункт Jetpack Compose (Theme.kt).

  3. Распакуйте архив и скопируйте папку theme в каталог sharedUI/src/androidMain/kotlin/com/jetbrains/spacetutorial:

    theme directory location
  4. В каждом файле пакета theme замените строку package, чтобы она указывала на созданный вами пакет:

    package com.jetbrains.spacetutorial.theme
    
  5. В файле Color.kt добавьте две переменные для цветов, которые будут использоваться для успешных и неуспешных запусков:

    val app_theme_successful = Color(0xff4BB543)
    val app_theme_unsuccessful = Color(0xffFC100D)
    

Реализация логики представления

Создайте основной компонуемый элемент App() для приложения и вызовите его из класса ComponentActivity:

  1. Создайте файл App.kt в каталоге sharedUI/src/androidApp/kotlin/com/jetbrains/spacetutorial.

  2. Откройте файл App.kt и вставьте следующий код:

    package com.jetbrains.spacetutorial
    
    import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.remember
    import androidx.compose.runtime.rememberCoroutineScope
    import androidx.compose.runtime.setValue
    import androidx.compose.ui.tooling.preview.Preview
    import org.koin.androidx.compose.koinViewModel
    import androidx.compose.material3.ExperimentalMaterial3Api
    
    @OptIn(
      ExperimentalMaterial3Api::class
    )
    @Composable
    @Preview
    fun App() {
        val viewModel = koinViewModel<RocketLaunchViewModel>()
        val state by remember { viewModel.state }
        val coroutineScope = rememberCoroutineScope()
        var isRefreshing by remember { mutableStateOf(false) }
        val pullToRefreshState = rememberPullToRefreshState()
    }
    

    Здесь вы используете API ViewModel для Koin, чтобы обратиться к viewModel, объявленному в модуле Koin для Android.

  3. Теперь добавьте код пользовательского интерфейса для экрана загрузки, столбца с результатами запусков и действия обновления с помощью свайпа вниз:

    import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.jetbrains.spacetutorial.entity.RocketLaunch import com.jetbrains.spacetutorial.theme.AppTheme import com.jetbrains.spacetutorial.theme.app_theme_successful import com.jetbrains.spacetutorial.theme.app_theme_unsuccessful import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview fun App() { val viewModel = koinViewModel<RocketLaunchViewModel>() val state by remember { viewModel.state } val coroutineScope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } val pullToRefreshState = rememberPullToRefreshState() AppTheme { Scaffold( topBar = { TopAppBar( title = { Text( "Space Launches", style = MaterialTheme.typography.headlineLarge ) } ) } ) { padding -> PullToRefreshBox( modifier = Modifier .fillMaxSize() .padding(padding), state = pullToRefreshState, isRefreshing = isRefreshing, onRefresh = { isRefreshing = true coroutineScope.launch { viewModel.loadLaunches() isRefreshing = false } } ) { if (state.isLoading && !isRefreshing) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { Text("Loading...", style = MaterialTheme.typography.bodyLarge) } } else { LazyColumn { items(state.launches) { launch: RocketLaunch -> Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp) ) { Text( text = launch.missionName, style = MaterialTheme.typography.headlineSmall ) Text( text = if (launch.status.id == 3) "Successful" else "Unsuccessful", color = if (launch.status.id == 3) app_theme_successful else app_theme_unsuccessful ) Text( text = "Launch year: ${launch.launchYear}" ) val details = launch.status.description if (details.isNotBlank()) { Text(details) } } HorizontalDivider() } } } } } } }
  4. Наконец, в файле androidApp/src/main/AndroidManifest.xml укажите класс MainActivity в теге <activity>:

    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
        ...
        <application
            ...
            <activity
                ...
                android:name="com.jetbrains.spacetutorial.MainActivity">
                ...
            </activity>
        </application>
    </manifest>
    
  5. Запустите приложение Android: выберите androidApp в меню конфигураций запуска, выберите эмулятор и нажмите кнопку запуска. Приложение автоматически выполнит запрос к API и отобразит список запусков (цвет фона зависит от созданной вами темы Material):

    Android application

Вы только что создали приложение Android, бизнес-логика которого реализована в модуле Kotlin Multiplatform, а пользовательский интерфейс работает на нативном Jetpack Compose.

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

Для части проекта, связанной с iOS, вы будете использовать SwiftUI для создания пользовательского интерфейса и паттерн Model View View-Model.

IntelliJ IDEA создает проект iOS, уже подключенный к общему модулю. Модуль Kotlin экспортируется под именем, указанным в файле sharedLogic/build.gradle.kts (baseName = "SharedLogic"), и импортируется с помощью обычной инструкции import: import SharedLogic.

Добавление флага динамической компоновки для SQLDelight

По умолчанию IntelliJ IDEA создает проекты, настроенные на статическую компоновку фреймворков iOS.

Чтобы использовать нативный драйвер SQLDelight в iOS, добавьте флаг динамической компоновки, который позволит инструментам Xcode найти системный бинарный файл SQLite:

  1. В IntelliJ IDEA выберите Файл | Открыть проект в Xcode, чтобы открыть проект в Xcode.

  2. В Xcode нажмите на имя проекта, чтобы открыть его настройки.

  3. Перейдите на вкладку Настройки сборки, переключитесь там на список Все и найдите поле Другие флаги компоновщика.

  4. Разверните поле, нажмите знак плюса рядом с полем Отладка и вставьте строку -lsqlite3 в поле Любая архитектура | Любой SDK.

  5. Повторите процесс для поля Другие флаги компоновщика | Релиз.

    The result of correctly adding the linker flag to the Xcode project
  6. Вернитесь в IntelliJ IDEA.

Подготовка класса Koin для внедрения зависимостей в iOS

Чтобы использовать классы и функции Koin в коде Swift, создайте специальный класс KoinComponent и объявите модуль Koin для iOS.

  1. Создайте файл KoinHelper.kt в каталоге sharedLogic/src/iosMain/kotlin/com/jetbrains/spacetutorial.

  2. Добавьте класс KoinHelper, который будет оберткой для класса SpaceSDK с ленивым внедрением Koin:

    package com.jetbrains.spacetutorial
    
    import org.koin.core.component.KoinComponent
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    import org.koin.core.component.inject
    
    class KoinHelper : KoinComponent {
        private val sdk: SpaceSDK by inject<SpaceSDK>()
    
        suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> {
            return sdk.getLaunches(forceReload = forceReload)
        }
    }
    
  3. Под классом KoinHelper добавьте функцию initKoin(), которую вы будете использовать в Swift для инициализации и запуска модуля Koin для iOS:

    import com.jetbrains.spacetutorial.cache.IOSDatabaseDriverFactory
    import com.jetbrains.spacetutorial.network.SpaceApi
    import org.koin.core.context.startKoin
    import org.koin.dsl.module
    
    fun initKoin() {
        startKoin {
            modules(module {
                single<SpaceApi> { SpaceApi() }
                single<SpaceSDK> {
                    SpaceSDK(
                        databaseDriverFactory = IOSDatabaseDriverFactory(), api = get()
                    )
                }
            })
        }
    }
    

Теперь можно запустить модуль Koin в приложении iOS, чтобы использовать нативный драйвер базы данных с общим классом SpaceSDK.

Реализация пользовательского интерфейса

Сначала вы создадите представление SwiftUI RocketLaunchRow для отображения элемента списка. Оно будет основано на представлениях HStack и VStack. В структуре RocketLaunchRow будут расширения с полезными вспомогательными функциями для отображения данных.

  1. В IntelliJ IDEA убедитесь, что выбрано представление ** Проект **.

  2. Создайте новый файл Swift в папке iosApp/iosApp, рядом с ContentView.swift, и назовите его RocketLaunchRow.

  3. Обновите файл RocketLaunchRow.swift, добавив следующий код:

    import SwiftUI
    import SharedLogic
    
    struct RocketLaunchRow: View {
        var rocketLaunch: RocketLaunch
    
        var body: some View {
            HStack() {
                VStack(alignment: .leading, spacing: 10.0) {
                    Text("\(rocketLaunch.missionName)")
                        .font(.system(size: 18))
                        .bold()
                        .fixedSize(horizontal: false, vertical: true)
                    Text(launchText).foregroundColor(launchColor)
                    Text("Launch year: \(String(rocketLaunch.launchYear))")
                    Text("\(rocketLaunch.status.description_)")
                }
                Spacer()
            }
        }
    }
    
    extension RocketLaunchRow {
        private var launchText: String {
            let isSuccess = rocketLaunch.status.id == 3
            return isSuccess ? "Successful" : "Unsuccessful"
        }
    
        private var launchColor: Color {
            let isSuccess = rocketLaunch.status.id == 3
            return isSuccess ? Color.green : Color.red
        }
    }
    

    Список запусков будет отображаться в представлении ContentView, которое уже включено в проект.

  4. В файле ContentView.swift добавьте расширение класса ContentView с классом ViewModel, который будет подготавливать данные и управлять ими:

    extension ContentView {
        enum LoadableLaunches {
            case loading
            case result([RocketLaunch])
            case error(String)
        }
    
        @MainActor
        class ViewModel: ObservableObject {
            @Published var launches = LoadableLaunches.loading
        }
    }
    

    Модель представления (ContentView.ViewModel) связана с представлением (ContentView) с помощью фреймворка Combine:

    • Класс ContentView.ViewModel объявлен как ObservableObject.

    • Атрибут @Published используется для свойства launches, поэтому модель представления будет отправлять сигналы при каждом изменении этого свойства.

  5. Удалите структуру ContentView_Previews: вы не будете реализовывать предварительный просмотр, который должен быть совместим с вашей моделью представления.

  6. Обновите тело класса ContentView, чтобы отображать список запусков и добавить возможность повторной загрузки.

    • Это основа пользовательского интерфейса: на следующем этапе руководства вы реализуете функцию loadLaunches.

    • Свойство viewModel помечено атрибутом @ObservedObject для подписки на модель представления.

    struct ContentView: View {
        @ObservedObject private(set) var viewModel: ViewModel
    
        var body: some View {
            NavigationView {
                listView()
                .navigationBarTitle("Space Launches")
                .navigationBarItems(trailing:
                    Button("Reload") {
                        self.viewModel.loadLaunches(forceReload: true)
                })
            }
        }
    
        private func listView() -> AnyView {
            switch viewModel.launches {
            case .loading:
                return AnyView(Text("Loading...").multilineTextAlignment(.center))
            case .result(let launches):
                return AnyView(List(launches) { launch in
                    RocketLaunchRow(rocketLaunch: launch)
                })
            case .error(let description):
                return AnyView(Text(description).multilineTextAlignment(.center))
            }
        }
    }
    
  7. Класс RocketLaunch используется в качестве параметра для инициализации представления List, поэтому он должен соответствовать протоколу Identifiable. В классе уже есть свойство с именем id, поэтому достаточно добавить расширение в конец файла ContentView.swift:

    extension RocketLaunch: Identifiable { }
    

Загрузка данных

Чтобы получить данные о запусках ракет в модели представления, вам понадобится экземпляр класса KoinHelper из библиотеки Multiplatform. Он позволит вызвать функцию SDK с подходящим драйвером базы данных.

  1. В файле ContentView.swift дополните класс ViewModel, добавив объект KoinHelper и функцию loadLaunches:

    extension ContentView {
        // ...
        class ViewModel: ObservableObject {
            // ...
            let helper: KoinHelper = KoinHelper()
    
            init() {
                self.loadLaunches(forceReload: false)
            }
    
            func loadLaunches(forceReload: Bool) {
                // TODO: retrieve data
            }
        }
    }
    
  2. В функции loadLaunches() вызовите функцию KoinHelper.getLaunches() (которая перенаправит вызов классу SpaceSDK) и сохраните результат в свойстве launches:

    func loadLaunches(forceReload: Bool) {
        Task {
            do {
                self.launches = .loading
                let launches = try await helper.getLaunches(forceReload: forceReload)
                self.launches = .result(launches)
            } catch {
                self.launches = .error(error.localizedDescription)
            }
        }
    }
    

    При компиляции модуля Kotlin в фреймворк Apple приостанавливаемые функции можно вызывать с помощью механизма async/await в Swift.

    Поскольку функция getLaunches помечена аннотацией @Throws(Exception::class) в Kotlin, любые исключения, являющиеся экземплярами класса Exception или его подкласса, будут переданы в Swift как NSError. Поэтому все такие исключения можно перехватить функцией loadLaunches().

  3. Перейдите к точке входа приложения — файлу iOSApp.swift — и инициализируйте модуль Koin, представление и модель представления:

    import SwiftUI
    import SharedLogic
    
    @main
    struct iOSApp: App {
        init() {
            KoinHelperKt.doInitKoin()
        }
    
        var body: some Scene {
            WindowGroup {
                ContentView(viewModel: .init())
            }
        }
    }
    
  4. В IntelliJ IDEA переключитесь на конфигурацию iosApp, выберите эмулятор и запустите приложение, чтобы увидеть результат:

iOS Application

Окончательную версию проекта можно найти в ветке final.

Что дальше?

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

Также ознакомьтесь с дополнительными материалами:

  • Использование HTTP-клиента Ktor в мультиплатформенных проектах

  • Подробнее о Koin и внедрении зависимостей

  • Запуск приложения Android в iOS

  • Подробнее о структуре мультиплатформенного проекта.

24 июня 2026 г.
Перенос приложения Jetpack Compose на Kotlin MultiplatformСоздание библиотеки Kotlin Multiplatform — руководство

© 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-ktor-sqldelight.html

Spec-Zone.ru

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