使用KMP迁移Android app到IOS平台

使用KMP迁移Android app到IOS平台_第1张图片

使用KMP迁移Android app到IOS平台

如果你有一款Android app,你想将其迁移到IOS平台,但是你不熟悉Swift语言,那么你该如何做呢?辛亏JetBrains 推出 Kotlin Multiplatform 和 Compose Multiplatform ,突然间,你可以重复使用大部分代码库,并继续使用我熟悉的语言和 IDE。

架构

使迁移更加容易的一个关键因素是现有的应用程序架构。应用程序应遵循 Hexagonal/Clean 架构概念,该概念指出业务规则不应依赖于框架。通过遵循这个规则并应用模块化,我们有两个好处:

- 所有业务规则都可以在没有额外迁移的情况下工作(例如添加任务、更新主屏幕小部件并安排通知)
- 模块化允许逐步迁移,而不是一次性移植大部分代码

在下面的表示中,我们可以看到关注点的分离和每个模块中使用的技术。有关架构和模块化的更多信息,请参阅 Google 的应用程序架构官方指南(https://developer.android.com/topic/architecture)。

使用KMP迁移Android app到IOS平台_第2张图片

绿色带有 Android 标志的模块是基于 Android 的,而带有 Kotlin 标志的紫色模块仅使用 Kotlin。Kotlin-only 模块可以轻松转换为 Kotlin Multiplatform,只需要在 build.gradle(.kts) 中进行微小调整和文件夹替换。

但是,基于 Android 的模块更加困难,因为诸如 Room、Retrofit 和 ViewModel 等库仅在 Android 上可用。为了实现跨平台支持,我们有两个选择:

- 在公共源代码中公开接口,并在每个平台上实现本地代码
- 使用 Android 库的多平台替代品

由于自从 2022 年达到 beta 版以来 KMP 社区已经快速增长,我们已经有了广泛的多平台库,可以替换特定于平台的库。

值得一提的是,要创建 Kotlin Multiplatform 应用程序,并不需要使用这种特定的架构。在文档、示例和多个开源代码库中,使用了更为简洁的替代方案。然而,由于此系列文章专注于移植现有的生产就绪代码库,该解决方案可能不具可扩展性。
使用KMP迁移Android app到IOS平台_第3张图片

第一步

域和存储库模块是快速胜利,因为这些模块中没有 Android 代码。第一步是了解 Kotlin-only 和 KMP 模块的不同之处。由于我之前没有经验,所以创建了一个新模块来查看结构。为了在 Android Studio 中添加支持,需要使用 KMP 插件。
以下是主要区别:
- build.gradle(.kts) 使用 kotlin("multiplatform")id("com.android.library") 插件
- 使用 sourceSets 为每个平台指定依赖关系
- 在 src/kotlin 中为每个平台使用专用的代码源目录
在了解了这些内容之后,很容易迁移现有模块,甚至创建扩展函数和 Gradle 预编译脚本加快开发速度。
关于源位置,基本上,我们在 KMP 模块中有三个目录,分别对应每个平台:commonMain(multiplatform)androidMain(Android)iosMain(iOS)。在域和存储库模块的情况下,所有代码都从 src/java/main 移动到了 src/kotlin/commonMain

需要考虑的要点

尽管域和存储库模块是 Kotlin-only 的,但在迁移过程中遇到了一些挑战:

  • 依赖注入框架
    App可使用 Koin 作为 DI 框架,这使得迁移工作变得容易。Koin 已经支持多平台,并且在 iOS 上的设置很简单。然而,请注意,如果现有项目使用其他框架,如 Dagger 或 Hilt,可能需要更多的工作。

  • 创建一个非多平台的 iOS 应用程序
    在创建 Xcode 项目时,选择项目模板时要小心。我不小心选择了“多平台应用程序”,它无法与现有教程直接使用。经过一些调查后,我选择了“iOS 应用程序”,第一次尝试就成功了。有关 iOS 设置的更多信息,请查阅官方文档。

  • KMP 不支持仅限于 Java 的 API
    App有一个简单的日期和时间处理功能,以前依赖于 java.Calendar。请注意,没有 Kotlin 对应的基于 Java 的库将无法工作。为了实现多平台兼容性,使用了 kotlinx-datetime。

  • 单元测试不允许使用空格
    Kotlin 引入了在反引号中包围的带有空格的测试函数名称(例如 test task was inserted)。然而,此功能只适用于 Android 最低 SDK 版本 30,这对大多数应用程序来说不可行。解决方案是将空格替换为下划线。

  • “伞形”共享模块
    最初的目标是将所有模块设置为多平台,并在 iOS 应用程序中逐个连接它们。然而,在进行 Xcode 设置时,我们需要提供包含生成的 KMP 代码的路径。如果我们独立使用模块,则每次添加新模块时都需要更新设置。

简化此设置的一种方法是创建一个“伞形”共享模块,该模块了解所有其他 KMP 模块。Xcode 设置将依赖于单个路径,我们可以根据需要添加新的 KMP 模块。这也使得 DI 注入设置更加简单。
使用KMP迁移Android app到IOS平台_第4张图片

数据源

前面架构部分我们讲到App有两个数据源:本地和数据存储库。第一个负责使用Room通过SQLite进行数据持久化,而第二个则用于使用Preferences DataStore进行键值轻型数据库。目前还没有远程层连接到服务器。
这两个数据源的实现都使用了仅适用于Android的库,我们需要进行更改以使其能够与KMP一起使用。此外,我们需要确保在迁移过程中用户不会遇到任何问题,并且所有数据都将如原样可用。

本地数据库

在App的Android版本中,使用Room来存储所有任务及其信息,例如描述、闹钟时间和类别。然而,该库尚未准备好用于Kotlin Multiplatform。幸运的是,我们有几个KMP的备选方案,其中最明显的是SQLDelight。

SQLDelight的结构与Room有些不同:它不依赖注解处理器,而是从SQL语句生成类型安全的API。这将需要更多手动步骤,但这不应该是一个问题,因为我们将迁移一个现有的模式。

本节的目标是专注于将现有数据库从Room迁移到SQLDelight的步骤。如果您需要有关如何设置SQLDelight的基础知识的更多信息,请参阅官方文档。

https://github.com/cashapp/sqldelight
https://cashapp.github.io/sqldelight/2.0.0/

保留现有数据

由于我们正在迁移现有的应用程序,保留数据库中的所有现有数据至关重要。否则,用户将在应用程序升级时丢失其数据。

SQLDelight支持SQLite,这与Room使用的是同一个数据库。我们的想法是,而不是重新创建数据库,我们只需替换封装它的库。我们可以通过实施以下步骤来实现:

  1. 完全重建所有表结构
    为了使SQLDelight能够操作现有的表,我们需要确保所有新生成的代码都与现有结构相匹配。例如,这是Category表的Room结构:
//Category.kt
@Entity
data class Category(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "category_id")
    var id: Long = 0,

    @ColumnInfo(name = "category_name") var name: String,
  
    @ColumnInfo(name = "category_color") var color: String,
)

