使用 Ktor 和 SQLDelight 创建多平台应用
本教程使用 IntelliJ IDEA,但您也可以在 Android Studio 中进行操作——这两个 IDE 共享相同的核心功能和 Kotlin Multiplatform 支持。
本教程演示了如何使用 IntelliJ IDEA 使用 Kotlin Multiplatform 为 iOS 和 Android 创建一个高级移动应用程序。 该应用程序将:
- 使用 Ktor 通过互联网从公开的 SpaceX API 获取数据。
- 使用 SQLDelight 将数据保存在本地数据库中。
- 显示 SpaceX 火箭发射列表,包括发射日期、结果以及发射的详细描述。
该应用程序将包含一个包含 iOS 和 Android 平台共享代码的模块。业务逻辑和数据访问层将在共享模块中仅实现一次,而两个应用程序的 UI 都将是原生的。

您将在项目中使用以下多平台库:
- Ktor 作为 HTTP 客户端,用于通过互联网检索数据。
kotlinx.serialization用于将 JSON 响应反序列化为实体类的对象。kotlinx.coroutines用于编写异步代码。- SQLDelight 用于从 SQL 查询生成 Kotlin 代码并创建类型安全的数据库 API。
- Koin 通过 SQL 注入提供平台特定的数据库驱动程序。
创建一个项目
在 快速入门 中,完成 为 Kotlin Multiplatform 开发设置环境 的说明。
在 IntelliJ IDEA 中,选择 File | New | Project。
在左侧面板中,选择 Kotlin Multiplatform(在 Android Studio 中,该模板可以在 New Project 向导的 Generic 选项卡中找到)。
在 New Project 窗口中指定以下字段:
- Name: SpaceTutorial
- Project ID: com.jetbrains.spacetutorial
选择 Android 和 iOS 目标。
对于 iOS,选择 Do not share UI 选项。您将为两个平台实现原生 UI。
指定完所有字段和目标后,点击 Create。

添加 Gradle 依赖项
要将多平台库添加到共享模块中,您需要将依赖项指令 (implementation) 添加到 build.gradle.kts 文件中相关源集 (source set) 的 dependencies {} 块中。
kotlinx.serialization 和 SQLDelight 库都需要额外的配置。
修改或在 gradle/libs.versions.toml 文件的版本目录中添加行,以反映所有需要的依赖项:
在
[versions]块中,检查 AGP 版本并添加其余部分:toml[versions] agp = "9.0.1" material3 = "1.11.0-alpha07" # ... coroutinesVersion = "1.10.2" dateTimeVersion = "0.7.1" koin = "4.1.0" ktor = "3.3.3" sqlDelight = "2.2.1"在
[libraries]块中,添加以下库引用:[libraries] ... android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "dateTimeVersion" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" }在
[plugins]块中,指定必要的 Gradle 插件:toml[plugins] # ... kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }版本目录更新后,系统会提示您重新同步项目。点击 Sync Gradle Changes 按钮同步 Gradle 文件:

