将现有应用迁移到 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. 了解示例应用

本教程包含使用原生框架构建的 Fruitties 示例应用,该应用在 Android 上采用 Jetpack Compose 框架,在 iOS 上则采用 SwiftUi 框架。

Fruitties 应用提供两项主要功能:

  • 一个包含多个 Fruit 项目的列表,每个项目旁边都设有一个按钮以用于将该项目添加至 Cart。
  • Cart 呈现在应用顶部,显示添加的水果种类及对应数量。

4a7f262b015d7f78.png

Android 应用架构

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

在 KMP 集成之前的 Android 应用架构图

iOS 应用架构

在 KMP 集成之前的 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-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 驱动程序切换为捆绑驱动程序,接下来,请验证应用 build 并确保一切正常运行,然后再将数据库迁移到 :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 架构位置:
// Should be its own top level block. For convenience, add at the bottom of the file
room {
   schemaDirectory("$projectDir/schemas")
}
  1. Gradle 同步项目

将 Room 架构移至 :shared 模块

androidApp/schemas 目录移至 src/ 文件夹旁边的 :shared 模块根文件夹:

原位置:e1ee37a3f3a10b35.png

目标位置:ba3c9eb617828bac.png

移动 DAO 和实体

现在,您已向 KMP 共享模块添加了必要的 Gradle 依赖项。接下来,需要将 DAO 和实体从 :androidApp 模块移至 :shared 模块。

这一过程涉及将相关文件移至 :shared 模块中 commonMain 源代码集内的相应位置。

移动 Fruittie 模型

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

  1. 找到 androidApp/src/main/kotlin/.../model/Fruittie.kt 文件,右键点击该文件,然后依次选择重构→移动(或按 F6c893e12b8bf683ae.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. 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() {
...

410a3c0c656b6499.png

将数据库创建移至 :shared 模块

接下来,您需要将 Android 专用的 Room 设置从 :androidApp 模块移至 :shared 模块。此操作为必要操作,因为在下一步中,您将从 :androidApp 模块中移除 Room 依赖项。

  1. 找到 androidApp/.../di/DatabaseModule.kt 文件。
  2. 选择 providesAppDatabase 函数的内容,右键点击,然后依次选择 Refactor > Extract Function to Scopeda4d97319f9a0e8c.png
  3. 从菜单中选择 DatabaseModule.kt5e540a1eec6e3493.png 这会将内容移至全局 appDatabase 函数。按 Enter 确认函数名称。e2fb113d66704a36.png
  4. 通过移除 private 可见性修饰符,将函数设为公开。
  5. 右键点击 Refactor > Move,将该函数移至 :shared 模块中。
  6. Move 对话框中,选择 Destination directory 字段旁边的 ... 图标。e2101005f2ef4747.png
  7. Choose Destination Directory 对话框中,选择 shared >androidMain 源代码集,然后选择 /shared/src/androidMain/ 文件夹,然后点击 OK73d244941c68dc85.png
  8. To package 字段中的后缀从 .di 更改为 .database ac5cf30d32871e2c.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 上使用

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

为 iOS 应用启用数据库创建功能

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

  1. iosMain 源代码集内的 :shared 模块中添加一个名为 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 文件,然后将 @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

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 目录中创建一个新的 Swift 文件,方法是:右键点击目录名称,然后选择 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

在 KMP 集成之前的 Android 应用架构图

在 KMP 集成之前的 iOS 应用架构图

迁移后架构

bcd8c29b00f67c19.png

了解详情