将现有应用迁移到 Room KMP

1. 开始之前

前提条件

所需条件

您将学到什么

  • 如何在 Android 应用和 iOS 应用之间共享 Room 数据库。

2. 设置

要开始,请按照以下步骤操作

  1. 使用以下终端命令克隆 GitHub 仓库
$ git clone https://github.com/android/codelab-android-kmp.git

或者,您可以将仓库下载为 zip 文件

  1. Android Studio 中,打开 migrate-room 项目,该项目包含以下分支
  • main:包含此项目的起始代码,您在此处进行更改以完成 Codelab。
  • end:包含此 Codelab 的解决方案代码。

我们建议您从 main 分支开始,并按照您自己的进度逐步完成 Codelab。

  1. 如果您想查看解决方案代码,请运行此命令
$ git clone -b end https://github.com/android/codelab-android-kmp.git

或者,您可以下载解决方案代码

3. 理解示例应用

本教程包含使用原生框架(Android 上的 Jetpack Compose,iOS 上的 SwiftUI)构建的 Fruitties 示例应用。

Fruitties 应用提供两个主要功能

  • 水果项目列表,每个项目都有一个添加到购物车的按钮。
  • 顶部显示一个购物车,显示已添加的水果数量及其各自的数量。

4a7f262b015d7f78.png

Android 应用架构

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

Architecture diagram of the Android application before KMP integration

iOS 应用架构

Architecture diagram of the iOS application before KMP integration

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-runtimesqlite-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 上的功能。

  1. 打开位于 androidApp/src/main/kotlin/com/example/fruitties/kmptutorial/android/di/DatabaseModule.ktDatabaseModule.kt 文件
  2. 按照以下代码片段更新 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 多平台依赖项。

  1. 添加 KSP 和 Room 插件
plugins {
   ...
   // TODO add KSP + ROOM plugins
   alias(libs.plugins.ksp)
   alias(libs.plugins.room)
}
  1. room-runtimesqlite-bundled 依赖项添加到 commonMain 块中
sourceSets {
    commonMain {
        // TODO Add KMP dependencies here
        implementation(libs.androidx.room.runtime)
        implementation(libs.androidx.sqlite.bundled)
    }
}
  1. 通过添加新的顶级 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)
}
  1. 同样在顶级,添加一个新块来设置 Room schema 位置
// Should be its own top level block. For convenience, add at the bottom of the file
room {
   schemaDirectory("$projectDir/schemas")
}
  1. Gradle 同步项目

将 Room schema 移动到 :shared 模块

androidApp/schemas 目录移动到 :shared 模块根文件夹,位于 src/ 文件夹旁边

从: e1ee37a3f3a10b35.png

到: ba3c9eb617828bac.png

移动 DAO 和实体

现在您已经将必要的 Gradle 依赖项添加到 KMP 共享模块,您需要将 DAO 和实体从 :androidApp 模块移动到 :shared 模块。

这将涉及将文件移动到 :shared 模块中 commonMain 源集中的相应位置。

移动 Fruittie 模型

