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 字段旁边的
...
图标。 - 在 Choose Destination Directory 对话框中选择 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)
@Serializable
@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 文档。