在
sharedLogic/build.gradle.kts文件的最开头,将以下几行添加到plugins {}块中:kotlinplugins { // ... alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.sqldelight) }公共源集 (common source set) 需要每个库的核心构件 (artifact),以及 Ktor 的 序列化功能,以便使用
kotlinx.serialization处理网络请求和响应。 iOS 和 Android 源集 (source set) 也需要 SQLDelight 和 Ktor 的平台驱动程序。在同一个
sharedLogic/build.gradle.kts文件中,添加所有需要的依赖项:kotlinkotlin { // ... sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.runtime) implementation(libs.kotlinx.datetime) implementation(libs.koin.core) } androidMain.dependencies { implementation(libs.ktor.client.android) implementation(libs.android.driver) } iosMain.dependencies { implementation(libs.ktor.client.darwin) implementation(libs.native.driver) } } }指定依赖项后,再次点击 Sync Gradle Changes 按钮以更新 Gradle 文件。
完成 Gradle 同步后,项目配置就完成了,您可以开始编写代码了。
有关多平台依赖项的深入指南,请参阅 Kotlin Multiplatform 库的依赖项。
创建应用数据模型
本教程的应用将包含公共的 SpaceXSDK 类,作为联网和缓存服务的门面 (facade)。 应用程序数据模型将拥有三个实体类,包含:
- 关于发射的一般信息
- 任务徽章图像的链接
- 与发射相关的文章 URL
在本教程结束时,并非所有这些数据都会出现在 UI 中。 我们使用该数据模型是为了展示序列化。 但您可以尝试使用链接和徽标来将此示例扩展为更具信息量的内容!
创建必要的数据类:
在
sharedLogic/src/commonMain/kotlin/com/jetbrains/spacetutorial目录中,创建entity软件包, 然后在该软件包中创建Entity.kt文件。为基本实体声明所有数据类:
kotlin
每个可序列化的类都必须标记有 @Serializable 注解。除非您在注解参数中显式传递了对序列化程序的引用,否则 kotlinx.serialization 插件会自动为 @Serializable 类生成默认序列化程序。
@SerialName 注解允许您重新定义字段名称,这有助于使用更具可读性的标识符访问数据类中的属性。
配置 SQLDelight 并实现缓存逻辑
SQLDelight 库允许您从 SQL 查询生成类型安全的 Kotlin 数据库 API。在编译期间,生成器会验证 SQL 查询并将其转换为可在共享模块中使用的 Kotlin 代码。
配置 SQLDelight
SQLDelight 依赖项已包含在项目中。要配置该库,请打开 sharedLogic/build.gradle.kts 文件并在末尾添加 sqldelight {} 块。此块包含数据库列表及其参数:
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.jetbrains.spacetutorial.cache")
}
}
}packageName 参数指定生成的 Kotlin 源代码的包名。
提示时同步 Gradle 项目文件,或者按两次 并搜索 Sync All Gradle, Swift Package Manager projects 操作。
考虑安装官方的 SQLDelight 插件 以处理
.sq文件。
生成数据库 API
首先,创建包含所有必要 SQL 查询的 .sq 文件。默认情况下,SQLDelight 插件会在源集 (source set) 的 sqldelight 文件夹中查找 .sq 文件:
在
sharedLogic/src/commonMain目录中,创建一个新的sqldelight目录。在
sqldelight目录中,创建一个名为com/jetbrains/spacetutorial/cache的新目录,以创建软件包的嵌套目录。在
cache目录中,创建AppDatabase.sq文件(名称与您在build.gradle.kts文件中指定的数据库名称相同)。 应用程序的所有 SQL 查询都将存储在此文件中。数据库将包含一个带有发射数据的表。 将以下代码添加到
AppDatabase.sq文件中以创建表并定义稍后将使用的几个函数:textimport kotlin.Boolean; 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 ); -- 向 'Launch' 表中插入数据 insertLaunch: INSERT INTO Launch(flightNumber, missionName, details, launchSuccess, launchDateUTC, patchUrlSmall, patchUrlLarge, articleUrl) VALUES(?, ?, ?, ?, ?, ?, ?, ?); -- 清除 'Launch' 表中的所有数据 removeAllLaunches: DELETE FROM Launch; -- 检索所有发射的信息 selectAllLaunchesInfo: SELECT Launch.* FROM Launch;生成相应的
AppDatabase接口(稍后您将使用数据库驱动程序对其进行初始化)。 为此,请在项目根目录的终端中运行以下命令:shell./gradlew generateCommonMainAppDatabaseInterface生成的 Kotlin 代码存储在
sharedLogic/build/generated/sqldelight目录中。
为平台特定的数据库驱动程序创建工厂
要初始化 AppDatabase 接口,您需要向其传递一个 SqlDriver 实例。 SQLDelight 提供了 SQLite 驱动程序的多个平台特定实现,因此您需要为每个平台单独创建这些实例。
虽然您可以使用 预期接口和实际接口 来实现这一点, 但在本项目中,您将使用 Koin 来尝试 Kotlin Multiplatform 中的 SQL 注入。
为数据库驱动程序创建一个接口。为此,在
sharedLogic/src/commonMain/kotlin/com/jetbrains/spacetutorial/目录中, 创建cache软件包。在
cache软件包内部创建DatabaseDriverFactory接口:kotlinpackage com.jetbrains.spacetutorial.cache import app.cash.sqldelight.db.SqlDriver interface DatabaseDriverFactory { fun createDriver(): SqlDriver }为 Android 创建实现此接口的类:在
sharedLogic/src/androidMain/kotlin目录中, 创建com.jetbrains.spacetutorial.cache软件包,然后在其中创建DatabaseDriverFactory.kt文件。在 Android 上,SQLite 驱动程序由
AndroidSqliteDriver类实现。在DatabaseDriverFactory.kt文件中, 将数据库信息和上下文链接传递给AndroidSqliteDriver类构造函数:kotlinpackage com.jetbrains.spacetutorial.cache import android.content.Context import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory { override fun createDriver(): SqlDriver { return AndroidSqliteDriver(AppDatabase.Schema, context, "launch.db") } }对于 iOS,在
shared/src/iosMain/kotlin/com/jetbrains/spacetutorial/目录中,创建cache软件包。在
cache软件包内部,创建DatabaseDriverFactory.kt文件并添加以下代码:kotlinpackage com.jetbrains.spacetutorial.cache import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver class IOSDatabaseDriverFactory : DatabaseDriverFactory { override fun createDriver(): SqlDriver { return NativeSqliteDriver(AppDatabase.Schema, "launch.db") } }
稍后您将在项目的平台特定部分中使用这些工厂。
实现缓存
到目前为止,您已经为平台数据库驱动程序添加了工厂,并添加了一个用于执行数据库操作的 AppDatabase 接口。 现在,创建一个 Database 类,它将包装 AppDatabase 接口并包含缓存逻辑。
在公共源集 (common source set)
sharedLogic/src/commonMain/kotlin中,在com.jetbrains.spacetutorial.cache软件包下创建一个新的Database类。它将包含两个平台通用的逻辑。为了给
AppDatabase提供驱动程序,将一个抽象的DatabaseDriverFactory实例传递给Database类构造函数:kotlinpackage com.jetbrains.spacetutorial.cache internal class Database(databaseDriverFactory: DatabaseDriverFactory) { private val database = AppDatabase(databaseDriverFactory.createDriver()) private val dbQuery = database.appDatabaseQueries }该类的 可见性 设置为 internal,这意味着它只能在多平台模块内部访问。
在
Database类中,实现一些数据处理操作。 首先,创建getAllLaunches函数以返回所有火箭发射的列表。mapLaunchSelecting函数用于将数据库查询的结果映射到RocketLaunch对象:kotlinimport com.jetbrains.spacetutorial.entity.Links import com.jetbrains.spacetutorial.entity.Patch import com.jetbrains.spacetutorial.entity.RocketLaunch internal class Database(databaseDriverFactory: DatabaseDriverFactory) { // ... 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 ) ) } }添加
clearAndCreateLaunches函数以清除数据库并插入新数据:kotlininternal class Database(databaseDriverFactory: DatabaseDriverFactory) { // ... internal fun clearAndCreateLaunches(launches: List<RocketLaunch>) { dbQuery.transaction { dbQuery.removeAllLaunches() launches.forEach { launch -> 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 ) } } } }
实现 API 服务
为了通过互联网检索数据,您将使用 SpaceX 公开 API 和一个从 v5/launches 端点检索所有发射列表的方法。
创建一个将应用程序连接到 API 的类:
在
sharedLogic/src/commonMain/kotlin/com/jetbrains/spacetutorial/目录中,创建一个network软件包。在
network目录中,创建SpaceXApi类:kotlinpackage com.jetbrains.spacetutorial.network import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json class SpaceXApi { private val httpClient = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true useAlternativeNames = false }) } } }此类执行网络请求并将 JSON 响应反序列化为
com.jetbrains.spacetutorial.entity软件包中的实体。 KtorHttpClient实例初始化并存储httpClient属性。此代码使用
ContentNegotiationKtor 插件来反序列化GET请求的结果。该插件将请求和响应有效负载作为 JSON 处理,并根据需要对其进行序列化和反序列化。声明返回火箭发射列表的数据检索函数:
kotlinimport com.jetbrains.spacetutorial.entity.RocketLaunch import io.ktor.client.request.get import io.ktor.client.call.body class SpaceXApi { // ... suspend fun getAllLaunches(): List<RocketLaunch> { return httpClient.get("https://api.spacexdata.com/v5/launches").body() } }
getAllLaunches 函数具有 suspend 修饰符,因为它包含对挂起函数 HttpClient.get() 的调用。 HttpClient.get() 函数包含一个通过互联网检索数据的异步操作,并且只能从协程或另一个挂起函数中调用。网络请求将在 HTTP 客户端的线程池中执行。
发送 GET 请求的 URL 作为参数传递给 get() 函数。
构建 SDK
您的 iOS 和 Android 应用程序将通过共享模块与 SpaceX API 进行通信,共享模块将提供一个公共类 SpaceXSDK。
在公共源集 (common source set)
sharedLogic/src/commonMain/kotlin中,在com.jetbrains.spacetutorial软件包中,创建SpaceXSDK类。 此类将作为Database和SpaceXApi类的门面 (facade)。要创建
Database类实例,请提供一个DatabaseDriverFactory实例:kotlinpackage com.jetbrains.spacetutorial import com.jetbrains.spacetutorial.cache.Database import com.jetbrains.spacetutorial.cache.DatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) { private val database = Database(databaseDriverFactory) }您将通过
SpaceXSDK类构造函数在平台特定代码中注入正确的数据库驱动程序。添加
getLaunches函数,该函数使用创建的数据库和 API 来获取发射列表:kotlinimport com.jetbrains.spacetutorial.entity.RocketLaunch class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) { // ... @Throws(Exception::class) suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> { val cachedLaunches = database.getAllLaunches() return if (cachedLaunches.isNotEmpty() && !forceReload) { cachedLaunches } else { api.getAllLaunches().also { database.clearAndCreateLaunches(it) } } } }
该类包含一个获取所有发射信息的函数。根据 forceReload 的值,它返回缓存的值,或者从互联网加载数据,然后用结果更新缓存。如果没有缓存数据,无论 forceReload 标志的值如何,它都会从互联网加载数据。
您的 SDK 客户端可以使用 forceReload 标志来加载有关发射的最新信息,从而为用户启用下拉刷新手势。
所有 Kotlin 异常都是非受检异常,而 Swift 只有受检错误(详见 与 Swift/Objective-C 的互操作性)。因此,为了让您的 Swift 代码意识到预期的异常,从 Swift 调用的 Kotlin 函数应标有 @Throws 注解,并指定潜在异常类的列表。
创建 Android 应用程序
IntelliJ IDEA 为您处理了初始 Gradle 配置,因此 sharedUI 和 sharedLogic 模块已经连接到了您的 Android 应用程序 (androidApp)。
在实现 UI 和展示逻辑之前,请将 Koin Android 依赖项添加到 sharedUI/build.gradle.kts 文件中:
kotlin {
// ...
sourceSets {
androidMain.dependencies {
implementation(libs.koin.androidx.compose)
}
}
}提示时同步 Gradle 项目文件,或者按两次 并搜索 Sync All Gradle, Swift Package Manager projects。
为 androidApp 添加互联网访问权限
要访问互联网,Android 应用程序需要相应的权限。 在 androidApp/src/main/AndroidManifest.xml 文件中,添加 <uses-permission> 标记:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!--...-->
</manifest>添加 SQL 注入代码
Koin SQL 注入允许您声明可在不同上下文中使用的模块(组件集)。 在本项目中,您将创建两个模块:一个用于 Android 应用程序,另一个用于 iOS 应用。 然后,您将使用相应的模块为每个原生 UI 启动 Koin。
声明一个包含 Android 应用组件的 Koin 模块:
在
sharedUI/src/androidMain/kotlin目录中,在com.jetbrains.spacetutorial软件包中创建AppModule.kt文件。在该文件中,将模块声明为两个单例,一个用于
SpaceXApi类,一个用于SpaceXSDK类:kotlinpackage com.jetbrains.spacetutorial import com.jetbrains.spacetutorial.cache.AndroidDatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val appModule = module { single<SpaceXApi> { SpaceXApi() } single<SpaceXSDK> { SpaceXSDK( databaseDriverFactory = AndroidDatabaseDriverFactory( androidContext() ), api = get() ) } }SpaceXSDK类构造函数被注入了平台特定的AndroidDatabaseDriverFactory类。get()函数解析模块内的依赖项:Koin 将传递之前声明的SpaceXApi单例,以代替SpaceXSDK()的api参数。创建一个自定义的
Application类,该类将启动 Koin 模块。在您创建的
AppModule.kt文件旁边,创建包含以下代码的Application.kt文件,并在modules()函数调用中指定您声明的模块:kotlinpackage com.jetbrains.spacetutorial import android.app.Application import org.koin.android.ext.koin.androidContext import org.koin.core.context.GlobalContext.startKoin class MainApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@MainApplication) modules(appModule) } } }在
AndroidManifest.xml文件的<application>标记中指定您创建的MainApplication类:xml<manifest xmlns:android="http://schemas.android.com/apk/res/android"> ... <application ... android:name="com.jetbrains.spacetutorial.MainApplication"> ... </application> </manifest>
现在,您已准备好实现使用平台特定数据库驱动程序提供的信息的 UI。
准备带有发射列表的 View model
您将使用 Jetpack Compose 和 Material 3 实现 Android UI。首先,您将创建使用 SDK 获取发射列表的 View model。然后,您将设置 Material 主题,最后,您将编写将所有内容整合在一起的可组合函数 (composable function)。
在
sharedUI/src/androidMain/kotlin目录中,在com.jetbrains.spacetutorial软件包中创建RocketLaunchViewModel.kt文件:kotlinpackage com.jetbrains.spacetutorial import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.jetbrains.spacetutorial.entity.RocketLaunch class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { private val _state = mutableStateOf(RocketLaunchScreenState()) val state: State<RocketLaunchScreenState> = _state } data class RocketLaunchScreenState( val isLoading: Boolean = false, val launches: List<RocketLaunch> = emptyList() )RocketLaunchScreenState实例将存储从 SDK 接收的数据和请求的当前状态。在
RocketLaunchViewModel类中添加loadLaunches函数,该函数将在该 View model 的协程作用域内调用 SDK 的getLaunches函数:kotlinimport androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { //... fun loadLaunches() { viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, launches = emptyList()) try { val launches = sdk.getLaunches(forceReload = true) _state.value = _state.value.copy(isLoading = false, launches = launches) } catch (e: Exception) { _state.value = _state.value.copy(isLoading = false, launches = emptyList()) } } } }在
RocketLaunchViewModel类中,添加一个包含loadLaunches()调用的init {}块,以便在创建RocketLaunchViewModel对象后立即向 API 请求数据:kotlinclass RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { // ... init { loadLaunches() } }现在,在
AppModule.kt文件中,在 Koin 模块中指定 View model:kotlinimport org.koin.core.module.dsl.viewModel val appModule = module { // ... viewModel { RocketLaunchViewModel(sdk = get()) } }
构建 Material Theme
您将围绕 Material Theme 提供的 AppTheme 函数构建主 App() 可组合项:
您可以使用 Material Theme Builder 为您的 Compose 应用生成主题。选择您的颜色、字体,然后点击右下角的 Export theme。
在导出屏幕上,点击 Export 下拉菜单并选择 Jetpack Compose (Theme.kt) 选项。
解压存档并将
theme文件夹复制到sharedUI/src/androidMain/kotlin/com/jetbrains/spacetutorial目录中:
在
theme软件包内的每个文件中,更改package行以引用您创建的软件包:kotlinpackage com.jetbrains.spacetutorial.theme在
Color.kt文件中,为成功和不成功的发射添加两个颜色变量:kotlinval app_theme_successful = Color(0xff4BB543) val app_theme_unsuccessful = Color(0xffFC100D)
实现展示逻辑
为您的应用程序创建主 App() 可组合项,并从 ComponentActivity 类中调用它:
从
sharedUI模块中删除commonMain和commonTest源集 (source sets),因为 Android UI 是不共享的。在
sharedUI/src/androidApp/kotlin/com/jetbrains/spacetutorial目录中创建App.kt文件。打开
App.kt文件并插入以下代码:kotlinpackage com.jetbrains.spacetutorial import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import org.koin.androidx.compose.koinViewModel import androidx.compose.material3.ExperimentalMaterial3Api @OptIn( ExperimentalMaterial3Api::class ) @Composable @Preview fun App() { val viewModel = koinViewModel<RocketLaunchViewModel>() val state by remember { viewModel.state } val coroutineScope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } val pullToRefreshState = rememberPullToRefreshState() }这里,您使用了 Koin ViewModel API 来引用您在 Android Koin 模块中声明的
viewModel。现在添加 UI 代码,它将实现加载屏幕、发射结果列以及下拉刷新操作:
kotlinpackage com.jetbrains.spacetutorial import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.jetbrains.spacetutorial.entity.RocketLaunch import com.jetbrains.spacetutorial.theme.AppTheme import com.jetbrains.spacetutorial.theme.app_theme_successful import com.jetbrains.spacetutorial.theme.app_theme_unsuccessful import kotlinx.coroutines.launch ... @OptIn( ExperimentalMaterial3Api::class ) @Composable @Preview fun App() { val viewModel = koinViewModel<RocketLaunchViewModel>() val state by remember { viewModel.state } val coroutineScope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } val pullToRefreshState = rememberPullToRefreshState() AppTheme { Scaffold( topBar = { TopAppBar( title = { Text( "SpaceX Launches", style = MaterialTheme.typography.headlineLarge ) } ) } ) { padding -> PullToRefreshBox( modifier = Modifier .fillMaxSize() .padding(padding), state = pullToRefreshState, isRefreshing = isRefreshing, onRefresh = { isRefreshing = true coroutineScope.launch { viewModel.loadLaunches() isRefreshing = false } } ) { if (state.isLoading && !isRefreshing) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { Text("Loading...", style = MaterialTheme.typography.bodyLarge) } } else { LazyColumn { items(state.launches) { launch: RocketLaunch -> Column(modifier = Modifier.padding(16.dp)) { Text( text = "${launch.missionName} - ${launch.launchYear}", style = MaterialTheme.typography.headlineSmall ) Spacer(Modifier.height(8.dp)) Text( text = if (launch.launchSuccess == true) "Successful" else "Unsuccessful", color = if (launch.launchSuccess == true) app_theme_successful else app_theme_unsuccessful ) Spacer(Modifier.height(8.dp)) val details = launch.details if (details != null && details.isNotBlank()) { Text(details) } } HorizontalDivider() } } } } } } }最后,在
androidApp/src/main/AndroidManifest.xml中,在<activity>标记中指定您的MainActivity类:xml<manifest xmlns:android="http://schemas.android.com/apk/res/android"> ... <application ... <activity ... android:name="com.jetbrains.spacetutorial.MainActivity"> ... </activity> </application> </manifest>运行您的 Android 应用:从运行配置菜单中选择 composeApp,选择模拟器,然后点击运行按钮。该应用会自动运行 API 请求并显示发射列表(背景颜色取决于您生成的 Material Theme):

