Создание кроссплатформенного приложения с помощью Ktor и SQLDelight – руководство
В этом руководстве показано, как использовать Android Studio для создания мобильного приложения для iOS и Android с помощью Kotlin Multiplatform Mobile с Ktor и SQLDelight.
Приложение будет включать модуль с общим кодом для платформ iOS и Android. Бизнес-логика и уровни доступа к данным будут реализованы только в общем модуле, в то время как пользовательский интерфейс обоих приложений будет нативным.
Вы получите приложение, которое получает данные из сети с публичного API SpaceX, сохраняет их в локальной базе данных и отображает список запусков ракет SpaceX вместе с датой запуска, результатами и подробным описанием запуска:

В проекте вы будете использовать следующие кроссплатформенные библиотеки:
Ktor в качестве HTTP-клиента для получения данных из сети.
kotlinx.serializationдля десериализации JSON-ответов в объекты классов сущностей.kotlinx.coroutinesдля написания асинхронного кода.SQLDelight для генерации Kotlin-кода из SQL-запросов и создания безопасного для типов API базы данных.
Прежде чем начать
Загрузите и установите Android Studio.
-
Найдите плагин Kotlin Multiplatform Mobile в Marketplace Android Studio и установите его.

Загрузите и установите Xcode.
Дополнительную информацию см. в разделе Настройка среды.
Создание кроссплатформенного проекта
-
В Android Studio выберите Файл | Новый | Новый проект. В списке шаблонов проектов выберите Kotlin Multiplatform App и нажмите Далее.

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

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

Дополнительную информацию о функциях проекта и способах их использования см. в разделе Понимание структуры проекта.
Добавление зависимостей в кроссплатформенный модуль
Для добавления кроссплатформенной библиотеки в общий модуль необходимо добавить инструкции по зависимостям (implementation) для всех библиотек в блок dependencies соответствующих наборов исходных файлов в файле build.gradle.kts.
Как библиотеки Ktor, так и SQLDelight требуют дополнительной настройки.
-
В каталоге
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для обработки сетевых запросов и ответов.
-
В самом начале файла
build.gradle.ktsв том же каталогеshared, добавьте следующие строки в блокplugins.plugins { // ... kotlin("plugin.serialization") version "1.8.0" id("com.squareup.sqldelight") } -
Теперь перейдите к файлу
build.gradle.ktsв корневой директории проекта и укажите класспаут для плагина в зависимостях системы сборки:buildscript { // ... val sqlDelightVersion = "1.5.4" dependencies { // ... classpath("com.squareup.sqldelight:gradle-plugin:$sqlDelightVersion") } } -
Наконец, определите версию SQLDelight в файле
gradle.propertiesв корневой директории проекта, чтобы гарантировать, что версии SQLDelight плагина и библиотек совпадают:sqlDelightVersion=1.5.4
Синхронизируйте проект Gradle.
Узнайте больше о добавлении зависимостей кроссплатформенных библиотек.
Создание модели данных приложения
Приложение Kotlin Multiplatform будет содержать публичный SpaceXSDK класс, фасад над сетевыми и кеширующими службами. Модель данных приложения будет иметь три класса сущностей с:
Общие сведения о запуске
Ссылка на внешнюю информацию
Данные о ракете
В
shared/src/commonMain/kotlin, добавьте пакетcom.jetbrains.handson.kmm.shared.entity.Создайте файл
Entity.ktвнутри пакета.-
Объявите все классы данных для основных сущностей:
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 позволяет переопределить имена полей, что помогает объявлять свойства в классах данных с более читаемыми именами.
Настройка 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-источников.
Генерация API базы данных
Сначала создайте файл .sq, который будет содержать все необходимые SQL-запросы. По умолчанию плагин SQLDelight читает файлы .sq из папки sqldelight:
В
shared/src/commonMain, создайте новую директориюsqldelightи добавьте пакетcom.jetbrains.handson.kmm.shared.cache.Внутри пакета создайте файл
.sqс именем базы данныхAppDatabase.sq. Все SQL-запросы для приложения будут в этом файле.-
База данных будет содержать две таблицы с данными о запусках и ракетах. Чтобы создать таблицы, добавьте следующий код в файл
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 ); -
Чтобы вставить данные в таблицы, объявите SQL-функции вставки:
insertLaunch: INSERT INTO Launch(flightNumber, missionName, launchYear, rocketId, details, launchSuccess, launchDateUTC, missionPatchUrl, articleUrl) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?); insertRocket: INSERT INTO Rocket(id, name, type) VALUES(?, ?, ?);
-
Чтобы очистить данные в таблицах, объявите SQL-функции удаления:
removeAllLaunches: DELETE FROM Launch; removeAllRockets: DELETE FROM Rocket;
-
Аналогичным образом, объявите функции для извлечения данных. Для данных о ракете используйте ее идентификатор и выберите информацию обо всех ее запусках с помощью оператора 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, поэтому их нужно создавать для каждой платформы отдельно. Это можно сделать, используя ожидаемые и фактические объявления.
-
Создайте абстрактную фабрику для драйверов баз данных. Для этого в
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для этого ожидаемого класса. -
На 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") } } -
На 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 с помощью удобной иконки в области отступов:

