使用KMP迁移Android app到IOS平台

使用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

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

代码随想录算法训练营第十六天| 104.二叉树的最大深度 ● 111.二叉树的最小深度 ● 222.完全二叉树的节点个数

104.二叉树的最大深度 本题可以使用前序&#xff08;中左右&#xff09;&#xff0c;也可以使用后序遍历&#xff08;左右中&#xff09;&#xff0c;使用前序求的就是深度&#xff0c;使用后序求的是高度。 ●二叉树节点的深度&#xff1a;指从根节点到该节点的最长简单路径边…

Linux-Shell脚本基础

一、前言&#xff1a; 1.程序编程风格&#xff1a; 面向过程语言&#xff1a; 开发的时候 需要 一步一步 执行 做一件事情&#xff0c;排出个步骤&#xff0c;第一步干什么&#xff0c;第二步干什么&#xff0c;如果出现情况A&#xff0c;做什么处理&#xff0c;如果出现了情…

DC-9靶机做题记录

靶机下载地址&#xff1a; 链接&#xff1a;https://pan.baidu.com/s/1LR44-oFnO6NU6bTNs7VNrw?pwdhzke 提取码&#xff1a;hzke 参考&#xff1a; 【DC系列靶机DC9通关讲解】 https://www.bilibili.com/video/BV1p24y1s78C/?share_sourcecopy_web&vd_source12088c392…

Fluent Bit配置与使用——基于版本V2.2.2

Fluent Bit日志采集终端 文档适用版本&#xff1a;V2.2 1、日志文件处理流程 数据源是一个普通文件&#xff0c;其中包含 JSON 内容&#xff0c;使用tail插件记录日志&#xff0c;通过parsers进行格式化匹配&#xff08;图里没写&#xff09;&#xff0c;通过两个筛选器&…

白酒:发酵过程中的化学反应与香气形成

云仓酒庄的豪迈白酒在发酵过程中经历了多种化学反应&#xff0c;这些反应对于酒的香气和口感的形成起到了至关重要的作用。 首先&#xff0c;我们要了解发酵的定义。发酵是一个生物化学过程&#xff0c;通过特定微生物的作用&#xff0c;将原料中的糖类物质转化为酒精和二氧化碳…

爬虫正则+bs4+xpath+综合实战详解

Day3 - 1.数据解析概述_哔哩哔哩_bilibili 聚焦爬虫&#xff1a;爬取页面中指定的页面内容 编码流程&#xff1a;指定url -> 发起请求 -> 获取响应数据 -> 数据解析 -> 持久化存储 数据解析分类&#xff1a;正则、bs4、xpath(本教程的重点) 数据解析原理概述&am…

JQuery下载和一些语法

最近学了六种jQuery选择器&#xff0c;我想把学过案例和知识结合起来&#xff0c;给大家分享下&#xff01; 那么既然学jQuery选择器&#xff0c;肯定要先了解下jQuery是什么吧&#xff01;jQuery是一个快速、简洁的JavaScript框架&#xff0c;相当于用jQuery能更加高效的创建…

男主角展现炸裂演技,演绎方式独具匠心,令人叹为观止

♥ 为方便您进行讨论和分享&#xff0c;同时也为能带给您不一样的参与感。请您在阅读本文之前&#xff0c;点击一下“关注”&#xff0c;非常感谢您的支持&#xff01; 文 |猴哥聊娱乐 编 辑|徐 婷 校 对|侯欢庭 在漫长的等待之后&#xff0c;《要久久爱》这部都市情感剧终…

Redis的主从复制

目录 一、主从复制 1.主从复制是什么 2. 主从复制能干嘛 3 主从复制 3.1创建一主二仆 3.2创建伪主从 3.3编写配置文件 3.4启动三台redis服器 3.5配置注册关系 4.复制原理 5.薪火相传 6.反客为主 7.哨兵模式(sentinel) 一、主从复制 1.主从复制是什么 主机数据更新…

服务器运维小技巧(二)——如何进行监控告警

服务器运维难度高的原因&#xff0c;很大程度是因为服务器一旦出现问题&#xff0c;生产环境的业务就会受到严重影响&#xff0c;极有可能带来难以承担的后果。因此这份工作要求工程师保持高要求的服务质量&#xff0c;能够快速响应问题&#xff0c;及时解决问题。 但是“及时…

EasyExcel实现导出图片到excel

pom依赖&#xff1a; <dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.1.0</version> </dependency> 实体类&#xff1a; package com.aicut.monitor.vo;import com.aicut.monit…

springboot优雅停机

import org.springframework.context.annotation.Configuration;import javax.annotation.PreDestroy;Configuration public class DataBackupConfig {PreDestroypublic void backData(){System.out.println("开始备份..."System.currentTimeMillis());System.out.pr…

【机器学习300问】18、正则化是如何解决过拟合问题的?

当我初次看见“正则化”三个字的时候&#xff0c;我简直头疼。在我的理解里“正则”还是Python中用在字符串处理的re正则库呢&#xff01;怎么加一个“化”字就看不懂了&#xff01;听我给你慢慢道来。 一、正则化中的“正则”是个啥玩意儿&#xff1f; 正则化&#xff08;Reg…

Docker安装多个nginx容器时,要注意端口设置:

Docker安装多个nginx容器时&#xff0c;要注意端口设置&#xff1a; docker run -id --namemynginx4 -p 8089:80 nginx 安装多个nginx容器时&#xff0c;要注意端口设置&#xff1a;宿主机80端口已经被暂用&#xff0c;所以宿主机端口一定不能设置位80&#xff0c;但是容器上80…

三篇论文联合复现:高比例新能源下考虑需求侧响应和智能软开关的配电网重构程序代码!

适用平台&#xff1a;MatlabYalmipCplex 程序在高比例新能源接入的情况下提出了考虑需求响应&#xff08;DR&#xff09;和智能软开关&#xff08;SOP&#xff09;的多时段主动配电网重构策略&#xff0c;进一步降低配电系统重构费用&#xff0c;减少弃风率和弃光率&#xff1…

鼠标移入/点击子组件,获取选中子组件事件

不管是移入&#xff0c;或者是点击事件 都要知道是触发的哪个组件 这里子组件是个通用小标题title 所以&#xff0c;通过标题内容&#xff0c;获取触发的哪个子组件子组件 <template><div mouseover"tMouseover" mouseleave"tMouseLeave" class&…

五、flowable操作、查询相关

1、依赖 <dependency><groupId>com.ikaiyong.score</groupId><artifactId>score-spring-boot-starter-flowable</artifactId></dependency> 2、流程部署相关 如下建立对应文件和文件夹 2.1 流程部署 /*** 部署流程* param name*/GetMapp…

3d导模型赋予材质方法---模大狮模型网

给3D模型赋予材质的方法可以根据您使用的软件和工作流程而有所不同。以下是一般的步骤&#xff0c;您可以根据自己的情况进行调整&#xff1a; 准备模型&#xff1a;首先&#xff0c;确保您的模型已经完全建模并进行了UV映射。UV映射是将2D纹理坐标应用到3D模型表面的过程&…

17 位社区大咖寄语,Seata 进入 Apache 孵化器

北京时间 2023 年 10 月 29 日&#xff0c;分布式事务开源项目 Seata 正式通过 Apache 基金会的投票决议&#xff0c;以全票通过的优秀表现正式成为 Apache 孵化器项目&#xff01; 根据 Apache 基金会邮件列表显示&#xff0c;在包含 13 个约束性投票 (binding votes) 和 6 个…