您可以利用 Refactor → Move 功能来切换模块而不会破坏导入

  1. 找到 androidApp/src/main/kotlin/.../model/Fruittie.kt 文件,右键单击该文件,然后选择 Refactor → Move(或按键 F6:c893e12b8bf683ae.png
  2. Move 对话框中,选择 Destination directory 字段旁边的 ... 图标。 1d51c3a410e8f2c3.png
  3. Choose Destination Directory 对话框中选择 commonMain 源集,然后点击 OK。您可能需要取消选中 Show only existing source roots 复选框。f61561feb28a6445.png
  4. 点击 Refactor 按钮以移动文件。

移动 CartItemCartItemWithFruittie 模型

对于文件 androidApp/.../model/CartItem.kt,您需要按照以下步骤操作

  1. 打开文件,右键单击 CartItem 类,然后选择 Refactor > Move
  2. 这将打开相同的 Move 对话框,但在本例中,您还需要勾选 CartItemWithFruittie 成员的复选框。
  3. a25022cce5cee5e0.png 继续选择 ... 图标并选择 commonMain 源集,就像您对 Fruittie.kt 文件所做的那样。

移动 DAO 和 AppDatabase

对以下文件执行相同的步骤(您可以同时选择这三个文件)

  • androidApp/.../database/FruittieDao.kt
  • androidApp/.../database/CartDao.kt
  • androidApp/.../database/AppDatabase.kt

更新共享的 AppDatabase 以跨平台工作

现在您已将数据库类移动到 :shared 模块,您需要调整它们以在两个平台上生成所需的实现。

  1. 打开 /shared/src/commonMain/kotlin/com/example/fruitties/kmptutorial/android/database/AppDatabase.kt 文件。
  2. 添加 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
}
  1. 使用 @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() {
...

410a3c0c656b6499.png

将数据库创建移动到 :shared 模块

接下来,您将 Android 特定的 Room 设置从 :androidApp 模块移动到 :shared 模块。这是必要的,因为在下一步中,您将从 :androidApp 模块中移除 Room 依赖项。

  1. 找到 androidApp/.../di/DatabaseModule.kt 文件。
  2. 选择 providesAppDatabase 函数的内容,右键单击,然后选择 Refactor > Extract Function to Scope: da4d97319f9a0e8c.png
  3. 从菜单中选择 DatabaseModule.kt5e540a1eec6e3493.png 这会将内容移动到一个全局 appDatabase 函数中。按 Enter 键确认函数名称。e2fb113d66704a36.png
  4. 通过移除 private 可见性修饰符使函数成为 public。
  5. 通过右键单击 Refactor > Move 将函数移动到 :shared 模块。
  6. Move 对话框中,选择 Destination directory 字段旁边的 ... 图标。 e2101005f2ef4747.png
  7. Choose Destination Directory 对话框中,选择 shared >androidMain 源集并选择 /shared/src/androidMain/ 文件夹,然后点击 OK。73d244941c68dc85.png
  8. To package 字段中的后缀从 .di 更改为 .databaseac5cf30d32871e2c.png
  9. 点击 Refactor

清理 :androidApp 中不需要的代码

此时,您已将 Room 数据库移动到多平台模块,并且 :androidApp 模块中不再需要任何 Room 依赖项,因此您可以将其移除。

  1. 打开 :androidApp 模块中的 build.gradle.kts 文件。
  2. 按照以下代码片段移除依赖项和配置
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")
}
  1. Gradle 同步项目。

构建并运行 Android 应用

运行 Fruitties Android 应用,确保应用正常运行并且现在使用的是来自 :shared 模块的数据库。如果您之前添加了购物车商品,那么即使 Room 数据库现在位于 :shared 模块中,您此时也应该看到相同的商品。

6. 准备 Room 以在 iOS 上使用

为了进一步准备 Room 数据库以用于 iOS 平台,您需要在 :shared 模块中设置一些支持代码,以便在下一步中使用。

为 iOS 应用启用数据库创建

首先要做的是添加一个 iOS 特定的数据库构建器。

  1. :shared 模块的 iosMain 源集中添加一个名为 AppDatabase.ios.kt 的新文件:dcb46ba560298865.png
  2. 添加以下辅助函数。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

54836291a243ebe9.png

移除 Core Data 实体类

首先,您需要移除 Core Data 实体类,以便为稍后创建的实体包装器腾出空间。根据您的应用在 Core Data 中存储的数据类型,您可以完全移除 Core Data 实体,或保留它们用于数据迁移。对于本教程,您可以直接移除它们,因为您不需要迁移任何现有数据。

在 Xcode 中

  1. 转到 Project Navigator。
  2. 转到 Resources 文件夹。
  3. 打开 Fruitties 文件。
  4. 点击并删除每个实体。