Реализация кэша
До этого вы добавили драйвера баз данных для платформ и класс AppDatabase для выполнения операций с базой данных. Теперь создайте класс Database, который обернёт класс AppDatabase и будет содержать логику кэширования.
В общем наборе источников
shared/src/commonMain/kotlin, создайте новый классDatabaseв пакетеcom.jetbrains.handson.kmm.shared.cache. Он будет общим для обеих платформ.-
Для предоставления драйвера для
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, что означает, что он доступен только изнутри многоплатформенного модуля.
-
Внутри класса
Databaseреализуйте некоторые операции обработки данных. Добавьте функцию для очистки всех таблиц в базе данных в одной SQL-транзакции:internal fun clearDatabase() { dbQuery.transaction { dbQuery.removeAllRockets() dbQuery.removeAllLaunches() } } -
Создайте функцию для получения списка всех запусков ракет:
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. -
Добавьте функцию для вставки данных в базу данных:
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.
Реализация API-сервиса
Для получения данных через интернет вам понадобится публичный API SpaceX и один метод для получения списка всех запусков с помощью конечной точки v5/launches.
Создайте класс, который подключит приложение к API:
-
В общем наборе источников
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. Экземпляр KtorHttpClientинициализирует и сохраняет свойствоhttpClient.Этот код использует плагин Ktor
ContentNegotiationдля десериализации результата запросаGET. Плагин обрабатывает запрос и полезную нагрузку ответа как JSON, сериализуя и десериализуя их с помощью специального сериализатора.
-
Объявите функцию получения данных, которая вернёт список
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>
Создание SDK
Ваши приложения для iOS и Android будут взаимодействовать с API SpaceX через общий модуль, который предоставит публичный класс.
-
В пакете
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. -
Для создания экземпляра класса
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, определяющей список потенциальных классов исключений.
Создание приложения 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")
}
// ...
Реализация пользовательского интерфейса: отображение списка запусков ракет
-
Для реализации пользовательского интерфейса создайте файл
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> -
В
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) } } -
Чтобы элемент
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) { // ... } } } -
Создайте файл ресурсов
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> -
В
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> -
Завершите реализацию
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))) } } } } -
Обновите класс
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.
Реализация логики представления
-
Создайте экземпляр класса
SpaceXSDKиз общего модуля и внедрите в него экземплярDatabaseDriverFactory:class MainActivity : AppCompatActivity() { // ... private val sdk = SpaceXSDK(DatabaseDriverFactory(this)) } -
Реализуйте частную функцию
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 } } } Выберите androidApp из меню конфигурации запуска, выберите эмулятор и нажмите кнопку запуска:

Вы только что создали приложение Android, в котором бизнес-логика реализована в модуле Kotlin Multiplatform Mobile.
Создание приложения для iOS
Для части проекта, относящейся к iOS, вы будете использовать SwiftUI для построения пользовательского интерфейса и паттерн «Модель-Представление-Представление-Модель» для связи интерфейса с общим модулем, содержащим всю бизнес-логику.
Общий модуль уже подключен к проекту iOS, поскольку мастер-мастер Android Studio выполнил всю настройку. Вы можете импортировать его так же, как и обычные зависимости iOS: import shared.
Реализация пользовательского интерфейса
Сначала вы создадите вид RocketLaunchRow SwiftUI для отображения элемента из списка. Он будет основан на видах HStack и VStack. Будут расширения структуры RocketLaunchRow с полезными вспомогательными средствами для отображения данных.
Запустите ваше приложение Xcode и выберите Открыть проект или файл.
Перейдите к своему проекту и выберите папку
iosApp. Нажмите Открыть.-
В вашем проекте 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, который уже создал мастер-ассистент проекта. -
Создайте класс
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, поэтому модель представления будет испускать сигналы всякий раз, когда это свойство изменяется.
-
Реализуйте тело файла
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используется для подписки на модель представления. -
Чтобы оно компилировалось, класс
RocketLaunchдолжен подтвердить протоколIdentifiable, так как он используется в качестве параметра для инициализацииListSwift UIView. В классеRocketLaunchуже есть свойство с именемid, поэтому добавьте следующее в конецContentView.swift:extension RocketLaunch: Identifiable { }
Загрузка данных
Для получения данных о запусках ракет в модели представления вам понадобится экземпляр SpaceXSDK из библиотеки Multiplatform.
-
В
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 } } } -
Вызовите функцию
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.
-
Перейдите к точке входа приложения,
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)) } } } В Android Studio переключитесь на конфигурацию iosApp, выберите эмулятор и запустите его, чтобы увидеть результат:

Что дальше?
В этом руководстве представлены некоторые потенциально ресурсоемкие операции, такие как разбор JSON и обращение к базе данных в основном потоке. Чтобы узнать, как писать конкурентный код и оптимизировать приложение, см. Как работать с конкурентностью.
Вы также можете ознакомиться с дополнительными учебными материалами:
© 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