【长文】记一次个人 Android 项目全量迁移至 KMP 跨平台的过程

背景

对背景不感兴趣的可以直接跳到 迁移全过程

我个人开发并维护的开源应用 译站 自 2020 年 1 月首个 commit 依赖,已经过了 4 年。技术栈从旧版的基于 Java 语言,在传统 View 体系下开发;到后面逐步全量迁移到 Kotlin,页面完全改用 Jetpack Compose。前些日子,线上参与 Kotlin 中文开发者大会,听了南京 GDG 组织者的分享 (传送门) 后,决定也试试 参与 Kotlin Multiplatform 竞赛,赢取 KotlinConf'24 之旅! | The Kotlin Blog。于是从 2023 年 10 月 23 日起,我开始将这个项目从纯 Android 项目迁移至 Compose 跨平台。本次博客记录了此次迁移的部分过程。

项目源代码:github.com/FunnySaltyF...

关于 KMP 和 CMP

为了给不太懂的读者们有个基本的了解,下面简单介绍一下一些出现的术语:

  • KMP (Kotlin Multiplatform) : 是 Kotlin 的一个功能,允许在多个平台上共享代码。这包括 Android、iOS、Web 等。通过 KMP,开发者可以编写一次代码,并在多个平台上使用,从而提高开发效率。KMP 在 23年11月 已经稳定并且可以投入生产环境)。KMP 基于 Expect/actual 机制完成特定平台 API 的实现,也就是 Expect 定义一个空架子,在各平台通过 Actual 实现。
  • CMP (Compose Multiplatform) : 是基于 KMP 的一项技术,使得使用 Jetpack Compose 这一现代 UI 框架成为跨平台开发的可能。Compose Multiplatform 让开发者能够共享 UI 代码,并在不同平台上创建原生的用户界面。目前较为稳定的支持 Android、Desktop,iOS 已进入 Alpha 阶段

这两项技术的结合,使得开发者可以更高效地同时在 Android 和 Desktop(以及其他平台)上构建漂亮且一致的用户界面。

最终效果

效果见 Github 仓库,有截屏以及演示视频

一些主要亮点

  • 完成了多种组件的 KMP 化,如 Context, Activity,通过 expect/actual + typealias,尽可能的减少代码变动
  • 自己设计了 Compose Desktop 的窗口管理,通过 DSL 的形式配置窗口,做到 Window 与 Activity 的对应
  • 通过动态代理的方式完成 DAO 的迁移,以简单的代码保持了原有 Room DAO 仍然可用(底层转化为 SqlDelight 生成代码的调用)
  • ......

IDE 体验

在开发的前几天,我分别多次尝试了用 Fleet、Android Studio、IDEA 来开发这个应用,到最后用下来还是继续用的 AS。它的开发体验基本和 IDEA 一致,而且 Desktop 应用也可以直接点击小按钮运行。而不选用另外两者的原因分别是:

  • Fleet:目前来看还是略显简陋,各种窗口很难调出来(不能保持常驻),没有 AI 补全

    可以通过 下载 Fleet 安装(通过 Toolbox)

    Fleet 还内置了一个 Jetbrains AI,兴冲冲地登录,一看,好家伙,直接 "在你的地区不可用"。好吧,那我还是用回我的 AS 吧

  • IDEA:自动生成的 expect/actual 生成的位置非常奇怪,比如对于 android 侧,分别显示了 androidMain,和 kotlin.androidMain,选前一个啥用也没有,选后一个生成的文件名会是:Xxx.android.commonMain.kotlin.kt 不太合理

  • AS:Android 开发体验继承,Desktop 也可以直接运行

迁移全过程

在写代码的同时,我也记录了部分迁移过程。

迁移前

涉及全项目迁移,我必须慎重。在正式动手前,我花了一段时间做一些准备

思考迁移条件

项目如果想迁移到 KMP + CMP,对它来说,自带的一些优势可能包括:

  • UI 几乎全部是 Jetpack Compose,可以迁移

    • 但是悬浮窗、代码编辑器用的 xml
    • 有一些控件也是 View,比如 WebView、MarkdownText
  • 用的一些第三方库本身就是跨平台的

    • JSON 序列化框架用的 kotlinx.serialization,跨平台
    • kotlinx.coroutines 跨平台
    • 完全没用 LiveData,都改成 Flow 了,跨平台
  • 代码几乎全是 Kotlin

  • 目标仅是 Desktop(JVM)+ Android,二者均有 Java 运行环境,这意味着 commonMain 里面可以用 Java API,比如 java.io.File, java.util.Date 等,很多代码改动不会那么大

但仍然存在很多问题,作为 Android 项目,会有大量代码涉及到 Android 特有 API,比如 Context, Activity 等。通过大致浏览代码以及 Github 仓库,我在迁移前尝试列出了如下可能涉及的变更

可能涉及的变更

看目前的代码以及 KMP 项目 README 所列出来的可能要涉及的变更

主要的
项目 原始 Android 版本 KMP 可能存在的问题
数据库 androidx.room cashapp/sqldelight: SQLDelight - Generates typesafe Kotlin APIs from SQL (github.com) 大量写法变更,需要新建 sq 文件,手写很多 Sql 语句
资源(字符串、图片) xml、res 文件夹,stringResource()、painterResource()、string() (自定义全局函数)、context.getString/resources.getString,BitmapFactory 相关类 icerockdev/moko-resources: Resources access for mobile (android & ios) Kotlin Multiplatform development (github.com) 上面那个无法工作在 Desktop Skeptick/libres: Resources generation in Kotlin Multiplatform (github.com) 很多写法也得变更,R 需要替换成 MR,string() 函数也要重新实现,涉及 context、resources 都得替换 跨平台的图片和资源访问自带了:www.jetbrains.com/help/kotlin...
JavaScript 执行 rhino-android rhino? icerockdev/moko-javascript: JavaScript evaluation from kotlin common code for android & iOS (github.com) 也需要做 actual/expect,看看 JVM 平台能不能继续用 rhino
导航 androidx.navigation Tlaster/PreCompose: Compose Multiplatform Navigation && State Management (github.com)
ViewModel androix.lifecycle Tlaster/PreCompose: Compose Multiplatform Navigation && State Management (github.com)
持久化 MMKV + ComposeDataSaver MMKV 应该可以跨平台,但是 ComposeDataSaver 暂时不行,得把这个库改成跨平台的
网络请求 OkHttp + Retrofit jvm 平台应该可以继续用
权限 Permission jvm 平台没有这个概念
杂项

