使用KMP迁移Android app到IOS平台

news2024/11/15 13:39:14

使用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 应用程序,并不需要使用这种特定的架构。在文档、示例和多个开源代码库中,使用了更为简洁的替代方案。然而,由于此系列文章专注于移植现有的生产就绪代码库,该解决方案可能不具可扩展性。
A lean cross-platform architecture

第一步

域和存储库模块是快速胜利,因为这些模块中没有 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使用的是同一个数据库。我们的想法是,而不是重新创建数据库,我们只需替换封装它的库。我们可以通过实施以下步骤来实现:

  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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1418762.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

FastBee2.0开源版正式启动

一、项目介绍 物美智能(wumei-smart)更名为蜂信物联(FastBee)。 FastBee开源物联网平台&#xff0c;简单易用&#xff0c;更适合中小企业和个人学习使用。适用于智能家居、智慧办公、智慧社区、农业监测、水利监测、工业控制等。 系统后端采用Spring boot&#xff1b;前端采用…

【java】9:多重循环控制(难重点)

介绍&#xff1a; 1)将一个循环放在另一个循环体内&#xff0c;就形成了嵌套循环。其中&#xff0c;for ,while ,do... while均可以作为外层循环和内层循环。 【建议一般使用两层&#xff0c;最多不要超过3层&#xff0c;否则&#xff0c;代码的可读性很差】 2&#xff09;实…

C语言KR圣经笔记 5.12 复杂声明

5.12 复杂声明 C 语言有时会因为声明的语法而受到谴责&#xff0c;特别是涉及函数指针的声明语法。语法试图使声明和使用一致&#xff1b;在简单的情况下它的效果不错&#xff0c;但在更复杂的情况下会让人困惑&#xff0c;因为声明不能从左往右读&#xff0c;而且括号被过度使…

C/C++编码问题研究

文章目录 一、Unicode字符集与U8/U16/U32编码二、编码1. 占字节数2. ASCII、GB2312、GBK、GB18030 以及 UTF8 的关系3. BOM4. UTF-8的存储实现 三、编译器字符集设置1. GCC语法Example 2. MSVC语法Example 三、wchar_t五、编码转换函数六、代码 & 实践1. UTF8与UTF16、UTF3…

深度学习使用python建立最简单的神经元neuron

目录 介绍 数据&#xff1a; 建模&#xff1a; 模型&#xff1a; 介绍 在深度学习中&#xff0c;神经元通常指的是人工神经元&#xff08;或感知器&#xff09;&#xff0c;它是深度神经网络中的基本单元。深度学习的神经元模拟了生物神经元的工作原理&#xff0c;但在…

单链表实现通讯录(增删查改)

前言 之前写了很多次通讯录&#xff0c;一次比一次复杂&#xff0c;从静态到动态&#xff0c;再到文件操作&#xff0c;再到顺序表&#xff0c;今天要好好复习一下单链表&#xff0c;于是乎干脆用单链表再写一遍。 首先我们之前已经用单链表写过他的增删查改了&#xff0c;于…

IS-IS:04 DIS

IS-IS 协议只支持两种网络类型&#xff0c;即广播网络和点到点网络。与 OSPF 协议相同&#xff0c; IS-IS 协议在广播网络中会将网络视为一个伪节点 &#xff08; Pesudonde&#xff0c;简称 PSN&#xff09;&#xff0c;并选举出一台DIS &#xff08;Designated IS&#xff09…

【学习】傅里叶变换分析与理解

傅里叶级数的本质是将一个周期的信号分解成无限多分开的&#xff08;离散的&#xff09;正弦波&#xff0c;但是宇宙似乎并不是周期的。 理解频域、空域&#xff08;时域&#xff09; 时域&#xff08;空域&#xff09;是从时间方向看过去的得到的图像&#xff0c;而频域是从频…

laravel框架项目对接小程序实战经验回顾

一.对接小程序总结 1.状态转换带来的问题&#xff0c;如下 问题原因&#xff1a;由于status 传参赋值层级较多&#xff0c;导致后续查询是数组但是传参是字符串&#xff0c; 解决方案&#xff1a;互斥的地方赋值为空数组&#xff0c;有状态冲突的地方unset掉不需要的参数 2参…

4核16G幻兽帕鲁服务器优惠价格表,阿里云和腾讯云报价

幻兽帕鲁服务器价格多少钱&#xff1f;4核16G服务器Palworld官方推荐配置&#xff0c;阿里云4核16G服务器32元1个月、96元3个月&#xff0c;腾讯云幻兽帕鲁服务器服务器4核16G14M带宽66元一个月、277元3个月&#xff0c;8核32G22M配置115元1个月、345元3个月&#xff0c;16核64…

双非本科准备秋招(9.2)——力扣哈希

1、383. 赎金信 跟昨天的题大同小异&#xff0c;因为只有26个字母&#xff0c;所以可以建个有26个坑位的数组。 做完昨天的题目&#xff0c;这个题没啥新意。 class Solution {public boolean canConstruct(String ransomNote, String magazine) {int[] hashTable new int[…

shell脚本5 函数 数组

函数 试题1 查看版本 如果想更方便&#xff0c;可以建立一个专门存函数的文件 将func.sh里面的命令都移到func文件夹里面&#xff0c;在脚本里面执行文件夹更方便 输入echo $?反馈的结果都是0&#xff0c;都认为是正确的 无法使用$?去检验是否正确&#xff0c;所以要在后面增…

python 基础知识点(蓝桥杯python科目个人复习计划27)

今日复习内容&#xff1a;基础算法中的递归 1.介绍 递归&#xff1a;通过自我调用来解决问题的函数递归通常把一个复杂的大问题层层转化为一个与原问题相似的规模较小的问题来解决 递归要注意&#xff1a;&#xff08;1&#xff09;递归出口&#xff1b;&#xff08;2&#x…

使用 Redis 的 List 数据结构实现分页查询的思路

假设有一个存储数据的 List&#xff0c;每个元素代表一个记录&#xff0c;例如 recordsList。 按页存储数据&#xff1a; 每页存储一定数量的记录。例如&#xff0c;第一页存储索引 0 到 N-1 的记录&#xff0c;第二页存储索引 N 到 2N-1 的记录&#xff0c;以此类推。 分页查…

单片机介绍

本文为博主 日月同辉&#xff0c;与我共生&#xff0c;csdn原创首发。希望看完后能对你有所帮助&#xff0c;不足之处请指正&#xff01;一起交流学习&#xff0c;共同进步&#xff01; > 发布人&#xff1a;日月同辉,与我共生_单片机-CSDN博客 > 欢迎你为独创博主日月同…

IDEA开发使用 thymeleaf 模板$表达式报红波浪线解决方案

系列文章目录 文章目录 系列文章目录后端存值前端取值thymeleaf 后端存值 RequestMapping("/testModelAndView")//使用ModelAndView时返回的方法类型必须是ModelAndViewpublic ModelAndView testModelAndView() {//创建ModelAndView对象ModelAndView mav new Model…

# Redis 分布式锁如何自动续期

Redis 分布式锁如何自动续期 何为分布式 分布式&#xff0c;从狭义上理解&#xff0c;也与集群差不多&#xff0c;但是它的组织比较松散&#xff0c;不像集群&#xff0c;有一定组织性&#xff0c;一台服务器宕了&#xff0c;其他的服务器可以顶上来。分布式的每一个节点&…

2024年【N1叉车司机】考试内容及N1叉车司机复审考试

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 N1叉车司机考试内容是安全生产模拟考试一点通生成的&#xff0c;N1叉车司机证模拟考试题库是根据N1叉车司机最新版教材汇编出N1叉车司机仿真模拟考试。2024年【N1叉车司机】考试内容及N1叉车司机复审考试 1、【多选题…

SpringSecurity笔记

SpringSecurity 本笔记来自三更草堂&#xff1a;https://www.bilibili.com/video/BV1mm4y1X7Hc/?spm_id_from333.337.search-card.all.click&#xff0c;仅供个人学习使用 简介 Spring Security是Spring家族中的一个安全管理框架。相比与另外一个安全框架Shiro&#xff0c;…

【Django开发】前后端分离美多商城项目:项目准备和搭建(附代码,文档)

本系列文章md笔记&#xff08;已分享&#xff09;主要讨论django商城项目开发相关知识。本项目利用Django框架开发一套前后端不分离的商城项目&#xff08;4.0版本&#xff09;含代码和文档。功能包括前后端不分离&#xff0c;方便SEO。采用Django Jinja2模板引擎 Vue.js实现…