Spec-Zone.ru › Kotlin 1.8

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

В этом руководстве показано, как использовать Android Studio для создания мобильного приложения для iOS и Android с помощью Kotlin Multiplatform Mobile с Ktor и SQLDelight.

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

Вы получите приложение, которое получает данные из сети с публичного API SpaceX, сохраняет их в локальной базе данных и отображает список запусков ракет SpaceX вместе с датой запуска, результатами и подробным описанием запуска:

Emulator and Simulator

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

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

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

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

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

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

Прежде чем начать

  1. Загрузите и установите Android Studio.

  2. Найдите плагин Kotlin Multiplatform Mobile в Marketplace Android Studio и установите его.

    Kotlin Multiplatform Mobile plugin
  3. Загрузите и установите Xcode.

Дополнительную информацию см. в разделе Настройка среды.

Создание кроссплатформенного проекта

  1. В Android Studio выберите Файл | Новый | Новый проект. В списке шаблонов проектов выберите Kotlin Multiplatform App и нажмите Далее.

    Kotlin Multiplatform Mobile plugin wizard
  2. Дайте имя своему приложению и нажмите Далее.

  3. Выберите Обычный фреймворк в списке Распределения iOS-фреймворка.

    Kotlin Multiplatform Mobile plugin wizard. Final step
  4. Оставьте все остальные параметры по умолчанию. Нажмите Готово.

  5. Чтобы увидеть полную структуру кроссплатформенного мобильного проекта, переключите отображение с Android на Проект.

    Project view

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

Вы можете найти настроенный проект на ветке master.

Добавление зависимостей в кроссплатформенный модуль

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

Как библиотеки Ktor, так и SQLDelight требуют дополнительной настройки.

  1. В каталоге shared, укажите зависимости от всех необходимых библиотек в файле build.gradle.kts.

    val coroutinesVersion = "1.6.4"
    val ktorVersion = "2.2.1"
    val sqlDelightVersion = "1.5.4"
    val dateTimeVersion = "0.4.0"
    
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
                implementation("io.ktor:ktor-client-core:$ktorVersion")
                implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
                implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
                implementation("com.squareup.sqldelight:runtime:$sqlDelightVersion")
                }
            }
        val androidMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-android:$ktorVersion")
                implementation("com.squareup.sqldelight:android-driver:$sqlDelightVersion")
            }
        }
        val iosMain by creating {
            // ...
            dependencies {
                implementation("io.ktor:ktor-client-darwin:$ktorVersion")
                implementation("com.squareup.sqldelight:native-driver:$sqlDelightVersion")
            }
        }
    }
    
    • Каждая библиотека требует ядра артефакта в общем наборе исходных файлов.

    • И библиотека SQLDelight, и библиотека Ktor нуждаются в драйверах платформы в наборах исходных файлов iOS и Android.

    • Кроме того, Ktor требует функции сериализации для использования kotlinx.serialization для обработки сетевых запросов и ответов.

  2. В самом начале файла build.gradle.kts в том же каталоге shared, добавьте следующие строки в блок plugins.

        plugins {
        // ...
        kotlin("plugin.serialization") version "1.8.0"
        id("com.squareup.sqldelight")
    }
    
  3. Теперь перейдите к файлу build.gradle.kts в корневой директории проекта и укажите класспаут для плагина в зависимостях системы сборки:

    buildscript {
        // ...
        val sqlDelightVersion = "1.5.4"
    
        dependencies {
            // ...
            classpath("com.squareup.sqldelight:gradle-plugin:$sqlDelightVersion")
        }
    }
    
  4. Наконец, определите версию SQLDelight в файле gradle.properties в корневой директории проекта, чтобы гарантировать, что версии SQLDelight плагина и библиотек совпадают:

    sqlDelightVersion=1.5.4
    
  5. Синхронизируйте проект Gradle.

Узнайте больше о добавлении зависимостей кроссплатформенных библиотек.