后来我发现官方也给了个 Android 特有 API 的 List:Android-only components | Kotlin Multiplatform Development Documentation,也可以作为迁移时可能遇到的阻碍的参考

实际过程

我决定新建全新的项目,通过一点点复制代码-编译运行,逐步来完成迁移。新建项目直接使用

最初的两三天(2023年12月23日-12月25日)

12月26号

26 号,我决定先放弃尝试构建脚本的配置,先通过复制粘贴的方式暂且先用着,开始尝试实际的代码运行预实验。当天完成了 JavaScript 在 Android 端和 Desktop 端(以下简称双端)的运行。

12月27-28号

这两天主要完成了 OkHttp + Retrofit 在双端的运行,由于原本的代码涉及到方方面面,尽管只是网络请求,仍然涉及到多个文件。在这个过程中同时完成了 Json 序列化、CacheManager (原来的诸如 appContext.externalDir 被统一管理起来)、简单 Test 环境的搭建等。

12月29号

开始尝试做资源迁移的验证。在尝试了半天 moko-resources 后,我发现它没有办法正确在 Desktop 平台上生成对应的编译产物。在 issues 列表里面类似的问题也都是悬而未决。故而转而使用 libres

Libres 可以正常在双端运行,这意味着字符串资源可以用了。它需要在特定目录书写 strings_en.xmlstrings_zh.xml 并通过 <string name="xxx">XXX</string> 配置条目,然后通过运行 gradle task 生成 Kotlin 的 ResStrings 类,使用时形式直接为 val title: Srting = ResStrings.xxx。这意味着有相当一部分的代码需要做修改(原来是通过 R.string.xxx 访问)。

另外运行时发现,Libres 要求不能使用 Java Style 的 format ,也就是类似于

xml 复制代码
<string name="tip_reset_fingerprint">The fingerprint information saved for the current account (%1$s) on this device seems to have been cleared. Would you like to re-add the fingerprint and send a verification code to verify your email (%2$s)?</string>

得改成

xml 复制代码
<string name="tip_reset_fingerprint">The fingerprint information saved for the current account ${username} on this device seems to have been cleared. Would you like to re-add the fingerprint and send a verification code to verify your email ${email}?</string>

在 Gradle 任务中找到 libres 的对应任务,即可生成

而 libres 会生成对应的代码:

kotlin 复制代码
// StringsEn.kt
public object StringsEn : Strings {
    override val hint: String = "Hint"

  	override val tip_reset_fingerprint: LibresFormatTipResetFingerprint =
      LibresFormatTipResetFingerprint("The fingerprint information saved for the current account %1${'$'}s on this device seems to have been cleared. Would you like to re-add the fingerprint and send a verification code to verify your email %2${'$'}s?")
    
}

// FormattedClasses.kt
public class LibresFormatTipResetFingerprint(
  private val `value`: String,
) {
  public fun format(username: String, email: String): String = formatString(value,
      arrayOf(username,email))
}

调用时手动 .format() 一下。

接下来就是对原来所有的 R.string.xxx 做替换了,在我的项目中,它们主要是下面的形式

kotlin 复制代码
context.getString(R.string.xxx) 
string(R.string.xxx) // 全局函数,里面调用了 applicationCtx 的类似方法
stringResoucres(R.string.xxx) // Jetpack Compose 获取 string 的方法

// 以及带参数的
string\(R.string.([^,]+),\W(.+)\)

上面这些是好替换的,依靠在 AS 中的正则表达式就能完成

