Spec-Zone.ru › Kotlin 1.9

Создание многоплатформенного приложения с помощью 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.

Как библиотеки kotlinx.serialization, так и SQLDelight требуют дополнительных настроек.

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

    val coroutinesVersion = "1.7.1"
    val ktorVersion = "2.3.2"
    val sqlDelightVersion = "1.5.5"
    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")
                implementation("org.jetbrains.kotlinx:kotlinx-datetime:$dateTimeVersion")
                }
            }
        val androidMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-android:$ktorVersion")
                implementation("com.squareup.sqldelight:android-driver:$sqlDelightVersion")
            }
        }
        val iosMain by getting {
            // ...
            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.9.0"
        id("com.squareup.sqldelight")
    }
    
  3. Теперь перейдите к файлу build.gradle.kts в корневом каталоге проекта и укажите путь к классу для плагина в зависимостях системы сборки:

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

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

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

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

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

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

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

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

  • Информация о ракете

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

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

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

    import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RocketLaunch( @SerialName("flight_number") val flightNumber: Int, @SerialName("name") val missionName: String, @SerialName("date_utc") val launchDateUTC: String, @SerialName("details") val details: String?, @SerialName("success") val launchSuccess: Boolean?, @SerialName("links") val links: Links ) { var launchYear = launchDateUTC.toInstant().toLocalDateTime(TimeZone.UTC).year } @Serializable data class Links( @SerialName("patch") val patch: Patch?, @SerialName("article") val article: String? ) @Serializable data class Patch( @SerialName("small") val small: String?, @SerialName("large") val large: String? )

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

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

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

END_OF_DOCUMENT_MARKER

Настройка 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,
        details TEXT,
        launchSuccess INTEGER AS Boolean DEFAULT NULL,
        launchDateUTC TEXT NOT NULL,
        patchUrlSmall TEXT,
        patchUrlLarge TEXT,
        articleUrl TEXT
    );
    
  4. Для вставки данных в таблицы объявите функцию SQL вставки:

    insertLaunch:
    INSERT INTO Launch(flightNumber, missionName, details, launchSuccess, launchDateUTC, patchUrlSmall, patchUrlLarge, articleUrl)
    VALUES(?, ?, ?, ?, ?, ?, ?, ?);
    
  5. Для очистки данных в таблицах объявите функцию SQL удаления:

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

    selectAllLaunchesInfo:
    SELECT Launch.*
    FROM Launch;
    

После компиляции проекта сгенерированный 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.Patch
    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.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,
        details: String?,
        launchSuccess: Boolean?,
        launchDateUTC: String,
        patchUrlSmall: String?,
        patchUrlLarge: String?,
        articleUrl: String?
    ): RocketLaunch {
        return RocketLaunch(
            flightNumber = flightNumber.toInt(),
            missionName = missionName,
            details = details,
            launchDateUTC = launchDateUTC,
            launchSuccess = launchSuccess,
            links = Links(
                patch = Patch(
                    small = patchUrlSmall,
                    large = patchUrlLarge
                ),
                article = articleUrl
            )
        )
    }
    

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

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

    internal fun createLaunches(launches: List<RocketLaunch>) {
        dbQuery.transaction {
            launches.forEach { launch ->
                insertLaunch(launch)
            }
        }
    }
    
    private fun insertLaunch(launch: RocketLaunch) {
        dbQuery.insertLaunch(
            flightNumber = launch.flightNumber.toLong(),
            missionName = launch.missionName,
            details = launch.details,
            launchSuccess = launch.launchSuccess ?: false,
            launchDateUTC = launch.launchDateUTC,
            patchUrlSmall = launch.links.patch?.small,
            patchUrlLarge = launch.links.patch?.large,
            articleUrl = launch.links.article
        )
    }
    

Экземпляр класса 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/v5/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.

END_OF_DOCUMENT_MARKER

Создайте 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.9.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
    implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
    implementation("androidx.core:core-ktx:1.10.1")
    implementation("androidx.recyclerview:recyclerview:1.3.0")
    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 корутине, обрабатывает исключения и отображает текст ошибки в сообщении Toast:

    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.

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

Сначала вы создадите представление 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)
        }
    
       @MainActor
       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, так как он используется в качестве параметра для инициализации Swift UIView List. У класса RocketLaunch уже есть свойство под названием id, поэтому добавьте следующее в конец файла ContentView.swift:

    extension RocketLaunch: Identifiable { }
    

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

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

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

    extension ContentView {
        // ...
        @MainActor
        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) {
        Task {
            do {
                self.launches = .loading
                let launches = try await sdk.getLaunches(forceReload: forceReload)
                self.launches = .result(launches)
            } catch {
                self.launches = .error(error.localizedDescription)
            }
        }
    }
    
    • При компиляции Kotlin-модуля в Apple-фреймворк, функции приостановки доступны в нём как механизм Swift's async/await.

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

  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

Последнее изменение: 26 июня 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