Вы можете найти текущее состояние проекта на ветке final.

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

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

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

  • Ссылка на внешнюю информацию

  • Данные о ракете

  1. В shared/src/commonMain/kotlin, добавьте пакет com.jetbrains.handson.kmm.shared.entity.

  2. Создайте файл Entity.kt внутри пакета.

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

    import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RocketLaunch( @SerialName("flight_number") val flightNumber: Int, @SerialName("mission_name") val missionName: String, @SerialName("launch_year") val launchYear: Int, @SerialName("launch_date_utc") val launchDateUTC: String, @SerialName("rocket") val rocket: Rocket, @SerialName("details") val details: String?, @SerialName("launch_success") val launchSuccess: Boolean?, @SerialName("links") val links: Links ) @Serializable data class Rocket( @SerialName("rocket_id") val id: String, @SerialName("rocket_name") val name: String, @SerialName("rocket_type") val type: String ) @Serializable data class Links( @SerialName("mission_patch") val missionPatchUrl: String?, @SerialName("article_link") val articleUrl: String? )

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

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

Вы можете найти состояние проекта после этого раздела на ветке final.

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

Настройка SQLDelight

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

Библиотека уже включена в проект. Для ее настройки перейдите в директорию shared и добавьте блок sqldelight в конец файла build.gradle.kts. Блок будет содержать список баз данных и их параметров:

sqldelight {
    database("AppDatabase") {
        packageName = "com.jetbrains.handson.kmm.shared.cache"
    }
}

Параметр packageName указывает имя пакета для сгенерированных Kotlin-источников.

Рекомендуется установить официальный плагин SQLite для работы с файлами .sq.

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

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

  1. В shared/src/commonMain, создайте новую директорию sqldelight и добавьте пакет com.jetbrains.handson.kmm.shared.cache.

  2. Внутри пакета создайте файл .sq с именем базы данных AppDatabase.sq. Все SQL-запросы для приложения будут в этом файле.

  3. База данных будет содержать две таблицы с данными о запусках и ракетах. Чтобы создать таблицы, добавьте следующий код в файл AppDatabase.sq:

    CREATE TABLE Launch (
        flightNumber    INTEGER NOT NULL,
        missionName     TEXT    NOT NULL,
        launchYear      INTEGER AS Int NOT NULL DEFAULT 0,
        rocketId        TEXT    NOT NULL,
        details         TEXT,
        launchSuccess   INTEGER AS Boolean DEFAULT NULL,
        launchDateUTC   TEXT    NOT NULL,
        missionPatchUrl TEXT,
        articleUrl      TEXT
    );
    
    CREATE TABLE Rocket (
        id   TEXT NOT NULL PRIMARY KEY,
        name TEXT NOT NULL,
        type TEXT NOT NULL
    );
    
  4. Чтобы вставить данные в таблицы, объявите SQL-функции вставки:

    insertLaunch:
    INSERT INTO Launch(flightNumber, missionName, launchYear, rocketId, details, launchSuccess, launchDateUTC, missionPatchUrl, articleUrl)
    VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?);
    
    insertRocket:
    INSERT INTO Rocket(id, name, type)
    VALUES(?, ?, ?);
    
  5. Чтобы очистить данные в таблицах, объявите SQL-функции удаления:

    removeAllLaunches:
    DELETE FROM Launch;
    
    removeAllRockets:
    DELETE FROM Rocket;
    
  6. Аналогичным образом, объявите функции для извлечения данных. Для данных о ракете используйте ее идентификатор и выберите информацию обо всех ее запусках с помощью оператора JOIN:

    selectRocketById:
    SELECT * FROM Rocket
    WHERE id = ?;
    
    selectAllLaunchesInfo:
    SELECT Launch.*, Rocket.*
    FROM Launch
    LEFT JOIN Rocket ON Rocket.id == Launch.rocketId;
    