scss 复制代码
// 带参数的
string\(R.string.([^,]+),\W(.+)\) -> ResStrings.$1.format($2) (这个替换完可能得手动改一下,把非 String 的参数加上 .toString()

// 不带参数的
string\(R.string.(\w+)\) -> ResStrings.$1
stringResource\(R.string.(\w+)\) -> ResStrings.$1
stringResource\(id = R.string.(\w+)\) -> ResStrings.$1

但还有一些就不好替换了,比如 class Student(val nameId: Int) 这种类里面本来引用资源 ID 的,现在只能一点点迁移。

12月31号

2023年最后一天了,我还在这苦逼的迁移。今天做的是数据库的迁移,从 Room 迁移到 Sqldelight。

参考 Getting Started - SQLDelight 完成 Setting,SQLDelight 使用笔记 - 掘金 (juejin.cn) 做中文版的参考。注意虽然官方文档和它都说 sq 放在 main 文件夹下,但实际上我这里需要放在 commonMain 这个文件夹,否则什么代码都不生成、。

这里注意的是,你的表名称决定生成的 data class 的名称,我原来叫 trans_history,结果生成的 class 叫 class Trans_history,改成 create table transHistory 就正确了。另外,这个插件用起来还是非常可以的,在安装 AS 中的同名插件后,在单个 sq 里做的更改立马就能生成对应代码,不用手动 build(只有需要修改Database,比如新建了个 Table、新增了 Queries 时才需要手动执行 Gradle 任务),点个赞。

另外编写时发现,在 sql 中书写的所有 Integer 类型的字段,生成的 class 对应属性默认为 Long,而我原来定义的都是 Int,这导致了很多不兼容之处。后来发现需要手动 AS 指定

sql 复制代码
import kotlin.Boolean;

CREATE TABLE foo(
  is_bar INTEGER AS Boolean
);

类似的,Text 转成 List<Int> 之类的也需要这样。你需要在定义 Database 时传入 ColumnAdapter(传入的参数列表也是自动生成的),比如:

kotlin 复制代码
val listOfStringsAdapter = object : ColumnAdapter<List<String>, String> {
  override fun decode(databaseValue: String) =
    if (databaseValue.isEmpty()) {
      listOf()
    } else {
      databaseValue.split(",")
    }
  override fun encode(value: List<String>) = value.joinToString(separator = ",")
}

val queryWrapper: Database = Database(
  driver = driver,
  hockeyPlayerAdapter = hockeyPlayer.Adapter(
    cup_winsAdapter = listOfStringsAdapter
  )
)

原先写好的 TypeConverter 怎么转过来呢?可以借助 ChatGPT 的帮助

我现在将 Android 应用迁移到 KMP,数据库从 Room 迁移到 SqlDelight。参考下列需要的代码,将所有的 room type converters 转为 sqldelighht 需要的格式: 参考的代码:

kotlin 复制代码
val listOfStringsAdapter = object : ColumnAdapter<List<String>, String> {
  override fun decode(databaseValue: String) =
    if (databaseValue.isEmpty()) {
      listOf()
    } else {
      databaseValue.split(",")
    }
  override fun encode(value: List<String>) = value.joinToString(separator = ",")
}

你需要转换的代码:

kotlin 复制代码
class LanguageListConverter{
    @TypeConverter
    fun languagesToJson(languages : List<Language>) : String = JsonX.toJson(languages)

    @TypeConverter
    fun jsonToLanguages(json : String) : List<Language> {
        return JsonX.fromJson(json)
    }
}

// .... 等等等

你生成的代码为单例 object,JsonX 是我已有实现,不需要改变

对于基本类型,可以使用它们提供的另一个包:

Primitives

A sibling module that adapts primitives for your convenience.

kotlin 复制代码
dependencies {
  	implementation("app.cash.sqldelight:primitive-adapters:2.0.1")
}

The following adapters exist:

  • FloatColumnAdapter --- Retrieves kotlin.Float for an SQL type implicitly stored as kotlin.Double
  • IntColumnAdapter --- Retrieves kotlin.Int for an SQL type implicitly stored as kotlin.Long
  • ShortColumnAdapter --- Retrieves kotlin.Short for an SQL type implicitly stored as kotlin.Long

如果需要转成 Flow,则可以用它们提供的另一个拓展:

kotlin 复制代码
 implementation("app.cash.sqldelight:coroutines-extensions:2.0.1")

val players: Flow<List<HockeyPlayer>> = 
  playerQueries.selectAll()
    .asFlow()
    .mapToList(Dispatchers.IO)

甚至分页也有提供:AndroidX Paging - SQLDelight

另外就是关于原 Bean 的处理了,现在既然由 SqlDelight 生成了,那么原 Bean 就可以不需要了,为了保持调用处代码仍然不需要更改,这里继续用 typealias

kotlin 复制代码
// 删除原先的 data class JsBean(xxx, xxx, )

typealias JsBean = com.funny.translation.database.Plugin

另一件事情是完成了 Toast 的迁移,由于之前的 toast 调用都是通过 context.toastOnUi() 这个拓展函数实现的,这个迁移到是比较容易。在 Android 端继续使用原有代码,在 Desktop 端则是通过在整个顶级 Composable 上面覆盖一层 ToastUI,参考 Jetpack Compose实现的一个优雅的 Toast 组件------简单易用~ - 掘金 (juejin.cn) 完成了一个全局 Toast 组件,然后去让它显示对应内容。

2023 就这样结束了

2024年1月1号

是的,元旦我还在写代码。今天迁移的是 键值对存储 部分的代码。之前使用的FunnySaltyFish/ComposeDataSaver: 在Jetpack Compose中优雅完成数据持久化 | An elegant way to do data persistence in Jetpack Compose 与 Android 绑定了。鉴于我是这个库的开发者,所以迁移起来很熟悉。最好的处理是让库支持 KMP,不过限于时间,我先直接 Copy 源码改一改。

改动的过程很简单,Android 端继续用 DataSaverMMKV,而 Desktop 则自己继承 DataSaverInterface ,让 ChatGPT 帮我写一个用 properties 储存的方案即可。代码如下:

kotlin 复制代码
class DataSaverProperties(private val filePath: String) : DataSaverInterface() {
    private val properties = Properties()

    init {
        try {
            FileReader(filePath).use { reader ->
                properties.load(reader)
            }
        } catch (e: Exception) {
            // 处理文件不存在等异常
        }
    }

    private fun saveProperties() {
        FileWriter(filePath).use { writer ->
            properties.store(writer, null)
        }
    }

    override fun <T> saveData(key: String, data: T) {
        properties[key] = data.toString()
        saveProperties()
    }

    override fun <T> readData(key: String, default: T): T {
        val value = properties.getProperty(key) ?: return default
        return when (default) {
            is Int -> value.toIntOrNull() as T? ?: default
            is Long -> value.toLongOrNull() as T? ?: default
            is Boolean -> value.toBoolean() as T ?: default
            is Double -> value.toDoubleOrNull() as T? ?: default
            is Float -> value.toFloatOrNull() as T? ?: default
            is String -> value as T
            else -> value as T
        }
    }

    override fun remove(key: String) {
        properties.remove(key)
        saveProperties()
    }

    override fun contains(key: String): Boolean {
        return properties.containsKey(key)
    }
} 

然后就可以正常使用了

kotlin 复制代码
CompositionLocalProvider(
    LocalDataSaver provides DataSaverUtils // in desktop, = DataSaverProperties(xxx)
) {
    MaterialTheme {
        Box(Modifier.fillMaxSize()) {
            // 省略其他的
            var switch by rememberDataSaverState<Boolean>(
                key = "switch",
                initialValue = false
            )
            Switch(checked = switch, onCheckedChange = { switch = it })

            Toast(
                modifier = Modifier.align(Alignment.BottomEnd)
            )
        }
    }
}

如果对这个库感兴趣,欢迎点进去了解。我也会在近期将其迁移到 KMP。

另外也把 FunnySaltyFish/CMaterialColors: Jetpack Compose Material Design Colors | 在 Jetpack Compose 中使用 Material Design Color 的颜色复制了一份,这样项目里就能用 MaterialColors.A300 这种了。

1月2号

完成了 Theme 的迁移,Android 平台支持多种类型主题(壁纸取色、图片取色等),Desktop 就只支持固定颜色了

同时添加了 Desktop 端的 Markdown (基于 mikepenz/multiplatform-markdown-renderer: Markdown renderer for Kotlin Multiplatform Projects (Android, iOS, Desktop), using Compose. (github.com) )并简单调整了下样式(字体大小、间距等)。另外加入了 PreCompose 的依赖,这是一个 CMP 的 navigation, viewModel, lifecycle 库,但并未实际使用

1月3号

开始做 KMP 的 Context 迁移,由于涉及到的使用之处实在太多,我希望尽可能减少迁移的代码,因此尽可能使用 typealias 形式。对于 Context ,由于它是抽象类,非常容易实现

kotlin 复制代码
// commonMain
expect abstract class KMPContext

expect fun KMPContext.openAssetsFile(fileName: String): InputStream

expect fun KMPContext.readAssetsFile(fileName: String): String

expect val LocalKMPContext: ProvidableCompositionLocal<KMPContext>

expect val appCtx: KMPContext


// androidMain
actual typealias KMPContext = android.content.Context

actual val LocalKMPContext = LocalContext

actual val appCtx: KMPContext
    get() = BaseApplication.ctx

actual fun KMPContext.openAssetsFile(fileName: String): InputStream {
    return runBlocking {
        ByteArrayInputStream(resource("assets/$fileName").readBytes())
    }
}

actual fun KMPContext.readAssetsFile(fileName: String): String {
    return runBlocking {
        resource("assets/$fileName").readBytes().decodeToString()
    }
}

// Desktop Main
actual abstract class KMPContext

actual val LocalKMPContext: ProvidableCompositionLocal<KMPContext> =
    staticCompositionLocalOf { appCtx }

actual val appCtx = object : KMPContext() { }

actual fun KMPContext.openAssetsFile(fileName: String): InputStream {
    return runBlocking {
        ByteArrayInputStream(resource("assets/$fileName").readBytes())
    }
}

actual fun KMPContext.readAssetsFile(fileName: String): String {
    return runBlocking {
        runCatching {
            resource("assets/$fileName").readBytes().decodeToString()
        }.getOrDefault("")
    }
}

其中用到的函数 resource 是 CMP 的一个函数,用于访问 commonMain/resources 目录下的文件,可以跨平台使用,因此跨平台资源也实现了。

对于 Activty

java 复制代码
public class Activity extends ContextThemeWrapper
        implements LayoutInflater.Factory2,
        Window.Callback, KeyEvent.Callback,
        OnCreateContextMenuListener, ComponentCallbacks2,
        Window.OnWindowDismissedCallback,
        ContentCaptureManager.ContentCaptureClient {}

Activity 因为实现了太多的接口,没有办法直接用 typealias 处理。再经过了几天的探索后,最终形成了下面的形式:

kotlin 复制代码
// common
expect open class KMPActivity()

// android
actual typealias KMPActivity = AppCompatActivity

fun KMPActivity.toastOnUi(msg: String) {
    appCtx.toastOnUi(msg)
}

// desktop
actual open class KMPActivity: KMPContext()

再另外实现 BaseActivity 继承自 KMPActivity,其他所有 Activity 均继承自 BaseActivity

kotlin 复制代码
expect open class BaseActivity() : KMPActivity

// android
actual open class BaseActivity : KMPActivity() {
    private lateinit var callback: OnBackPressedCallback
    lateinit var activityResultLauncher: ActivityResultLauncher<Intent>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
		// 各种初始化等等,它就是原本的 BaseActivity
    }
}

