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. 理解示例应用
本教程包含使用原生框架(Android 上的 Jetpack Compose,iOS 上的 SwiftUI)构建的 Fruitties 示例应用。
Fruitties 应用提供两个主要功能
- 水果项目列表,每个项目都有一个添加到购物车的按钮。
- 顶部显示一个购物车,显示已添加的水果数量及其各自的数量。
Android 应用架构
Android 应用遵循官方Android 架构指南,以保持清晰的模块化结构。
iOS 应用架构
KMP 共享模块
该项目已经设置了 KMP 共享模块,尽管目前它是空的。如果您的项目尚未设置共享模块,请从Kotlin Multiplatform 入门 Codelab 开始。
4. 准备 Room 数据库以便进行 KMP 集成
在将 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 驱动程序切换为捆绑的驱动程序,在将数据库迁移到 :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
schema 位置
// Should be its own top level block. For convenience, add at the bottom of the file
room {
schemaDirectory("$projectDir/schemas")
}
- Gradle 同步项目
将 Room schema 移动到 :shared
模块
将 androidApp/schemas
目录移动到 :shared
模块根文件夹,位于 src/
文件夹旁边
从:
到:
移动 DAO 和实体
现在您已经将必要的 Gradle 依赖项添加到 KMP 共享模块,您需要将 DAO 和实体从 :androidApp
模块移动到 :shared
模块。
这将涉及将文件移动到 :shared
模块中 commonMain
源集中的相应位置。
移动 Fruittie
模型
您可以利用 Refactor → Move 功能来切换模块而不会破坏导入
- 找到
androidApp/src/main/kotlin/.../model/Fruittie.kt
文件,右键单击该文件,然后选择 Refactor → Move(或按键 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
}
- 使用
@ConstructedBy(AppDatabaseConstructor::class)
注解AppDatabase
类
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
可见性修饰符使函数成为 public。 - 通过右键单击 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 上使用
为了进一步准备 Room 数据库以用于 iOS 平台,您需要在 :shared
模块中设置一些支持代码,以便在下一步中使用。
为 iOS 应用启用数据库创建
首先要做的是添加一个 iOS 特定的数据库构建器。
- 在
:shared
模块的iosMain
源集中添加一个名为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
文件,并向 Fruittie
实体添加 @ObjCName
注解。由于此注解是实验性的,您可能需要在文件中添加 @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
目录名并选择 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 文档。