您刚刚创建了一个 Android 应用程序,其业务逻辑在 Kotlin Multiplatform 模块中实现,UI 使用原生 Jetpack Compose 制作。
创建 iOS 应用程序
对于项目的 iOS 部分,您将利用 SwiftUI 构建用户界面,并使用 Model View View-Model 模式。
IntelliJ IDEA 会生成一个已连接到共享模块的 iOS 项目。Kotlin 模块以 sharedLogic/build.gradle.kts 文件中指定的名称(baseName = "SharedLogic")导出,并使用常规的 import 语句导入:import SharedLogic。
为 SQLDelight 添加动态链接标志
默认情况下,IntelliJ IDEA 生成的项目设置为静态链接 iOS 框架。
要在 iOS 上使用原生 SQLDelight 驱动程序,请添加动态链接器标志,以便 Xcode 工具能够找到系统提供的 SQLite 二进制文件:
在 IntelliJ IDEA 中,选择 File | Open Project in Xcode 选项以在 Xcode 中打开您的项目。
在 Xcode 中,点击项目名称以打开其设置。
切换到 Build Settings 选项开,在此处切换到 All 列表并搜索 Other Linker Flags 字段。
展开该字段,点击 Debug 字段旁边的加号,并将
-lsqlite3字符串粘贴到 Any Architecture | Any SDK 中。对 Other Linker Flags | Release 字段重复此过程。