而桌面端则额外加入了 Window 管理

kotlin 复制代码
actual open class BaseActivity : KMPActivity() {
    lateinit var windowState: WindowState
    val windowShowState = mutableStateOf(false)
    var data: DataType? = null

    open fun onShow() {
        Log.d("BaseActivity", "onShow: $this")
    }

    open fun onStart() {
        Log.d("BaseActivity", "onStart: $this")
    }

    fun finish() {
        windowShowState.value = false
    }

    override fun toString(): String {
        return "Activity: ${this::class.simpleName}, show = ${windowShowState.value}"
    }
}

其中的 onShow, onStart 是自己写的简单生命周期,data 用于接收其他 Activity 跳转过来传的参数,具体细节见源码

1月5号

这一天完成了原 login 模块的迁移,包括所有注册登录逻辑。这一部分由于涉及到多个类,迁移起来比较缓慢。在 Android 平台使用了 androidx.biometric 以支持指纹登录,这个特性通过 expect/actual 完成迁移,目前在 Desktop 使用了空实现(不支持),通过调整新增一个变量 supportBiometric 来替代原来频繁出现的 Build.VERSION.SDK_INT >= Build.VERSION_CODES.M

kotlin 复制代码
expect val supportBiometric: Boolean

// android
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M)
actual val supportBiometric: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M

// desktop
actual val supportBiometric: Boolean = false

在 UI 层判断这个来去掉指纹相关的 UI 代码(录入指纹、验证指纹、切换为密码等)

迁移过程中出现了 R.drawable.xxx 的情况,把原先项目里的 drawable 挪到 commonMain/resources/drawble 文件夹下,然后再批量替换

bash 复制代码
painterResource\(id \= R\.drawable\.(.+?)\) 
->
org.jetbrains.compose.resources.painterResource("drawable/$1.png")

1月6号

开始迁移主 module,这里涉及到了数据库的实际调用。我原来的项目是用的 Room,也就是有很多 Dao,类似于:

kotlin 复制代码
@Dao
interface TransHistoryDao {
    @Query("select * from table_trans_history where id in " +
            "(select max(id) as id from table_trans_history group by sourceString) order by id desc")
    fun queryAllPaging(): PagingSource<Int, TransHistoryBean>

    @Query("delete from table_trans_history where id = :id")
    fun deleteTransHistory(id: Int)