После компиляции проекта сгенерированный Kotlin-код будет сохранен в директории shared/build/generated/sqldelight. Генератор создаст интерфейс с именем AppDatabase, как указано в build.gradle.kts.

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

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

  1. Создайте абстрактную фабрику для драйверов баз данных. Для этого в shared/src/commonMain/kotlin, создайте пакет com.jetbrains.handson.kmm.shared.cache и класс DatabaseDriverFactory внутри него:

    package com.jetbrains.handson.kmm.shared.cache
    
    import com.squareup.sqldelight.db.SqlDriver
    
    expect class DatabaseDriverFactory {
        fun createDriver(): SqlDriver
    }
    

    Теперь предоставьте реализации actual для этого ожидаемого класса.

  2. На Android класс AndroidSqliteDriver реализует драйвер SQLite. Передайте информацию о базе данных и ссылку на контекст в конструктор класса AndroidSqliteDriver.

    Для этого в директории shared/src/androidMain/kotlin, создайте пакет com.jetbrains.handson.kmm.shared.cache и класс DatabaseDriverFactory внутри него с фактической реализацией:

    package com.jetbrains.handson.kmm.shared.cache
    
    import android.content.Context
    import com.squareup.sqldelight.android.AndroidSqliteDriver
    import com.squareup.sqldelight.db.SqlDriver
    
    actual class DatabaseDriverFactory(private val context: Context) {
        actual fun createDriver(): SqlDriver {
            return AndroidSqliteDriver(AppDatabase.Schema, context, "test.db")
        }
    }
    
  3. На iOS реализация драйвера SQLite — это класс NativeSqliteDriver. В директории shared/src/iosMain/kotlin, создайте пакет com.jetbrains.handson.kmm.shared.cache и класс DatabaseDriverFactory внутри него с фактической реализацией:

    package com.jetbrains.handson.kmm.shared.cache
    
    import com.squareup.sqldelight.db.SqlDriver
    import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
    
    actual class DatabaseDriverFactory {
        actual fun createDriver(): SqlDriver {
            return NativeSqliteDriver(AppDatabase.Schema, "test.db")
        }
    }
    

Экземпляры этих фабрик будут созданы позже в коде ваших Android и iOS проектов.

Вы можете переходить по объявлениям expect и реализациям actual с помощью удобной иконки в области отступов:

Expect/Actual gutter

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

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

  1. В общем наборе источников shared/src/commonMain/kotlin, создайте новый класс Database в пакете com.jetbrains.handson.kmm.shared.cache. Он будет общим для обеих платформ.

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

    package com.jetbrains.handson.kmm.shared.cache
    
    import com.jetbrains.handson.kmm.shared.entity.Links
    import com.jetbrains.handson.kmm.shared.entity.Rocket
    import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    
    internal class Database(databaseDriverFactory: DatabaseDriverFactory) {
        private val database = AppDatabase(databaseDriverFactory.createDriver())
        private val dbQuery = database.appDatabaseQueries
    }
    

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

  3. Внутри класса Database реализуйте некоторые операции обработки данных. Добавьте функцию для очистки всех таблиц в базе данных в одной SQL-транзакции:

    internal fun clearDatabase() {
        dbQuery.transaction {
            dbQuery.removeAllRockets()
            dbQuery.removeAllLaunches()
        }
    }
    
  4. Создайте функцию для получения списка всех запусков ракет:

    import com.jetbrains.handson.kmm.shared.entity.Links
    import com.jetbrains.handson.kmm.shared.entity.Patch
    import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    
    internal fun getAllLaunches(): List<RocketLaunch> {
        return dbQuery.selectAllLaunchesInfo(::mapLaunchSelecting).executeAsList()
    }
    
    private fun mapLaunchSelecting(
        flightNumber: Long,
        missionName: String,
        launchYear: Int,
        rocketId: String,
        details: String?,
        launchSuccess: Boolean?,
        launchDateUTC: String,
        missionPatchUrl: String?,
        articleUrl: String?,
        rocket_id: String?,
        name: String?,
        type: String?
    ): RocketLaunch {
        return RocketLaunch(
            flightNumber = flightNumber.toInt(),
            missionName = missionName,
            launchYear = launchYear,
            details = details,
            launchDateUTC = launchDateUTC,
            launchSuccess = launchSuccess,
            rocket = Rocket(
                id = rocketId,
                name = name!!,
                type = type!!
            ),
            links = Links(
                missionPatchUrl = missionPatchUrl,
                articleUrl = articleUrl
            )
        )
    }
    

    Аргумент, передаваемый в selectAllLaunchesInfo, — это функция, которая сопоставляет класс сущности базы данных с другим типом, который в данном случае является классом модели данных RocketLaunch.

  5. Добавьте функцию для вставки данных в базу данных:

    internal fun createLaunches(launches: List<RocketLaunch>) {
        dbQuery.transaction {
            launches.forEach { launch ->
                val rocket = dbQuery.selectRocketById(launch.rocket.id).executeAsOneOrNull()
                if (rocket == null) {
                    insertRocket(launch)
                }
    
                insertLaunch(launch)
            }
        }
    }
    
    private fun insertRocket(launch: RocketLaunch) {
        dbQuery.insertRocket(
            id = launch.rocket.id,
            name = launch.rocket.name,
            type = launch.rocket.type
        )
    }
    
    private fun insertLaunch(launch: RocketLaunch) {
        dbQuery.insertLaunch(
            flightNumber = launch.flightNumber.toLong(),
            missionName = launch.missionName,
            launchYear = launch.launchYear,
            rocketId = launch.rocket.id,
            details = launch.details,
            launchSuccess = launch.launchSuccess ?: false,
            launchDateUTC = launch.launchDateUTC,
            missionPatchUrl = launch.links.missionPatchUrl,
            articleUrl = launch.links.articleUrl
        )
    }
    

