1. 准备工作
前提条件
- 对 Kotlin Multiplatform 有基本的了解。
- 具备 Kotlin 使用经验。
- 对 Swift 语法有基本的了解。
- 安装了 Xcode 和 iOS 模拟器。
所需条件
- 最新的稳定版 Android Studio。
- 搭载 macOS 系统的 Mac 计算机。
- Xcode 16.1 和搭载 iOS 16.0 或更高版本的 iPhone 模拟器。
学习内容
- 如何在 Android 应用和 iOS 应用之间共享 Room 数据库。
2. 进行设置
要开始,请执行以下步骤:
- 使用以下终端命令克隆 GitHub 代码库:
$ git clone https://github.com/android/codelab-android-kmp.git
或者,您也能以 Zip 文件的形式下载该代码库:
- 在 Android Studio 中,打开
migrate-room项目,其中包含以下分支:
main:包含该项目的起始代码,您将在其中做出更改来完成此 Codelab。end:包含此 Codelab 的解决方案代码。
我们建议您从 main 分支开始,按照自己的节奏逐步完成此 Codelab。
- 如果您想查看解决方案代码,请运行以下命令:
$ git clone -b end https://github.com/android/codelab-android-kmp.git
或者,您也可以下载解决方案代码:
3. 了解示例应用
本教程包含使用原生框架构建的 Fruitties 示例应用,该应用在 Android 上采用 Jetpack Compose 框架,在 iOS 上则采用 SwiftUi 框架。
Fruitties 应用提供两项主要功能:
- 一个包含多个 Fruit 项目的列表,每个项目旁边都设有一个按钮以用于将该项目添加至 Cart。
- Cart 呈现在应用顶部,显示添加的水果种类及对应数量。

Android 应用架构
Android 应用遵循官方 Android 架构准则,以保持清晰的模块化结构。

iOS 应用架构

KMP 共享模块
此项目已设置 KMP 共享模块,但目前为空。如果您的项目尚未设置共享模块,请先完成Kotlin Multiplatform 使用入门 Codelab。
4. 为 KMP 集成准备 Room 数据库
在将 Room 数据库代码从 Fruitties Android 应用移至 shared 模块之前,您需要确保该应用与 Kotlin Multiplatform (KMP) Room API 兼容。本部分将引导您完成该过程。
一项关键更新是使用与 Android 和 iOS 均兼容的 SQLite 驱动程序。如需在多个平台上支持 Room 数据库功能,您可以使用 BundledSQLiteDriver。此驱动程序会将 SQLite 直接捆绑到应用中,使其适用于 Kotlin 的多平台使用。如需获得详细指导,请参阅 Room KMP 迁移指南。
更新依赖项
首先,将 room-runtime 和 sqlite-bundled 依赖项添加到 libs.versions.toml 文件中:
# Add libraries
[libraries]
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" }
接下来,更新 :androidApp 模块的 build.gradle.kts 以使用这些依赖项,并移除对 libs.androidx.room.ktx 的使用:
// Add
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
// Remove
implementation(libs.androidx.room.ktx)
现在,在 Android Studio 中同步项目。
为 BundledSQLiteDriver 修改数据库模块
接下来,修改 Android 应用中的数据库创建逻辑以使用 BundledSQLiteDriver,让其与 KMP 兼容,同时在 Android 上保持功能。
- 打开位于
androidApp/src/main/kotlin/com/example/fruitties/kmptutorial/android/di/DatabaseModule.kt的DatabaseModule.kt文件 - 更新
providesAppDatabase方法,如以下代码段所示:
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
@Module
@InstallIn(SingletonComponent::class)
internal object DatabaseModule {
...
@Provides
@Singleton
fun providesAppDatabase(@ApplicationContext context: Context): AppDatabase {
val dbFile = context.getDatabasePath("sharedfruits.db")
return Room.databaseBuilder<AppDatabase>(context, dbFile.absolutePath)
.setDriver(BundledSQLiteDriver())
.build()
}
构建并运行 Android 应用
现在,您已将原生 SQLite 驱动程序切换为捆绑驱动程序,接下来,请验证应用 build 以及所有内容是否正常运行,然后再将数据库迁移到 :shared 模块。
5. 将数据库代码移至 :shared 模块
在此步骤中,我们会将 Room 数据库设置从 Android 应用转移到 :shared 模块,以便 Android 和 iOS 都能访问该数据库。
更新 :shared 模块的 build.gradle.kts 配置
首先,更新 :shared 模块的 build.gradle.kts,以使用 Room 多平台依赖项。
- 添加 KSP 和 Room 插件:
plugins {
...
// TODO add KSP + ROOM plugins
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
- 将
room-runtime和sqlite-bundled依赖项添加到commonMain代码块中:
sourceSets {
commonMain {
// TODO Add KMP dependencies here
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
}
}
- 通过添加新的顶级
dependencies代码块,为每个平台目标添加 KSP 配置。为方便起见,您可以将其添加到文件底部:
// Should be its own top level block. For convenience, add at the bottom of the file
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
}
- 同样在顶级,添加一个新代码块以设置
Room架构位置:
// Should be its own top level block. For convenience, add at the bottom of the file
room {
schemaDirectory("$projectDir/schemas")
}
- Gradle 会同步该项目
将 Room 架构移至 :shared 模块
将 androidApp/schemas 目录移至 src/ 文件夹旁边的 :shared 模块根文件夹:
原位置:
新位置:
移动 DAO 和实体
现在,您已向 KMP 共享模块添加了必要的 Gradle 依赖项。接下来,需要将 DAO 和实体从 :androidApp 模块移至 :shared 模块。
这一过程涉及将相关文件移至 :shared 模块中 commonMain 源代码集内的相应位置。
移动 Fruittie 模型
您可利用 Refactor → Move功能来切换模块,而不破坏导入:
- 找到
androidApp/src/main/kotlin/.../model/Fruittie.kt文件,右键点击该文件,然后依次选择重构→移动(或按 F6):
- 在 Move 对话框中,选择 Destination directory 字段旁边的
...图标。
- 在 commonMain 对话框中选择 commonMain 源代码集,然后点击“OK”。您可能需要停用 Show only existing source roots 复选框。

- 点击 Refactor 按钮以移动文件。
移动 CartItem 和 CartItemWithFruittie 模型
对于文件 androidApp/.../model/CartItem.kt,您需要执行以下步骤:
- 打开文件,右键点击
CartItem类,然后选择 Refactor > Move。 - 这会打开相同的 Move 对话框,但在本例中,您还需要选中
CartItemWithFruittie成员对应的复选框。
继续操作,依次选择 ...图标和commonMain源代码集,就像您针对Fruittie.kt文件执行的操作。
移动 DAO 和 AppDatabase
对以下文件(您可以同时选择这三个文件)执行相同的步骤:
androidApp/.../database/FruittieDao.ktandroidApp/.../database/CartDao.ktandroidApp/.../database/AppDatabase.kt
更新共享的 AppDatabase,以便跨平台使用
现在,您已将数据库类移至 :shared 模块,接下来需要调整这些类,以便在两个平台上生成所需的实现。
- 打开
/shared/src/commonMain/kotlin/com/example/fruitties/kmptutorial/android/database/AppDatabase.kt文件。 - 添加以下
RoomDatabaseConstructor实现:
import androidx.room.RoomDatabaseConstructor
// The Room compiler generates the `actual` implementations.
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}
- 为
AppDatabase类添加@ConstructedBy(AppDatabaseConstructor::class)注解:
import androidx.room.ConstructedBy
@Database(
entities = [Fruittie::class, CartItem::class],
version = 1,
)
// TODO Add this line
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
...

将数据库创建移至 :shared 模块
接下来,您需要将 Android 专用的 Room 设置从 :androidApp 模块移至 :shared 模块。此操作为必要操作,因为在下一步中,您将从 :androidApp 模块中移除 Room 依赖项。
- 找到
androidApp/.../di/DatabaseModule.kt文件。 - 选择
providesAppDatabase函数的内容,右键点击,然后依次选择 Refactor > Extract Function to Scope:
- 从菜单中选择
DatabaseModule.kt。
这会将内容移至全局 appDatabase函数。按 Enter 键确认函数名称。
- 通过移除
private可见性修饰符,将函数设为公开。 - 右键点击 Refactor > Move,将该函数移至
:shared模块中。 - 在 Move 对话框中,选择 Destination directory 字段旁边的 ... 图标。

- 在 Choose Destination Directory 对话框中,选择 shared >androidMain 源代码集,然后选择 /shared/src/androidMain/ 文件夹,然后点击 OK。

- 将 To package 字段中的后缀从
.di更改为.database
- 点击 Refactor。
清理 :androidApp 中不需要的代码
此时,您已将 Room 数据库移至多平台模块,并且 :androidApp 模块不需要任何 Room 依赖项,因此您可以将其移除。
- 打开
:androidApp模块中的build.gradle.kts文件。 - 移除依赖项和配置,如以下代码段所示:
plugins {
// TODO Remove
alias(libs.plugins.room)
}
android {
// TODO Remove
ksp {
arg("room.generateKotlin", "true")
}
dependencies {
// TODO Keep room-runtime
implementation(libs.androidx.room.runtime)
// TODO Remove
implementation(libs.androidx.sqlite.bundled)
ksp(libs.androidx.room.compiler)
}
// TODO Remove
room {
schemaDirectory("$projectDir/schemas")
}
- Gradle 会同步该项目。
构建并运行 Android 应用
运行 Fruitties Android 应用,确保应用正常运行,并且现在使用 :shared 模块中的数据库。如果您之前添加了购物车商品,那么即使 Room 数据库现在位于 :shared 模块中,此时您应该也会看到相同的商品。
6. 备好 Room 以在 iOS 上使用
为了进一步为 iOS 平台准备 Room 数据库,您需要在 :shared 模块中设置一些支持代码,以便在下一步中使用。
为 iOS 应用启用数据库创建功能
首先要做的是,添加特定于 iOS 的数据库构建器。
- 在
iosMain源代码集内的:shared模块中添加一个名为AppDatabase.ios.kt的新文件:
- 添加以下辅助函数。iOS 应用将使用这些函数来获取 Room 数据库的实例。
package com.example.fruitties.kmptutorial.shared
import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.example.fruitties.kmptutorial.android.database.AppDatabase
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.value
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSError
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
fun getPersistentDatabase(): AppDatabase {
val dbFilePath = documentDirectory() + "/" + "fruits.db"
return Room.databaseBuilder<AppDatabase>(name = dbFilePath)
.setDriver(BundledSQLiteDriver())
.build()
}
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun documentDirectory(): String {
memScoped {
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = errorPtr.ptr,
)
if (documentDirectory != null) {
return requireNotNull(documentDirectory.path) {
"""Couldn't determine the document directory.
URL $documentDirectory does not conform to RFC 1808.
""".trimIndent()
}
} else {
val error = errorPtr.value
val localizedDescription = error?.localizedDescription ?: "Unknown error occurred"
error("Couldn't determine document directory. Error: $localizedDescription")
}
}
}
为 Room 实体添加“Entity”后缀
由于您要在 Swift 中为 Room 实体添加封装容器,因此最好让 Room 实体的名称不同于封装容器的名称。我们将通过使用 @ObjCName 注解向 Room 实体添加 Entity 后缀来确保两者名称不同。
打开 shared 模块中的 Fruittie.kt 文件,然后将 @ObjCName 注解添加到 Fruittie 实体。由于此注解处于实验阶段,因此您可能需要向文件添加 @OptIn(ExperimentalObjC::class) 注解。
Fruittie.kt
import kotlin.experimental.ExperimentalObjCName
import kotlin.native.ObjCName
@OptIn(ExperimentalObjCName::class)
@Entity(indices = [Index(value = ["id"], unique = true)])
@ObjCName("FruittieEntity")
data class Fruittie(
...
)
然后,对 CartItem.kt 文件中的 CartItem 实体执行相同的操作。
CartItem.kt
import kotlin.experimental.ExperimentalObjCName
import kotlin.native.ObjCName
@OptIn(ExperimentalObjCName::class)
@ObjCName("CartItemEntity")
@Entity(
foreignKeys = [
ForeignKey(
entity = Fruittie::class,
parentColumns = ["id"],
childColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class CartItem(@PrimaryKey val id: Long, val count: Int = 1)
7. 在 iOS 应用中使用 Room
iOS 应用是使用 Core Data 的既有应用。在此 Codelab 中,您不必担心迁移数据库中的任何现有数据,因为此应用只是一个原型。如果您要将正式版应用迁移到 KMP,则必须编写函数来读取当前的 Core Data 数据库,并在迁移后首次启动时将这些项插入 Room 数据库。
打开 Xcode 项目
在 Xcode 中打开 iOS 项目,方法是:前往 /iosApp/ 文件夹,然后在关联的应用中打开 Fruitties.xcodeproj。

移除 Core Data 实体类
首先,您需要移除 Core Data 实体类,以便为稍后创建的实体封装容器腾出空间。您可完全移除 Core Data 实体,或者保留这些实体以便进行数据迁移,具体取决于应用在 Core Data 中存储的数据类型。在本教程中,您可以直接将其移除,因为您无需迁移任何现有数据。
在 Xcode 中:
- 前往 Project Navigator。
- 找到“Resources”文件夹。
- 打开
Fruitties文件。 - 点击并删除每个实体。

如需在代码中实现这些更改,请清理并重建项目。
这会导致构建失败并显示以下错误,这是意料之中的结果。

创建实体封装容器
接下来,我们将为 Fruittie 和 CartItem 实体创建实体封装容器,以便顺利处理 Room 和 Core Data 实体之间的 API 差异。
这些封装容器将有助于我们从 Core Data 过渡到 Room,因为它们可最大限度地减少需要立即更新的代码量。您应该着眼于日后直接访问 Room 实体,以替换这些封装容器。
现在,我们将为 FruittieEntity 类创建一个封装容器,并为其添加可选属性。
创建 FruittieEntity 封装容器
- 在
Sources/Repository目录中创建一个新的 Swift 文件,方法是:右键点击目录名称,然后选择 New File from Template...

- 将它命名为
Fruittie,并确保仅选择 Fruitties 目标而非测试目标。
- 将以下代码添加到新的 Fruittie 文件中:
import sharedKit
struct Fruittie: Hashable {
let entity: FruittieEntity
var id: Int64 {
entity.id
}
var name: String? {
entity.name
}
var fullName: String? {
entity.fullName
}
}
Fruittie 结构体封装了 FruittieEntity 类,将属性设置为可选,并传递实体的属性。此外,我们还让 Fruittie 结构体遵循 Hashable 协议,以便在 SwiftUI 的 ForEach 视图中使用。
创建 CartItemEntity 封装容器
接下来,为 CartItemEntity 类创建类似的封装容器。
在 Sources/Repository 目录中创建一个名为 CartItem.swift 的新 Swift 文件。
import sharedKit
struct CartItem: Hashable {
let entity: CartItemWithFruittie
let fruittie: Fruittie?
var id: Int64 {
entity.cartItem.id
}
var count: Int64 {
Int64(entity.cartItem.count)
}
init(entity: CartItemWithFruittie) {
self.entity = entity
self.fruittie = Fruittie(entity: entity.fruittie)
}
}
由于原始 Core Data CartItem 类具有 Fruittie 属性,因此我们还在 CartItem 结构体中添加了 Fruittie 属性。虽然 CartItem 类没有任何可选属性,但 count 属性在 Room 实体中具有不同的类型。
更新代码库
现在,实体封装容器已就位,您需要更新 DefaultCartRepository 和 DefaultFruittieRepository 以使用 Room 而非 Core Data。
更新DefaultCartRepository
我们先从 DefaultCartRepository 类开始,因为它是两者中更简单的那个。
打开 Sources/Repository 目录中的 CartRepository.swift 文件。
- 首先,将
CoreData导入替换为sharedKit:
import sharedKit
- 然后,移除
NSManagedObjectContext属性并将其替换为CartDao属性:
// Remove
private let managedObjectContext: NSManagedObjectContext
// Replace with
private let cartDao: any CartDao
- 更新
init构造函数以初始化新的cartDao属性:
init(cartDao: any CartDao) {
self.cartDao = cartDao
}
- 接下来,更新
addToCart方法。此方法需要从 Core Data 中提取现有购物车商品,但我们的 Room 实现不需要这样做。实际上,它会插入新商品或增加现有购物车商品的数量。
func addToCart(fruittie: Fruittie) async throws {
try await cartDao.insertOrIncreaseCount(fruittie: fruittie.entity)
}
- 最后,更新
getCartItems()方法。此方法将对CartDao调用getAll()方法,并将CartItemWithFruittie实体映射到CartItem封装容器。
func getCartItems() -> AsyncStream<[CartItem]> {
return cartDao.getAll().map { entities in
entities.map(CartItem.init(entity:))
}.eraseToStream()
}
更新DefaultFruittieRepository
为了迁移 DefaultFruittieRepository 类,我们需做出相似更改,就像更改 DefaultCartRepository 类。
将 FruittieRepository 文件更新为以下内容:
import ConcurrencyExtras
import sharedKit
protocol FruittieRepository {
func getData() -> AsyncStream<[Fruittie]>
}
class DefaultFruittieRepository: FruittieRepository {
private let fruittieDao: any FruittieDao
private let api: FruittieApi
init(fruittieDao: any FruittieDao, api: FruittieApi) {
self.fruittieDao = fruittieDao
self.api = api
}
func getData() -> AsyncStream<[Fruittie]> {
let dao = fruittieDao
Task {
let isEmpty = try await dao.count() == 0
if isEmpty {
let response = try await api.getData(pageNumber: 0)
let fruitties = response.feed.map {
FruittieEntity(
id: 0,
name: $0.name,
fullName: $0.fullName,
calories: ""
)
}
_ = try await dao.insert(fruitties: fruitties)
}
}
return dao.getAll().map { entities in
entities.map(Fruittie.init(entity:))
}.eraseToStream()
}
}
替换 @FetchRequest 属性封装容器
我们还需要替换 SwiftUI 视图中的 @FetchRequest 属性封装容器。@FetchRequest 属性封装容器用于从 Core Data 中提取数据并观察更改,因此我们无法将其与 Room 实体搭配使用。我们改用 UIModel 从代码库中访问数据。
- 打开
Sources/UI/CartView.swift文件中的CartView。 - 将实现替换为以下内容:
import SwiftUI
struct CartView : View {
@State
private var expanded = false
@ObservedObject
private(set) var uiModel: ContentViewModel
var body: some View {
if (uiModel.cartItems.isEmpty) {
Text("Cart is empty, add some items").padding()
} else {
HStack {
Text("Cart has \(uiModel.cartItems.count) items (\(uiModel.cartItems.reduce(0) { $0 + $1.count }))")
.padding()
Spacer()
Button {
expanded.toggle()
} label: {
if (expanded) {
Text("collapse")
} else {
Text("expand")
}
}
.padding()
}
if (expanded) {
VStack {
ForEach(uiModel.cartItems, id: \.self) { item in
Text("\(item.fruittie!.name!): \(item.count)")
}
}
}
}
}
}
更新 ContentView
更新 Sources/View/ContentView.swift 文件中的 ContentView,以将 FruittieUIModel 传递给 CartView。
CartView(uiModel: uiModel)
更新 DataController
iOS 应用中的 DataController 类负责设置 Core Data 堆栈。由于我们不再使用 Core Data,因此需要更新 DataController 以初始化 Room 数据库。
- 打开
Sources/Database中的DataController.swift文件。 - 添加
sharedKit导入。 - 移除
CoreData导入。 - 在
DataController类中实例化 Room 数据库。 - 最后,从
DataController初始化程序中移除loadPersistentStores方法调用。
最终类应如下所示:
import Combine
import sharedKit
class DataController: ObservableObject {
let database = getPersistentDatabase()
init() {}
}
更新依赖项注入
iOS 应用中的 AppContainer 类负责初始化依赖关系图。由于我们更新了代码库以使用 Room 而非 Core Data,因此需要更新 AppContainer 以将 Room DAO 传递给代码库。
- 打开
Sources/DI文件夹中的AppContainer.swift。 - 添加
sharedKit导入。 - 从
AppContainer类中移除managedObjectContext属性。 - 通过从
DataController提供的AppDatabase实例传入 Room DAO 来更改DefaultFruittieRepository和DefaultCartRepository初始化。
完成后,该类将如下所示:
import Combine
import Foundation
import sharedKit
class AppContainer: ObservableObject {
let dataController: DataController
let api: FruittieApi
let fruittieRepository: FruittieRepository
let cartRepository: CartRepository
init() {
dataController = DataController()
api = FruittieNetworkApi(
apiUrl: URL(
string:
"https://android.github.io/kotlin-multiplatform-samples/fruitties-api"
)!)
fruittieRepository = DefaultFruittieRepository(
fruittieDao: dataController.database.fruittieDao(),
api: api
)
cartRepository = DefaultCartRepository(
cartDao: dataController.database.cartDao()
)
}
}
最后,更新 main.swift 中的 FruittiesApp 以移除 managedObjectContext:
struct FruittiesApp: App {
@StateObject
private var appContainer = AppContainer()
var body: some Scene {
WindowGroup {
ContentView(appContainer: appContainer)
}
}
}
构建并运行 iOS 应用
最后,一旦您构建完应用并按 ⌘R 运行它,应用应该会基于从 Core Data 迁移到 Room 的数据库开始。

8. 恭喜
恭喜!您已成功使用 Room KMP 将独立的 Android 和 iOS 应用迁移到共享数据层。
以下是应用架构的比较,供您参考,以便了解所实现的效果:
之前
Android | iOS |
|
|
迁移后架构

了解详情
- 了解其他哪些 Jetpack 库支持 KMP。
- 阅读 Room KMP 文档。
- 阅读 SQLite KMP 文档。
- 查看官方 Kotlin Multiplatform 文档。