    @Query("delete from table_trans_history where sourceString = :sourceString")
    fun deleteTransHistoryByContent(sourceString: String)

    @Insert
    fun insertTransHistory(transHistoryBean: TransHistoryBean)

    @Query("select * from table_trans_history where time between :startTime and :endTime")
    fun queryAllBetween(startTime: Long, endTime: Long): List<TransHistoryBean>

    @Query("delete from table_trans_history")
    fun clearAll()
}

在原来的代码里都是这样用:

kotlin 复制代码
var allHistories = transHistoryDao.queryAllBetween(START_TIME, END_TIME) 

但是在迁移到 SqlDelight 后呢,就需要用它们的 queries,类似于:

kotlin 复制代码
var allHistories = appDB.transHistoryQueries.queryAllBetween(START_TIME, END_TIME).executeAsList() 

一点一点的改显然比较费劲,那有没有办法借助一些技术,通过写一些新的类或者方法,抹去这种差异。这个新的类可以适用于不同的 Dao,然后让调用处尽可能不改变呢?

这我不熟,然后就去问了下 ChatGPT,结果它真给出了一个貌似可行的方案:

kotlin 复制代码
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy

class UnifiedDaoProxy(private val roomDao: Any, private val sqlDelightQueries: Any) : InvocationHandler {

    override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? {
        return when (method.name) {
            "queryAllBetween" -> {
                callAppropriateMethod(method, args, List::class.java, TransHistoryBean::class.java)
            }
            "clearAll" -> {
                callAppropriateMethod(method, args)
            }
            // 添加其他需要统一的方法
            else -> throw UnsupportedOperationException("Method ${method.name} not supported")
        }
    }

    private fun callAppropriateMethod(
        method: Method,
        args: Array<out Any>?,
        expectedReturnType: Class<*>? = null,
        expectedGenericType: Class<*>? = null
    ): Any? {
        val returnType = method.returnType
        return when {
            roomDao.javaClass.methods.any { it.name == method.name && it.returnType == returnType } ->
                callMethod(roomDao, method, args, expectedReturnType, expectedGenericType)
            sqlDelightQueries.javaClass.methods.any { it.name == method.name && it.returnType == returnType } ->
                callMethod(sqlDelightQueries, method, args, expectedReturnType, expectedGenericType)
            else -> throw UnsupportedOperationException("Method ${method.name} not found")
        }
    }

    private fun callMethod(
        target: Any,
        method: Method,
        args: Array<out Any>?,
        expectedReturnType: Class<*>? = null,
        expectedGenericType: Class<*>? = null
    ): Any? {
        val result = method.invoke(target, *args.orEmpty())

        if (expectedReturnType != null && !expectedReturnType.isAssignableFrom(method.returnType)) {
            throw UnsupportedOperationException("Method ${method.name} does not return expected type.")
        }

        if (expectedGenericType != null && result is List<*> && result.isNotEmpty()) {
            val actualGenericType = result[0].javaClass
            if (!expectedGenericType.isAssignableFrom(actualGenericType)) {
                throw UnsupportedOperationException("Method ${method.name} does not return expected generic type.")
            }
        }

        return result
    }
}

// 工厂函数创建代理
inline fun <reified T : Any> createUnifiedDao(roomDao: Any, sqlDelightQueries: Any): T {
    val handler = UnifiedDaoProxy(roomDao, sqlDelightQueries)
    return Proxy.newProxyInstance(
        T::class.java.classLoader,
        arrayOf(T::class.java),
        handler
    ) as T
}

虽然它理解错了我的意思,但是给出了一条可行的路:通过动态代理找到和原本 DAO 相同签名的函数(方法),通过反射调用。于是在边修边改下,我完成了下面这个简单的实现:

kotlin 复制代码
private const val TAG = "DaoProxy"

/**
 * 转换 Dao 的调用为 SqlDelight 的调用
 * @author [FunnySaltyFish](https://github.com/FunnySaltyFish)
 *
  */

class DaoProxy(private val sqlDelightQueries: Any) : InvocationHandler {
    override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? {
        return callAppropriateMethod(method, args)
    }

    private fun callAppropriateMethod(
        method: Method,
        args: Array<out Any>?
    ): Any? {
        val sqldelightMethod = sqlDelightQueries.javaClass.methods.find {
            it.name == method.name && it.parameterCount == method.parameterCount
        }
        sqldelightMethod ?: throw UnsupportedOperationException("Method ${method.name} not found")
        Log.d(TAG, "find sqldelightMethod: $sqldelightMethod")

        val returnType = method.returnType
        // 强转成类型 Query<ExpectedGenericType>
        val query = sqldelightMethod.invoke(sqlDelightQueries, *args.orEmpty()) as? Query<*> ?: return null
        // 调用 Query 的方法
        return when (returnType) {
            List::class.java -> query.executeAsList()
            Flow::class.java -> query.executeAsFlowList()
            else ->  callAndConvert(returnType, query)
        }
    }

    /**
     * 调用方法并进行适当的类型转换,目前做的有
     * 1. 如果返回值是 Long 而 Dao 的返回值是 Int(count方法),那么就转为 Int
     */
    private fun callAndConvert(
        daoReturnType: Class<*>,
        query: Query<*>
    ): Any? {
        val executedQuery = query.executeAsOneOrNull() ?: return null
        return when {
            daoReturnType == Int::class.java && executedQuery is Long -> {
                executedQuery.toInt()
            }

            else -> {
                executedQuery
            }
        }
    }
}

// 工厂函数创建代理
inline fun <reified T : Any> createDaoProxy(sqlDelightQueries: Any): T {
    val handler = DaoProxy(sqlDelightQueries)
    return Proxy.newProxyInstance(
        T::class.java.classLoader,
        arrayOf(T::class.java),
        handler
    ) as T
}

inline fun <reified RowType : Any> Query<RowType>.executeAsFlowList(): Flow<List<RowType>> {
    return this.asFlow().mapToList(Dispatchers.IO)
}

然后通过拓展属性让原来的 appDB.xxDao 生效

kotlin 复制代码
val Database.transHistoryDao by lazy {
    createDaoProxy<TransHistoryDao>(appDB.transHistoryQueries)
}

具体见源码

1月8号

迁移 main.ui 时碰到了 LazyPagingItems 相关,这个是 androidx.paging:paging-compose 提供的,然鹅目前的 sqldelight 提供的 paging-extension 并不包括这个库。还好对应的源代码只有几个文件,一通复制粘贴,加上把涉及 android.os.Parcel 相关的部分处理成 expect/actual 实现,就可以继续用了。

另外发现了上面写的这个动态代理不适用于返回 PagingSource 的方法,于是参考官方文档 AndroidX Paging - SQLDelight (cashapp.github.io) 的描述把相应的调用处手动改回使用 xxQueries

1月9号

在经历了漫长的各种基础组件迁移后,终于是把 MainContent 跑起来了,并完成了调用 Rhino-Js 做一些加密逻辑的实际代码。这个过程中遇到了 rhino 1.7.14 报错 Failed resolution of: Ljavax/lang/model/SourceVersion;,在 github 上的 Rhino 1.7.14 not usable on Android any more due to javax.lang.model.SourceVersion · Issue #1149 这个 issue 也详细讨论了这个问题,并且似乎已经在 1.7.14.1 的版本中修复了------但很可惜并没有发版。将 rhino 降级至 1.7.13 问题解决。

除此之外还碰到了 java.lang.RuntimeException: No Context associated with current Thread at org.mozilla.javascript.Context.getContext(Context.java:2452) 这个报错,问 ChatGPT ,是因为多线程环境下 ContextThread 不对应导致的,听取它的建议改为 contextFactory.enterContext() 问题也解决了。

在 Android 跑起来后,又开始尝试在 Desktop 上运行,然后碰到了第一个报错

java.lang.UnsatisfiedLinkError: 'int org.jetbrains.skiko.SystemTheme_awtKt.getCurrentSystemTheme()

参考官方的 java.lang.UnsatisfiedLinkError when using isSystemInDarkTheme with v1.1.0-alpha02 · Issue #1761 这个 issue,虽然说是早已修复,但目前就是发生了。没办法找解决办法。参考 github.com/JetBrains/c... 换用另一个库解决了

然后新问题又出现了,一运行下面的代码就报错

kotlin 复制代码
viewModelScope.launch(Dispatchers.IO) { // <--- 这一行报错
    appDB.jsDao.getAllJs().forEach { jsBean ->
    }                                
}                                    

Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

继续参照 Module with the Main dispatcher had failed to initialize · Issue #767 · JetBrains/compose-multiplatform (github.com) ,在 desktop 去掉 kotlinx-coroutines-android相关的依赖

kotlin 复制代码
configurations.commonMainApi {
            exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-android")
        }

运行成功了。终于终于在 Desktop 上跑起来了!

1月10号

10号继续敲代码的一天,终于成功完成了 TransActivityLoginActivity 的跳转。Android 端的实现是为 BaseActivity 增加了 lateinit var activityResultLauncher: ActivityResultLauncher<Intent> 并且在 onCreate 做初始化,通过 object ActivityManager.start 方法找到这个 Activity 并执行 launch(Intent()),而 Desktop 则是自己管理 Window 的显示与隐藏,通过 ActivityWindow 一一对应的方式,自己设计了简单的 DSL 完成每个 Window 的声明,目前的使用侧 API 如下:

kotlin 复制代码
application {
        WindowHolder {
            addWindow<TransActivity>(
                rememberWindowState(), 
                show = true, 
                onCloseRequest = { exitApplication() }
            ) { transActivity ->
                CompositionLocalProvider(LocalActivityVM provides transActivity.activityViewModel) {
                    AppNavigation(navController = rememberNavController().also {
                        ActivityManager.findActivity<TransActivity>()?.navController = it
                    }, exitAppAction = ::exitApplication)
                }
            }

            addWindow<LoginActivity>(
                rememberWindowState(
                    placement = WindowPlacement.Floating,
                    width = 360.dp,
                    height = 700.dp,
                ),
            ) { loginActivity ->
                LoginNavigation(
                    onLoginSuccess = {
                        Log.d("Login", "登录成功: 用户: $it")
                        if (it.isValid()) AppConfig.login(it, updateVipFeatures = true)
                        loginActivity.finish()
                    }
                )
            }
      }
}
            

这中间经历了 N 多的坑,比如本来 Log 用的是 KotlinLogging 提供的,但是发现 run 界面没有输出,最后折腾一番决定换成 println 打日志,然后就有了;原先 Activity 的跳转在 Desktop 上一直没反应,后来发现是变量的一些写法没有触发重组;又比如中间出现过

Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied

后来发现是 State 读取的时机错误,又是一通修改。不管怎样,目前总算是有个起色了。

1月11日

今天开始做其他各种页面的迁移,由于基础设施们逐渐完善,这次准备多个页面一起迁移,这有助于全局替换 R.string.xxxR.drawble 的各种用法时减少一些次数。

比较难受的是,我的一些基础组件传图片原本参数都是 resourceId: Int,现在全部需要改成 resourceName: String 并改成 org.jetbrains.compose.resources.painterResource(path: String) 来调用,我自己封装了一个简单的函数来调用:

kotlin 复制代码
@OptIn(ExperimentalResourceApi::class)
@Composable
fun painterDrawableRes(name: String, suffix: String = "png") = painterResource("drawable/${name}.$suffix")

然后就是全局替换+一点点修改了,这个花了些时间,不过好在并不困难。

接下来遇到了 activityResultLauncher 的相关代码,原先在 Composable 是这样用:

kotlin 复制代码
val exportFileLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.CreateDocument("plain/text")
) { uri ->
   if (uri == null) return@rememberLauncherForActivityResult
   // 省略无关代码
   uri.writeText(context, "$text\n\n${"-".repeat(20)}\n$watermark")
   context.toastOnUi(string(id = R.string.export_success))
}

onClick = {
    exportFileLauncher.launch("result_${remark}.txt")
}

现在这个肯定是要 KMP 化了,顺手写个 Launcher

kotlin 复制代码
/**
 * KMP Launcher
 * @author FunnySaltyFish
 */
abstract class Launcher<Input, Output> {
    abstract fun launch(input: Input)
}

expect class FileLauncher<Input>: Launcher<Input, Uri?> {
    override fun launch(input: Input)
}

@Composable
expect fun rememberCreateFileLauncher(
    mimeType: String = "*/*",
    onResult: (Uri?) -> Unit = {},
): FileLauncher<String>

@Composable
expect fun rememberOpenFileLauncher(
    onResult: (Uri?) -> Unit = {},
): FileLauncher<Array<String>

然后在 Android 端包裹一层

kotlin 复制代码
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import com.eygraber.uri.Uri
import com.eygraber.uri.toUri
import android.net.Uri as AndroidUri

actual class FileLauncher<Input>(
    private val activityResultLauncher: ManagedActivityResultLauncher<Input, AndroidUri?>
) : Launcher<Input, Uri?>() {
    actual override fun launch(input: Input) {
        activityResultLauncher.launch(input)
    }
}

@Composable
actual fun rememberCreateFileLauncher(
    mimeType: String,
    onResult: (Uri?) -> Unit
): FileLauncher<String> {
    val res: ManagedActivityResultLauncher<String, AndroidUri?> = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(mimeType)) {
        onResult(it?.toUri())
    }
    return FileLauncher(res)
}

@Composable
actual fun rememberOpenFileLauncher(
    onResult: (Uri?) -> Unit
): FileLauncher<Array<String>> {
    val res = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
        onResult(it?.toUri())
    }
    return FileLauncher(res)
}

Desktop 则使用 awt 相关 API 来实现。

然后又发现有些地方用到了 SystemUiCntroller,看了看源码就一个文件,直接复制一份并在 Desktop 上给出空实现。完成

1月12日

今天主要迁移 code-editor 相关模块,这部分代码因为是 Android 强相关,所以暂时就只在 androidMain 里实现。迁移的过程与前几天大同小异,但是难受的是,经过一大堆代码的修改后,App 能成功 build 和跑起来,但是运行到对应部分就直接报错:

java.lang.NoClassDefFoundError: Failed resolution of: xxx

历经了一半天的过程后,我发现这个类是用 Java 写的,抱着怀疑的态度把它转成了 Kotlin,然后就好了!看来有一点需要注意,KMP 项目中的 androidMain 这种文件夹下不能写 Java 代码;但是可以依赖 Java 编写的纯 Android Library module 或者编译后的产物,真是非常神奇而坑人。

1月13日

今天完成了悬浮球相关代码今天做图片翻译的外部跳转,晚上处理图片跳转时,发现一直报错:

java.io.FileNotFoundException: No content provider: file%3A%2F%2F%2Fstorage%2Femulated%2F0%2FAndroid%2Fdata%2Fcom.funny.translation.kmp.debug%2Fcache%2Ftemp_captured_image.jpg

之前程序里没有用 Content Provider,因为只访问私有路径,所以我看上面报错 Content Provider,在经历了一番对比后,尝试加入这个

问了波 ChatGPT,它给了很多莫名其妙的答案,最后参考博客 FileProvider 路径配置策略的理解_file_provider_paths 才理解,然后报错变成了

java.io.FileNotFoundException: No content provider: content%3A%2F%2Fcom.funny.translation.kmp.debug.fileprovider%2Fcache%2Ftemp_captured_image.jpg

我心想这个 Content Provider 提供的路径没问题呀,真是奇了怪了,哪里的问题......后来折腾了许久后,ChatGPT 说:

最后,请确保您正在正确处理生成的 FileProvider URI。在您的错误信息中,URI 是 content%3A%2F%2Fcom.funny.translation.kmp.debug.fileprovider%2Fcache%2Ftemp_captured_image.jpg,这可能是由于URL编码造成的。您在使用 URI 时可能需要对其进行解码,以确保正确的文件路径。

合理啊!感觉是解码的问题。我又看了看全路径,这个 uri 是作为 deepLink 的一部分的,所以被编码过,这里应该解码才对。那为什么原来没问题呢?我猜测是因为原来只是 Android 平台的 Uri,在 Uri.parse 时完成了解码;现在因为涉及到 AndroidUriKMPUri 互转的多次操作,导致最终没有自动解码。加上一句解码,搞定了。。。

kotlin 复制代码
// 先对 uri 解码
val androidImageUri = Uri.decode(imageUri.toString()).toUri()

1月14号

到今天为止,Android 基本已经跑通了,今天就是在做 Desktop 的查缺补漏。

使用时发现,朗读时会报错,查了查是因为 ChatGPT 给我生成的代码用的 javax.sound.sampled.AudioSystem 似乎不支持 MP3 的解码,它建议我转用 javazoom 并给了个错误的依赖(捂脸),在依赖不下来后自己搜了搜,发现应该依赖 implementation("javazoom:jlayer:1.0.1")。再根据 ChatGPT 生成的新代码改一改,能正常朗读了。

另外发现长文翻译中更新 remark 的功能在 Desktop 似乎无效,在经过 Log、断点后发现 sqldelight 生成的代码确实执行了,但是 db 文件并未改变。暂时不解决了,提了个 issue

给 Desktop 修一修各处的小 bug 后,加上了 icon 和 title,基本就这样了。剩下的时间给打包。配置好一些 Task 后发现打 Release 包失败了:

Direct local .aar file dependencies are not supported when building an AAR. The resulting AAR would be broken because the classes and Android resources from any local .aar file dependencies would not be packaged in the resulting AAR. Previous versions of the Android Gradle Plugin produce broken AARs in this case too (despite not throwing this error). The following direct local .aar file dependencies of the :base-kmp project caused this error: D:\projects\kotlin\Transtation-KMP\base-kmp\libs\monet.aar