Экземпляр класса Database будет создан позже вместе с классом фасада SDK.

Вы можете найти состояние проекта после этого раздела на ветке final.

Реализация API-сервиса

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

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

  1. В общем наборе источников shared/src/commonMain/kotlin, создайте пакет com.jetbrains.handson.kmm.shared.network и класс SpaceXApi внутри него:

    package com.jetbrains.handson.kmm.shared.network
    
    import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    import io.ktor.client.*
    import io.ktor.client.call.*
    import io.ktor.client.plugins.contentnegotiation.*
    import io.ktor.client.request.*
    import io.ktor.serialization.kotlinx.json.*
    import kotlinx.serialization.json.Json
    
    class SpaceXApi {
        private val httpClient = HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    ignoreUnknownKeys = true
                    useAlternativeNames = false
                })
            }
        }
    }
    
    • Этот класс выполняет сетевые запросы и десериализует JSON-ответы в сущности из пакета entity. Экземпляр Ktor HttpClient инициализирует и сохраняет свойство httpClient.

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

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

    suspend fun getAllLaunches(): List<RocketLaunch> {
        return httpClient.get("https://api.spacexdata.com/v3/launches").body()
    }
    
    • Функция getAllLaunches имеет модификатор suspend, потому что она содержит вызов приостановленной функции get(), которая включает асинхронную операцию для получения данных через интернет и может быть вызвана только из сопрограммы или другой приостановленной функции. Сетевой запрос будет выполняться в пуле потоков HTTP-клиента.

    • URL определяется внутри функции get() для отправки запросов.

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

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

В файле androidApp/src/main/AndroidManifest.xml добавьте следующее разрешение в манифест:

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

Вы можете найти состояние проекта после этого раздела на ветке final.

Создание SDK

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

  1. В пакете com.jetbrains.handson.kmm.shared общего набора исходников создайте класс SpaceXSDK.

    package com.jetbrains.handson.kmm.shared
    
    import com.jetbrains.handson.kmm.shared.cache.Database
    import com.jetbrains.handson.kmm.shared.cache.DatabaseDriverFactory
    import com.jetbrains.handson.kmm.shared.network.SpaceXApi
    
    class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory) {
        private val database = Database(databaseDriverFactory)
        private val api = SpaceXApi()
    }
    

    Этот класс будет фасадом для классов Database и SpaceXApi.

  2. Для создания экземпляра класса Database вам необходимо предоставить ему экземпляр платформы DatabaseDriverFactory, поэтому вы внедрите его из кода платформы через конструктор класса SpaceXSDK.

    import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    
    @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.clearDatabase()
                database.createLaunches(it)
            }
        }
    }
    
    • Класс содержит одну функцию для получения всей информации о запусках. В зависимости от значения forceReload, она возвращает кэшированные значения или загружает данные из интернета, а затем обновляет кэш результатами. Если кэшированных данных нет, она загружает данные из интернета независимо от значения флага forceReload.

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

    • Для обработки исключений, генерируемых клиентом Ktor в Swift, функция помечена аннотацией @Throws.

    Все исключения Kotlin не проверяемые, а Swift имеет только проверяемые ошибки. Таким образом, чтобы код Swift был осведомлен об ожидаемых исключениях, функции Kotlin должны быть помечены аннотацией @Throws, определяющей список потенциальных классов исключений.

Вы можете найти состояние проекта после этого раздела на ветке final.

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

Плагин Kotlin Multiplatform Mobile для Android Studio уже выполнил конфигурацию за вас, поэтому общий модуль Kotlin Multiplatform уже подключён к вашему приложению Android.

Перед реализацией пользовательского интерфейса и логики представления добавьте все необходимые зависимости в androidApp/build.gradle.kts.

// ...
dependencies {
    implementation(project(":shared"))
    implementation("com.google.android.material:material:1.6.1")
    implementation("androidx.appcompat:appcompat:1.4.2")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
    implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2")
    implementation("androidx.core:core-ktx:1.8.0")
    implementation("androidx.recyclerview:recyclerview:1.2.1")
    implementation("androidx.cardview:cardview:1.0.0")
}
// ...

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

  1. Для реализации пользовательского интерфейса создайте файл layout/activity_main.xml в androidApp/src/main/res.

    Экран основан на ConstraintLayout с SwipeRefreshLayout внутри, которое содержит RecyclerView и FrameLayout с фоном с ProgressBar по центру:

    <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:id="@+id/swipeContainer" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/launchesListRv" android:layout_width="match_parent" android:layout_height="match_parent" /> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> <FrameLayout android:id="@+id/progressBar" android:layout_width="0dp" android:layout_height="0dp" android:background="#fff" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <ProgressBar android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout> </androidx.constraintlayout.widget.ConstraintLayout>
  2. В androidApp/src/main/java, замените реализацию класса MainActivity, добавив свойства для элементов пользовательского интерфейса:

    class MainActivity : AppCompatActivity() {
        private lateinit var launchesRecyclerView: RecyclerView
        private lateinit var progressBarView: FrameLayout
        private lateinit var swipeRefreshLayout: SwipeRefreshLayout
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            title = "SpaceX Launches"
            setContentView(R.layout.activity_main)
    
            launchesRecyclerView = findViewById(R.id.launchesListRv)
            progressBarView = findViewById(R.id.progressBar)
            swipeRefreshLayout = findViewById(R.id.swipeContainer)
        }
    }
    
  3. Чтобы элемент RecyclerView работал, вам нужно создать адаптер (как подкласс RecyclerView.Adapter), который преобразует исходные данные в представления элементов списка. Для этого создайте отдельный класс LaunchesRvAdapter:

    class LaunchesRvAdapter(var launches: List<RocketLaunch>) : RecyclerView.Adapter<LaunchesRvAdapter.LaunchViewHolder>() {
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LaunchViewHolder {
            return LayoutInflater.from(parent.context)
                .inflate(R.layout.item_launch, parent, false)
                .run(::LaunchViewHolder)
        }
    
        override fun getItemCount(): Int = launches.count()
    
        override fun onBindViewHolder(holder: LaunchViewHolder, position: Int) {
            holder.bindData(launches[position])
        }
    
        inner class LaunchViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
            // ...
            fun bindData(launch: RocketLaunch) {
                // ...
            }
        }
    }
    
  4. Создайте файл ресурсов item_launch.xml в androidApp/src/main/res/layout/ с макетом представления элементов:

    <?xml version="1.0" encoding="utf-8"?> <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:card_view="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="16dp" android:layout_marginVertical="8dp" card_view:cardCornerRadius="8dp"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingBottom="16dp"> <TextView android:id="@+id/missionName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/launchSuccess" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/missionName" /> <TextView android:id="@+id/launchYear" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/launchSuccess" /> <TextView android:id="@+id/details" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/launchYear" /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.cardview.widget.CardView>
  5. В androidApp/src/main/res/values/, создайте свой дизайн приложения или скопируйте следующие стили:

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <color name="colorPrimary">#37474f</color>
        <color name="colorPrimaryDark">#102027</color>
        <color name="colorAccent">#62727b</color>
    
        <color name="colorSuccessful">#4BB543</color>
        <color name="colorUnsuccessful">#FC100D</color>
        <color name="colorNoData">#615F5F</color>
    </resources>
    
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <string name="app_name">SpaceLaunches</string>
    
        <string name="successful">Successful</string>
        <string name="unsuccessful">Unsuccessful</string>
        <string name="no_data">No data</string>
    
        <string name="launch_year_field">Launch year: %s</string>
        <string name="mission_name_field">Launch name: %s</string>
        <string name="launch_success_field">Launch success: %s</string>
        <string name="details_field">Launch details: %s</string>
    </resources>
    
    <resources>
        <!-- Base application theme. -->
        <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
            <!-- Customize your theme here. -->
            <item name="colorPrimary">@color/colorPrimary</item>
            <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
            <item name="colorAccent">@color/colorAccent</item>
        </style>
    </resources>
    
  6. Завершите реализацию RecyclerView.Adapter:

    class LaunchesRvAdapter(var launches: List<RocketLaunch>) : RecyclerView.Adapter<LaunchesRvAdapter.LaunchViewHolder>() {
        // ...
        inner class LaunchViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
            private val missionNameTextView = itemView.findViewById<TextView>(R.id.missionName)
            private val launchYearTextView = itemView.findViewById<TextView>(R.id.launchYear)
            private val launchSuccessTextView = itemView.findViewById<TextView>(R.id.launchSuccess)
            private val missionDetailsTextView = itemView.findViewById<TextView>(R.id.details)
    
            fun bindData(launch: RocketLaunch) {
                val ctx = itemView.context
                missionNameTextView.text = ctx.getString(R.string.mission_name_field, launch.missionName)
                launchYearTextView.text = ctx.getString(R.string.launch_year_field, launch.launchYear.toString())
                missionDetailsTextView.text = ctx.getString(R.string.details_field, launch.details ?: "")
                val launchSuccess = launch.launchSuccess
                if (launchSuccess != null ) {
                    if (launchSuccess) {
                        launchSuccessTextView.text = ctx.getString(R.string.successful)
                        launchSuccessTextView.setTextColor((ContextCompat.getColor(itemView.context, R.color.colorSuccessful)))
                    } else {
                        launchSuccessTextView.text = ctx.getString(R.string.unsuccessful)
                        launchSuccessTextView.setTextColor((ContextCompat.getColor(itemView.context, R.color.colorUnsuccessful)))
                    }
                } else {
                    launchSuccessTextView.text = ctx.getString(R.string.no_data)
                    launchSuccessTextView.setTextColor((ContextCompat.getColor(itemView.context, R.color.colorNoData)))
                }
            }
        }
    }
    
  7. Обновите класс MainActivity следующим образом:

    class MainActivity : AppCompatActivity() {
        // ...
        private val launchesRvAdapter = LaunchesRvAdapter(listOf())
    
        override fun onCreate(savedInstanceState: Bundle?) {
            // ...
            launchesRecyclerView.adapter = launchesRvAdapter
            launchesRecyclerView.layoutManager = LinearLayoutManager(this)
    
            swipeRefreshLayout.setOnRefreshListener {
                swipeRefreshLayout.isRefreshing = false
                displayLaunches(true)
            }
    
            displayLaunches(false)
        }
    
        private fun displayLaunches(needReload: Boolean) {
            // TODO: Presentation logic
        }
    }
    

    Здесь вы создаете экземпляр LaunchesRvAdapter, настраиваете компонент RecyclerView и реализуете все функции интерфейса LaunchesListView. Для захвата жеста обновления экрана добавьте слушатель к SwipeRefreshLayout.

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

  1. Создайте экземпляр класса SpaceXSDK из общего модуля и внедрите в него экземпляр DatabaseDriverFactory:

    class MainActivity : AppCompatActivity() {
        // ...
        private val sdk = SpaceXSDK(DatabaseDriverFactory(this))
    }
    
  2. Реализуйте частную функцию displayLaunches(needReload: Boolean). Она выполняет функцию getLaunches() внутри запущенной в основной CoroutineScope корутины, обрабатывает исключения и отображает текст ошибки в сообщении об ошибке:

    class MainActivity : AppCompatActivity() {
        private val mainScope = MainScope()
        // ...
        override fun onDestroy() {
            super.onDestroy()
            mainScope.cancel()
        }
        // ...
        private fun displayLaunches(needReload: Boolean) {
            progressBarView.isVisible = true
            mainScope.launch {
                kotlin.runCatching {
                    sdk.getLaunches(needReload)
                }.onSuccess {
                    launchesRvAdapter.launches = it
                    launchesRvAdapter.notifyDataSetChanged()
                }.onFailure {
                    Toast.makeText(this@MainActivity, it.localizedMessage, Toast.LENGTH_SHORT).show()
                }
                progressBarView.isVisible = false
            }
        }
    }
    
  3. Выберите androidApp из меню конфигурации запуска, выберите эмулятор и нажмите кнопку запуска:

Android application

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

Вы можете найти состояние проекта после этого раздела на ветке final.

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

Для части проекта, относящейся к iOS, вы будете использовать SwiftUI для построения пользовательского интерфейса и паттерн «Модель-Представление-Представление-Модель» для связи интерфейса с общим модулем, содержащим всю бизнес-логику.

Общий модуль уже подключен к проекту iOS, поскольку мастер-мастер Android Studio выполнил всю настройку. Вы можете импортировать его так же, как и обычные зависимости iOS: import shared.

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

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

  1. Запустите ваше приложение Xcode и выберите Открыть проект или файл.

  2. Перейдите к своему проекту и выберите папку iosApp. Нажмите Открыть.

  3. В вашем проекте Xcode создайте новый Swift-файл с типом SwiftUI View, назовите его RocketLaunchRow и обновите его следующим кодом:

    import SwiftUI
    import shared
    
    struct RocketLaunchRow: View {
        var rocketLaunch: RocketLaunch
    
        var body: some View {
            HStack() {
                VStack(alignment: .leading, spacing: 10.0) {
                    Text("Launch name: \(rocketLaunch.missionName)")
                    Text(launchText).foregroundColor(launchColor)
                    Text("Launch year: \(String(rocketLaunch.launchYear))")
                    Text("Launch details: \(rocketLaunch.details ?? "")")
                }
                Spacer()
            }
        }
    }
    
    extension RocketLaunchRow {
        private var launchText: String {
            if let isSuccess = rocketLaunch.launchSuccess {
                return isSuccess.boolValue ? "Successful" : "Unsuccessful"
            } else {
                return "No data"
            }
        }
    
        private var launchColor: Color {
            if let isSuccess = rocketLaunch.launchSuccess {
                return isSuccess.boolValue ? Color.green : Color.red
            } else {
                return Color.gray
            }
        }
    }
    

    Список запусков будет отображаться в ContentView, который уже создал мастер-ассистент проекта.

  4. Создайте класс ViewModel для ContentView, который будет подготавливать и управлять данными. Объявите его как расширение для ContentView, так как они тесно связаны, и добавьте следующий код в ContentView.swift:

    // ...
    extension ContentView {
        enum LoadableLaunches {
            case loading
            case result([RocketLaunch])
            case error(String)
        }
    
       class ViewModel: ObservableObject {
           @Published var launches = LoadableLaunches.loading
       }
    }
    
    • Фреймворк Combine связывает модель представления (ContentView.ViewModel ) с представлением (ContentView).

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

  5. Реализуйте тело файла ContentView и отобразите список запусков:

    struct ContentView: View {
     @ObservedObject private(set) var viewModel: ViewModel
    
         var body: some View {
             NavigationView {
                 listView()
                 .navigationBarTitle("SpaceX 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))
             }
         }
     }
    

    Обертка свойства @ObservedObject используется для подписки на модель представления.

  6. Чтобы оно компилировалось, класс RocketLaunch должен подтвердить протокол Identifiable, так как он используется в качестве параметра для инициализации List Swift UIView. В классе RocketLaunch уже есть свойство с именем id, поэтому добавьте следующее в конец ContentView.swift:

    extension RocketLaunch: Identifiable { }
    

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

Для получения данных о запусках ракет в модели представления вам понадобится экземпляр SpaceXSDK из библиотеки Multiplatform.

  1. В ContentView.swift, передайте его через конструктор:

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

    func loadLaunches(forceReload: Bool) {
        self.launches = .loading
            sdk.getLaunches(forceReload: forceReload, completionHandler: { launches, error in
                if let launches = launches {
                    self.launches = .result(launches)
                    } else {
                        self.launches = .error(error?.localizedDescription ?? "error")
                    }
                })
            }
    
    • При компиляции модуля Kotlin в фреймворк Apple, функции приостановки доступны в нем как функции с обратными вызовами (completionHandler).

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

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

    import SwiftUI
    import shared
    
    @main
    struct iOSApp: App {
        let sdk = SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory())
        var body: some Scene {
            WindowGroup {
                ContentView(viewModel: .init(sdk: sdk))
            }
        }
    }
    
  4. В Android Studio переключитесь на конфигурацию iosApp, выберите эмулятор и запустите его, чтобы увидеть результат:

iOS Application

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

Что дальше?

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

Вы также можете ознакомиться с дополнительными учебными материалами:

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

  • Настройка вашего приложения Android для работы на iOS

  • Представление Kotlin Multiplatform Mobile вашей команде

Последнее изменение: 10 января 2023 г.
Опубликовать приложение Начало работы с Kotlin Multiplatform

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

Spec-Zone.ru

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