对于SQLDelight,我们需要提供用于创建的实际SQL语句:

//Category.sq
CREATE TABLE IF NOT EXISTS Category (
`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`category_name` TEXT NOT NULL,
`category_color` TEXT NOT NULL
);

如上面的示例所示,我们需要确保所有结构都匹配。当使用Room时,如果我们将类型定义为非空(例如category_namecategory_color),可能不太清楚它将被转换为SQL上的NOT NULL。

如果任何字段与Room中的定义及其在SQLDelight中的定义不匹配,应用程序将崩溃。幸运的是,在从现有模式迁移时有一种更简单的方法。

Room支持在每次增加数据库版本时自动导出模式。此外,如果您正在使用Room的自动迁移功能,则此设置可能已经为项目准备好。这些模式文件已经包含了创建每个表的所有SQL语句。以下是一个示例:

https://github.com/igorescodro/alkaa/blob/v2.3.0/data/local/schemas/com.escodro.local.TaskDatabase/4.json

而不是手动创建所有表并确保它们匹配,只需转到最新的模式文件,然后简单地复制语句并将其添加到各自的.sq文件中即可。

  1. 添加所有数据库迁移
    谈到迁移,我们需要确保所有现有的迁移仍然可供用户使用。这有两个重要原因:
    1.用户从旧版本的应用程序和数据库迁移-无论使用哪个SQLite库,都需要这样做。迁移文件确保数据库知道如何升级到新版本。如果不提供此设置,将在升级时导致应用程序崩溃-用户需要清除数据才能重新打开应用程序。
    2.版本号-SQLDelight还使用这些文件对模式进行版本控制。如果不提供此设置,将会将SQLDelight配置设置回版本1。如果您的应用程序在更高版本,则由于版本不匹配,应用程序也将崩溃。
    现有版本的SQLDelight不支持自动迁移。对于每个迁移文件,需要手动创建用于该文件的SQL语句。有关SQLDelight迁移的更多信息,请访问官方文档。

  2. 迁移适配器
    SQLDelight还允许自定义列类型,例如Enums、DateTime、List等。为了使它们与SQLite原始类型一起使用,我们需要适配器。该库已经提供了一些适配器,但是对于更复杂的类型,我们需要编写自己的实现。
    在Room上,我们依赖于注解处理器,而在SQLDelight上,我们创建了ColumnAdapter接口的实现。有关SQLDelight自定义列类型的更多信息,请访问官方文档。

//AlarmIntervalConverter.kt
@TypeConverter
fun toAlarmInterval(id: Int?): AlarmInterval? =
    AlarmInterval.values().find { it.id == id }

@TypeConverter
fun toId(alarmInterval: AlarmInterval?): Int? =
    alarmInterval?.id

Room中的类型转换

//AlarmIntervalAdapter.kt
val alarmIntervalAdapter = object : ColumnAdapter<AlarmInterval, Long> {
    override fun decode(databaseValue: Long): AlarmInterval =
        AlarmInterval.values().find { it.id == databaseValue.toInt() }!!

    override fun encode(value: AlarmInterval): Long =
        value.id.toLong()
}

SQLDelight中的ColumnAdapter

  1. 提供特定于平台的代码
    SQLDelight是一个KMP-ready库,这意味着我们可以为多个平台提供单个实现。但是,我们仍然需要提供特定于平台的SqlDriver,以帮助库在Android和iOS上创建和打开数据库。
//Android中的实现
//DriverFactory.kt 
actual class DriverFactory(private val context: Context) {
  actual fun createDriver(): SqlDriver {
    return AndroidSqliteDriver(Database.Schema, context, "todo.db") 
  }
}
// ios中的实现
//DriverFactory.kt 
actual class DriverFactory {
  actual fun createDriver(): SqlDriver {
    return NativeSqliteDriver(Database.Schema, "todo.db")
  }
}

您可能会注意到两个实际实现的签名不同:在Android上,我们接收一个Context,在iOS上我们不接收任何参数。有几种实现方法,但现在我想分享两个我认为非常有用的参考文献。

https://proandroiddev.com/achieving-platform-specific-implementations-with-koin-in-kmm-5cb029ba4f3b
https://stackoverflow.com/a/64141659/6100078

预填充数据库

在App中,Category表会预先填充几个类别,这些类别根据用户设备的语言进行本地化。目前,SQLDelight没有像Room那样的onCreate回调来通知模式何时创建。相反,我们可以检查数据库是否存在,如果不存在,则添加条目,这意味着只在第一次执行此操作。
为了使其工作,我们需要特定的代码来尝试在每个平台上查找文件。在Android上,这很简单:我们已经有一个方便的上下文函数来帮助我们:

//AndroidDriverFactory.kt 
override fun shouldPrepopulateDatabase(databaseName: String): Boolean =
    !context.getDatabasePath(databaseName).exists()

在iOS上,用于检查文件是否存在的函数使用Objective-C/Swift API。不过猜猜怎么着:我们仍然可以使用Kotlin编写代码,因为KMP对它们有包装器。需要注意的一件重要事情是SQLDelight在哪个路径上创建数据库,这让我花了一些时间进行调试。
使用NSFileManager编写的简单代码如下所示:

//IosDriverFactory.kt
override fun shouldPrepopulateDatabase(databaseName: String): Boolean =
    !databaseExists(databaseName)

private fun databaseExists(databaseName: String): Boolean {
    val fileManager = NSFileManager.defaultManager
    val documentDirectory = NSFileManager.defaultManager.URLsForDirectory(
        NSLibraryDirectory,
        NSUserDomainMask,
    ).last() as NSURL
    val file = documentDirectory
        .URLByAppendingPathComponent("$DATABASE_PATH$databaseName")?.path
    return fileManager.fileExistsAtPath(file ?: "")
}

private const val DATABASE_PATH = "Application Support/databases/"

有了这些信息,我们可以在模式创建时插入数据。

本地偏好设置数据库

在Alkaa中,本地偏好设置数据库用于存储简单的键值对数据,例如应用程序主题(明亮、暗黑或系统默认)使用Android Jetpack库中的Preferences DataStore。
幸运的是,这个库是Google正在努力将Android库移植到KMP支持的一部分。目前,这个库还处于alpha版本,所以请记住API还没有准备好投入生产使用,而Alkaa是一个开源的练手应用程序。实现代码很简单,并且GitHub上有一个官方示例供参考。

https://github.com/android/kotlin-multiplatform-samples/tree/main/DiceRoller

//DataStore.kt
private lateinit var dataStore: DataStore<Preferences>

private val lock = SynchronizedObject()

fun getDataStore(producePath: () -> String): DataStore<Preferences> =
    synchronized(lock) {
        if (::dataStore.isInitialized) {
            dataStore
        } else {
            PreferenceDataStoreFactory.createWithPath(
                produceFile = { producePath().toPath() },
            ).also { dataStore = it }
        }
    }
    
internal const val dataStoreFileName = "settings.preferences_pb"

Code on commonMain

//AndroidDataStore.kt 
fun getDataStore(): DataStore<Preferences> = getDataStore(
    producePath = { context.filesDir.resolve("datastore/$dataStoreFileName").absolutePath },
)

Code on androidMain

//IosDataStore.kt
@OptIn(ExperimentalForeignApi::class)
fun getDataStore(): DataStore<Preferences> = getDataStore(
    producePath = {
        val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )
        requireNotNull(documentDirectory).path + "/$dataStoreFileName"
    },
)

Code on iosMain

保留现有数据

为了继续使用现有的偏好设置数据库,我们需要注意以下细节:
设置相同的名称和扩展名 - 在设置过程中,我们需要确保文件和扩展名相同。如果DataStore文件在仅限Android版本中设置为my_data_store,那么KMP版本需要设置为my_data_store.preferences_db。
设置相同的文件路径 - 为了确保我们使用现有的偏好设置文件而不是创建新文件,我们需要确保设置与Android DataStore相同的文件路径。可以通过以下函数找到此路径:

context.filesDir.resolve("datastore/$dataStoreFileName").absolutePath

远程/网络

如前所述,App没有与服务器连接的远程层。然而,由于该层不负责持久性数据,替换应该很简单。对于多平台网络库,ktor是一个很好的选择。

完整项目参考如下:

https://github.com/igorescodro/alkaa

参考资源

https://kotlinlang.org/docs/multiplatform-mobile-integrate-in-existing-app.html
https://github.com/joreilly/PeopleInSpace
https://github.com/SebastianAigner/my-bird-app/tree/main
https://github.com/handstandsam/ShoppingApp/tree/main
https://github.com/joreilly/FantasyPremierLeague
https://github.com/igorescodro/alkaa

你可能感兴趣的:(kotlin多平台,Kotlin进阶,android,ios,kotlin)