7ad742d991d76b1c.png

要使这些更改在代码中可用,请清理并重新构建项目。

这将导致构建失败并出现以下错误,这是预期的情况。

e3e107bf0387eeab.png

创建实体包装器

接下来,我们将为 FruittieCartItem 实体创建实体包装器,以平滑处理 Room 和 Core Data 实体之间的 API 差异。

这些包装器将帮助我们通过最大程度地减少需要立即更新的代码量来从 Core Data 过渡到 Room。您应该着眼于将来用直接访问 Room 实体来替换这些包装器。

现在,我们将转而为 FruittieEntity 类创建一个包装器,为其提供可选属性。

创建 FruittieEntity 包装器

  1. 通过右键单击 Sources/Repository 目录名并选择 New File from Template... cce140b2fb3c2da8.png 6a0d4fa4292ddd4f.png
  2. 将其命名为 Fruittie,并确保仅选中 Fruitties 目标,而不是测试目标。827b9019b0a32352.png
  3. 将以下代码添加到新的 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 实体中的类型不同。

更新仓库

现在实体包装器已就位,您需要更新 DefaultCartRepositoryDefaultFruittieRepository 以使用 Room 而不是 Core Data。

更新 DefaultCartRepository

我们先从 DefaultCartRepository 类开始,因为它是两者中更简单的一个。

打开 Sources/Repository 目录中的 CartRepository.swift 文件。

  1. 首先,将 CoreData 导入替换为 sharedKit
import sharedKit
  1. 然后移除 NSManagedObjectContext 属性,并将其替换为 CartDao 属性
// Remove
private let managedObjectContext: NSManagedObjectContext

// Replace with
private let cartDao: any CartDao
  1. 更新 init 构造函数以初始化新的 cartDao 属性
init(cartDao: any CartDao) {
    self.cartDao = cartDao
}
  1. 接下来,更新 addToCart 方法。此方法需要从 Core Data 拉取现有购物车商品,但我们的 Room 实现不需要这样做。相反,它将插入新商品或增加现有购物车商品的数量。
func addToCart(fruittie: Fruittie) async throws {
    try await cartDao.insertOrIncreaseCount(fruittie: fruittie.entity)
}
  1. 最后,更新 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 从仓库访问数据。

  1. 打开 Sources/UI/CartView.swift 文件中的 CartView
  2. 将实现替换为以下内容
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 数据库。

  1. 打开 Sources/Database 中的 DataController.swift 文件。
  2. 添加 sharedKit 导入。
  3. 移除 CoreData 导入。
  4. DataController 类中实例化 Room 数据库。
  5. 最后,从 DataController 初始化程序中移除 loadPersistentStores 方法调用。

最终的类应该如下所示

import Combine
import sharedKit

class DataController: ObservableObject {
    let database = getPersistentDatabase()
    init() {}
}

更新依赖注入

iOS 应用中的 AppContainer 类负责初始化依赖图。由于我们已将仓库更新为使用 Room 而不是 Core Data,因此需要更新 AppContainer 以将 Room DAO 传递给仓库。

  1. 打开 Sources/DI 文件夹中的 AppContainer.swift
  2. 添加 sharedKit 导入。
  3. AppContainer 类中移除 managedObjectContext 属性。
  4. 通过从 DataController 提供的 AppDatabase 实例传入 Room DAO 来更改 DefaultFruittieRepositoryDefaultCartRepository 的初始化。

完成后,类将如下所示

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 的数据库。

5d2ae9438747f8f6.png

8. 恭喜

恭喜!您已成功使用 Room KMP 将独立的 Android 和 iOS 应用迁移到共享数据层。

作为参考,以下是应用架构对比,以便查看已实现的功能

之前

Android

iOS

Architecture diagram of the Android application before KMP integration

Architecture diagram of the iOS application before KMP integration

迁移后的架构

bcd8c29b00f67c19.png

了解详情