上网搜了一下,参考 [bug]完美解决Android中Direct local .aar file dependencies are not supported when building an AAR 处理了下,Android 可以正常打包了!

然后就是打包桌面端产物,参考juejin.cn/post/717687...

Failed to check JDK distribution: 'jpackage.exe' is missing JDK distribution path: D:\Program Files\AndroidStudioStable\jbr

发现自带的 jbr 不包含这个,于是指定 JavaHome 为另一个 JDK17 的位置。终于打出了一个 exe,运行后是熟悉的安装程序

kotlin 复制代码
compose.desktop {
    application {
        javaHome = System.getenv("JDK_17")
    }
}

但是安装后的 exe 无法正常运行,也没有报错什么的。。。

目前就是这样,我仍然会持续更新。

总体体验

这是我对 KMP+CMP 的第一次尝试,可以说踩了不少坑。整个过程中,除了上面描述到的,还有一些体验。列举在下面

好的

这是我第一次尝试 Version Catalog,也就是通过 toml 来配置依赖。总的来说还是很好上手的,而且 IDE 有相当不错的支持。在 sync 成功后,点击某一项依赖的具体名称,也会直接跳到对应的 toml 位置;而且 toml 也支持 rename 的操作,会自动更改对应的 build.gradle.kts 引用。你通过原始名称依赖的项目也会弹出提示,帮你改成 toml 的项目

不好的

  • 稍显欠缺的自动补全:在引用 commonMain 中全局定义的 expect class/object 时,会出现自动补全列表没有/无法 alt+Enter 自动导入的情况,这个时候就需要手动 import/写全 了,还是很麻烦的
  • @Preview 注解没法跨平台使用,Desktop 端的 @Preview 是androidx.compose.desktop.ui.tooling.preview.Preview 而 Android 端的是 androidx.compose.ui.tooling.preview.Preview
  • Add missing actual declarations 有 bug,在 H 上 File Name 前面会有个 /,如果不删掉生成的文件位于 D 盘根目录(我的项目在 D 盘),让一度我非常迷惑。所幸在 H-Patch1 上被修复了
AS H AS H-Patch1
  • Layout Inspector 似乎不支持 CMP,无法显示各个组件
  • 在使用 Androi Studio H 时,会出现这个报错

参考 javascript - Kotlin - Multi platform feature - Stack Overflow, 在 build.gradle.kts 中加上

kotlin 复制代码
kotlin {
    compilerOptions {
        freeCompilerArgs.add("-Xmulti-platform")
    }
    
}    

就不报了;

然后升级到了 AS H-Patch 1,又标黄了:

参考上面贴的链接

We didn't make it clear enough that expect/actual declarations are in Beta in pre-1.9.20 versions, because of that a lot of people used it in production code assuming that it's a stable feature. In 1.9.20, we want to fix it by introducing this warning

意思是目前的 expect/actual class 还不是很完美,有时候很复杂,官方希望特别指出这是个 Beta 特性,让使用的人注意一下。好吧。按上面的提示,再加上 -Xexpect-actual-classes 忽略这个警告即可

kotlin 复制代码
kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xmulti-platform", "-Xexpect-actual-classes")
    }
}
  • Expect/Actual 类移动时不会自动同时移动,比如你把 CommonMain 的移动了,剩下的 androidMain 和 DesktopMain 就需要手动再移动

Desktop 端的已知问题

会崩溃的页面

以下页面会崩溃

  • TransProScreen (但购买 AI 点数的页面不受影响,二者共用同一个 Composable,这是为什么?)
  • 对话翻译页面(输入产生回复后)
  • 插件页面

报错为:

java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). ...

我不知道为什么,这些页面在 Android 和 Desktop 共享同一套代码,但在 Android 平台工作正常 ,Desktop 直接崩溃。更奇怪的是,它们的父布局 Column 不可滚动;而且即使我限制了 LazyColumn 的高度(比如 Modifier.height(400.dp)),仍然是崩溃。我怀疑可能是 Compose Desktop 的问题,限于时间关系暂时没有找到解决方案

工作不正常

一些体会

  • 学好英语很重要。在整个过程中就没找到什么中文参考资料,大部分来自 Github 和 Issues,少部分来自 ChatGPT 和其他奇奇怪怪的地方。还算良好的英文阅读能力能帮我至少看得懂。

  • GPT 很能帮忙。这里面有非常多的工作都用到了 GPT。比如原先 DAO 到 Sq 文件的迁移,build.gradle 转 build.gradle.kts,很多类在 Desktop 上的实现。把源代码给它,告诉它迁移到什么平台,它就能帮忙

  • 新技术还是得有耐心,有时很抓狂,也只能静下心来慢慢捣鼓

参考

很多链接文章已经放过,不赘述

最后再复读一遍源代码:github.com/FunnySaltyF... 。如有帮助,欢迎 Star

相关推荐
Python私教1 小时前
Python ORM 框架 SQLModel 快速入门教程
android·java·python
编程乐学2 小时前
基于Android Studio 蜜雪冰城(奶茶饮品点餐)—原创
android·gitee·android studio·大作业·安卓课设·奶茶点餐
problc3 小时前
Android中的引用类型:Weak Reference, Soft Reference, Phantom Reference 和 WeakHashMap
android
IH_LZH3 小时前
Broadcast:Android中实现组件及进程间通信
android·java·android studio·broadcast
去看全世界的云3 小时前
【Android】Handler用法及原理解析
android·java
机器之心4 小时前
o1 带火的 CoT 到底行不行?新论文引发了论战
android·人工智能
机器之心4 小时前
从架构、工艺到能效表现,全面了解 LLM 硬件加速,这篇综述就够了
android·人工智能
AntDreamer4 小时前
在实际开发中,如何根据项目需求调整 RecyclerView 的缓存策略?
android·java·缓存·面试·性能优化·kotlin
运维Z叔5 小时前
云安全 | AWS S3存储桶安全设计缺陷分析
android·网络·网络协议·tcp/ip·安全·云计算·aws
Reese_Cool7 小时前
【C语言二级考试】循环结构设计
android·java·c语言·开发语言