返回 IntelliJ IDEA。
为 iOS SQL 注入准备 Koin 类
要在 Swift 代码中使用 Koin 类和函数,请创建一个特殊的 KoinComponent 类并为 iOS 声明 Koin 模块。
在
sharedLogic/src/iosMain/kotlin/com/jetbrains/spacetutorial目录中创建KoinHelper.kt文件。添加
KoinHelper类,它将使用延迟 Koin 注入来包装SpaceXSDK类:kotlinpackage com.jetbrains.spacetutorial import org.koin.core.component.KoinComponent import com.jetbrains.spacetutorial.entity.RocketLaunch import org.koin.core.component.inject class KoinHelper : KoinComponent { private val sdk: SpaceXSDK by inject<SpaceXSDK>() suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> { return sdk.getLaunches(forceReload = forceReload) } }在
KoinHelper类下方,添加initKoin()函数,您将在 Swift 中使用它来初始化并启动 iOS Koin 模块:kotlinimport com.jetbrains.spacetutorial.cache.IOSDatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi import org.koin.core.context.startKoin import org.koin.dsl.module fun initKoin() { startKoin { modules(module { single<SpaceXApi> { SpaceXApi() } single<SpaceXSDK> { SpaceXSDK( databaseDriverFactory = IOSDatabaseDriverFactory(), api = get() ) } }) } }
现在,您可以在 iOS 应用中启动 Koin 模块,以便通过通用的 SpaceXSDK 类使用原生数据库驱动程序。
实现 UI
首先,您将创建一个 RocketLaunchRow SwiftUI 视图来显示列表中的一项。它将基于 HStack 和 VStack 视图。RocketLaunchRow 结构上将会有一些扩展,包含显示数据的有用帮助程序。
在 IntelliJ IDEA 中,确保您处于 Project 视图中。
在
iosApp/iosApp文件夹中,在ContentView.swift旁边创建一个新的 Swift 文件,并命名为RocketLaunchRow。使用以下代码更新
RocketLaunchRow.swift文件:Swiftimport SwiftUI import SharedLogic struct RocketLaunchRow: View { var rocketLaunch: RocketLaunch var body: some View { HStack() { VStack(alignment: .leading, spacing: 10.0) { Text("\(rocketLaunch.missionName) - \(String(rocketLaunch.launchYear))").font(.system(size: 18)).bold() Text(launchText).foregroundColor(launchColor) Text("Launch year: \(String(rocketLaunch.launchYear))") Text("\(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视图中。在
ContentView.swift文件中,为ContentView类创建一个扩展,带有一个ViewModel类,该类将准备和管理数据:Swiftextension ContentView { enum LoadableLaunches { case loading case result([RocketLaunch]) case error(String) } @MainActor class ViewModel: ObservableObject { @Published var launches = LoadableLaunches.loading } }View model (
ContentView.ViewModel) 通过 Combine 框架 与视图 (ContentView) 连接:ContentView.ViewModel类被声明为ObservableObject。@Published特性用于launches属性,因此每当此属性更改时,View model 都会发出信号。
移除
ContentView_Previews结构:您不需要实现一个必须与您的 View model 兼容的预览。更新
ContentView类的正文以显示发射列表并添加重新加载功能。- 这是 UI 基础工作:您将在教程的下一阶段实现
loadLaunches函数。 viewModel属性标有@ObservedObject特性,以订阅 View model。
swiftstruct 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)) } } }- 这是 UI 基础工作:您将在教程的下一阶段实现
RocketLaunch类用作初始化List视图的参数,因此它需要 遵循Identifiable协议。该类已经有一个名为id的属性,因此您只需在ContentView.swift底部添加一个扩展:Swiftextension RocketLaunch: Identifiable { }
加载数据
要在 View model 中检索有关火箭发射的数据,您需要多平台库中 KoinHelper 类的一个实例。它将允许您使用正确的数据库驱动程序调用 SDK 函数。
在
ContentView.swift文件中,扩展ViewModel类以包含KoinHelper对象和loadLaunches函数:Swiftextension ContentView { // ... class ViewModel: ObservableObject { // ... let helper: KoinHelper = KoinHelper() init() { self.loadLaunches(forceReload: false) } func loadLaunches(forceReload: Bool) { // TODO: retrieve data } } }在
loadLaunches()函数中,调用KoinHelper.getLaunches()函数(它将代理对SpaceXSDK类的调用)并将结果保存在launches属性中:Swiftfunc loadLaunches(forceReload: Bool) { Task { do { self.launches = .loading let launches = try await helper.getLaunches(forceReload: forceReload) self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } }当您将 Kotlin 模块编译为 Apple 框架时,可以使用 Swift 的
async/await机制调用 挂起函数。由于
getLaunches函数在 Kotlin 中标有@Throws(Exception::class)注解,因此任何作为Exception类或其子类实例的异常都将作为NSError传播到 Swift。因此,所有此类异常都可以由loadLaunches()函数捕获。转到应用的入口点
iOSApp.swift文件,并初始化 Koin 模块、视图和 View model:Swiftimport SwiftUI import SharedLogic @main struct iOSApp: App { init() { KoinHelperKt.doInitKoin() } var body: some Scene { WindowGroup { ContentView(viewModel: .init()) } } }在 IntelliJ IDEA 中,切换到 iosApp 配置,选择模拟器,然后运行它以查看结果:

您可以在
final分支上 找到该项目的最终版本。
下一步是什么?
本教程包含一些可能耗费资源的操作,例如在主线程中解析 JSON 和向数据库发出请求。要了解如何编写并发代码并优化您的应用,请参阅 协程指南。
您还可以查看这些额外的学习材料: