Создание многоплатформенного приложения с помощью 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.
Как библиотеки kotlinx.serialization, так и SQLDelight требуют дополнительных настроек.
-
В каталоге
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для обработки сетевых запросов и ответов.
-
В самом начале файла
build.gradle.ktsв том же каталогеsharedдобавьте следующие строки в блокplugins:plugins { // ... kotlin("plugin.serialization") version "1.9.0" id("com.squareup.sqldelight") } -
Теперь перейдите к файлу
build.gradle.ktsв корневом каталоге проекта и укажите путь к классу для плагина в зависимостях системы сборки:buildscript { dependencies { // ... classpath("com.squareup.sqldelight:gradle-plugin:1.5.5") } } -
Наконец, определите версию SQLDelight в файле
gradle.propertiesв корневом каталоге проекта, чтобы убедиться, что версии SQLDelight плагина и библиотек совпадают:sqlDelightVersion=1.5.5
Синхронизируйте проект Gradle.
Дополнительные сведения об добавлении зависимостей многоплатформенных библиотек.
Создание модели данных приложения
Приложение Kotlin Multiplatform будет содержать публичный SpaceXSDK класс, фасад над сетевыми и кэш-сервисами. Модель данных приложения будет иметь три класса сущностей с:
Общие сведения о запуске
Ссылка на внешнюю информацию
Информация о ракете
В
shared/src/commonMain/kotlinдобавьте пакетcom.jetbrains.handson.kmm.shared.entity.Создайте файл
Entity.ktвнутри пакета.-
Объявите все классы данных для основных сущностей:
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 позволяет переопределять имена полей, что помогает объявлять свойства в классах данных с более удобочитаемыми именами.
Настройка 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, details TEXT, launchSuccess INTEGER AS Boolean DEFAULT NULL, launchDateUTC TEXT NOT NULL, patchUrlSmall TEXT, patchUrlLarge TEXT, articleUrl TEXT ); -
Для вставки данных в таблицы объявите функцию SQL вставки:
insertLaunch: INSERT INTO Launch(flightNumber, missionName, details, launchSuccess, launchDateUTC, patchUrlSmall, patchUrlLarge, articleUrl) VALUES(?, ?, ?, ?, ?, ?, ?, ?);
-
Для очистки данных в таблицах объявите функцию SQL удаления:
removeAllLaunches: DELETE FROM Launch;
-
Аналогичным образом, объявите функцию для получения данных:
selectAllLaunchesInfo: SELECT Launch.* FROM Launch;
После компиляции проекта сгенерированный 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.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, что означает, что он доступен только внутри модуля многоплатформенного приложения.
-
Внутри класса
Databaseреализуйте некоторые операции обработки данных. Добавьте функцию очистки всех таблиц в базе данных в одной SQL-транзакции:internal fun clearDatabase() { dbQuery.transaction { 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, 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. -
Добавьте функцию вставки данных в базу данных:
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.
Реализация службы 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/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>
Создайте 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.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")
}
// ...
Реализуйте пользовательский интерфейс: отобразите список запусков ракет
-
Для реализации пользовательского интерфейса создайте файл
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корутине, обрабатывает исключения и отображает текст ошибки в сообщении 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 } } } Выберите androidApp из меню конфигураций запуска, выберите эмулятор и нажмите кнопку запуска:

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