使用KMP迁移Android app到IOS平台
如果你有一款Android app,你想将其迁移到IOS平台,但是你不熟悉Swift语言,那么你该如何做呢?辛亏JetBrains 推出 Kotlin Multiplatform 和 Compose Multiplatform ,突然间,你可以重复使用大部分代码库,并继续使用我熟悉的语言和 IDE。
架构
使迁移更加容易的一个关键因素是现有的应用程序架构。应用程序应遵循 Hexagonal/Clean 架构概念,该概念指出业务规则不应依赖于框架。通过遵循这个规则并应用模块化,我们有两个好处:
- 所有业务规则都可以在没有额外迁移的情况下工作(例如添加任务、更新主屏幕小部件并安排通知)
- 模块化允许逐步迁移,而不是一次性移植大部分代码
在下面的表示中,我们可以看到关注点的分离和每个模块中使用的技术。有关架构和模块化的更多信息,请参阅 Google 的应用程序架构官方指南(https://developer.android.com/topic/architecture)。
绿色带有 Android 标志的模块是基于 Android 的,而带有 Kotlin 标志的紫色模块仅使用 Kotlin。Kotlin-only 模块可以轻松转换为 Kotlin Multiplatform,只需要在 build.gradle(.kts) 中进行微小调整和文件夹替换。
但是,基于 Android 的模块更加困难,因为诸如 Room、Retrofit 和 ViewModel 等库仅在 Android 上可用。为了实现跨平台支持,我们有两个选择:
- 在公共源代码中公开接口,并在每个平台上实现本地代码
- 使用 Android 库的多平台替代品
由于自从 2022 年达到 beta 版以来 KMP 社区已经快速增长,我们已经有了广泛的多平台库,可以替换特定于平台的库。
值得一提的是,要创建 Kotlin Multiplatform 应用程序,并不需要使用这种特定的架构。在文档、示例和多个开源代码库中,使用了更为简洁的替代方案。然而,由于此系列文章专注于移植现有的生产就绪代码库,该解决方案可能不具可扩展性。
第一步
域和存储库模块是快速胜利,因为这些模块中没有 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 注入设置更加简单。
数据源
前面架构部分我们讲到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使用的是同一个数据库。我们的想法是,而不是重新创建数据库,我们只需替换封装它的库。我们可以通过实施以下步骤来实现:
- 完全重建所有表结构
为了使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_name
和category_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.用户从旧版本的应用程序和数据库迁移-无论使用哪个SQLite库,都需要这样做。迁移文件确保数据库知道如何升级到新版本。如果不提供此设置,将在升级时导致应用程序崩溃-用户需要清除数据才能重新打开应用程序。
2.版本号-SQLDelight还使用这些文件对模式进行版本控制。如果不提供此设置,将会将SQLDelight配置设置回版本1。如果您的应用程序在更高版本,则由于版本不匹配,应用程序也将崩溃。
现有版本的SQLDelight不支持自动迁移。对于每个迁移文件,需要手动创建用于该文件的SQL语句。有关SQLDelight迁移的更多信息,请访问官方文档。 -
迁移适配器
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
- 提供特定于平台的代码
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