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.kt
- androidApp/.../database/CartDao.kt
- androidApp/.../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 文档。
