在第一部分中,我描述了基础架构:一个已经使用了 Ktor、Koin 和 SQLDelight 的模块化 Android 代码库,以及一条规则:每一步之后 Android 应用都必须保持可发布状态。结果是,大约 84% 的 Kotlin 代码现在位于 commonMain 中。

这个数字隐藏的是工作分布的不均匀性。有些层的迁移几乎是机械式的,一个下午就能完成,只需要更换插件和调整一些导入。而其他层则花费了数周时间,触及了我多年未看过的代码。

最终的模式很简单,事后看来也很明显:库对多平台的支持程度远不如你自身代码与平台 API 的耦合程度重要。一个库要么支持 KMP,要么不支持,几分钟内就能知道结果。而你自身的平台耦合则分散在数百个文件中,需要数周时间才能逐渐发现。

什么愉快迁移了

Koin

依赖注入是我如果凭记忆写这个列表会低估的部分,它值得更多的认可。Koin 最终成为了阻止平台代码进入共享代码的机制。

每当某个组件需要平台实现时,不再使用 expect/actual,而是让共享代码依赖一个接口,每个平台贡献一个绑定该接口的模块:

// commonMain
expect val commonModule: Module
// androidMain
actual val commonModule: Module = module {
single<NetworkMonitor> { ConnectivityManagerNetworkMonitor(androidContext()) }
…
// iosMain
actual val commonModule: Module = module {
single<NetworkMonitor> { NWPathMonitorNetworkMonitor() }
…
// wasmMain
actual val commonModule: Module = module {
single<NetworkMonitor> { AlwaysOnNetworkMonitor() }
…

expect/actual 适用于小而独立的东西:平台名称、格式化器、UUID 生成器等。一旦平台实现需要自己的依赖项,它就变得尴尬起来。commonMain 中的声明固定了所有目标的构造函数签名,因此需要 Context 的 Android 实现必须通过其他方式获取它,通常是通过平台必须记得调用的初始化步骤。
使用 DI 模块后,共享代码只依赖接口,别无其他。每个平台绑定自己的实现,以及该实现所需的任何依赖项,并且可以放在任何地方。
权衡是真实的:expect/actual 由编译器检查,而 Koin 模块中缺失的绑定则是运行时失败。我两者都用,分界线在于依赖项。如果平台实现需要从它的平台获取某些东西来构造,就通过 DI 进行。
Ktor Client
网络代码没有变化。客户端配置、请求构建器、序列化设置和错误处理都原封不动地移到了 commonMain。唯一与平台相关的部分是引擎:

// commonMain
expect val httpClientModule: Module
val networkModule =
module {
includes(appVersionModule, environmentModule, httpClientModule)
…
…
}
// androidMain
actual val httpClientModule: Module =
module {
single<HttpClient> {
HttpClient(OkHttp)
}
}
// iosMain
actual val httpClientModule: Module =
module {
single<HttpClient> { HttpClient(Darwin) }
}
// wasmJsMain
actual val httpClientModule: Module =
module {
single<HttpClient> { HttpClient(Js) }
}

所有其他内容,包括带配置的客户端工厂,都在 commonMain 中。

SQLDelight

如第一部分所述,这是最容易的一层,因为它从一开始就是为了这次迁移而选择的。模式、查询和生成的代码已经是平台无关的。剩下的只有驱动:

// commonMain
expect val driverModule: Module
// androidModule
actual val driverModule =
module {
single<SqlDriverFactory> { AndroidSqlDriverFactory(androidContext()) }
}
// iosModule
actual val driverModule =
module {
single<SqlDriverFactory> { IosSqlDriverFactory() }
}
// wasmModule
actual val driverModule =
module {
single<SqlDriverFactory> { WasmSqlDriverFactory() }
}

请注意,在 Web 上,WebWorkerDriver 不提供持久化,它只在内存中运行。

安静的泄漏

有一种故障模式只有在模块已经是多平台时才会出现:Android 专属的依赖项不断潜入共享代码,而且没有任何提示,直到另一个目标尝试构建时才暴露出来。

以 android.content.Context 为例。一开始,Composable 中有几个 LocalContext.current 调用。来自 Android 世界的人对此很熟悉。只要目标是 Android,它们就能正常编译和构建。但为任何其他平台编译时,这个错误本应从一开始就很明显。

这就是我在第一部分中提出建议的实际原因:从第一个转换后的模块开始,就在 CI 中构建第二个目标。绿色的 Android 构建并不能证明 commonMain 没有问题。只有第二个目标才能做到。

什么带来了痛苦

不仅仅是 Android。每一个 Java 依赖都会破坏构建。java.time、java.math 这些被认为理所当然的东西,都在需要避免的列表中。

java.time

这是整个迁移中最困难的部分,而且远远超过其他部分。

问题不在于 java.time 没有多平台的等价物。而在于日期在像 BikeHub 这样的应用中无处不在。一个维护追踪应用,本质上是一个关于日期和时间间隔的应用:组件何时安装、自那以后运行了多少、下次保养何时到期。日期存在于领域模型中、数据库映射中、API 层中、UI 格式化中,以及断言以上所有的测试中。

一些让它比机械式查找替换更困难的因素:

基于 java.time 的构造如此自然,以至于我没有费心将它们封装在抽象后面,所以它们遍布各处。影响范围巨大。kotlinx-datetime 拯救了我,但在某些方面它似乎不如 Java 版本那么完善。

FormatStyle。Java 有它。可以是 FULL、LONG、MEDIUM 和 SHORT。并且它是区域敏感的,因此可能产生 MM DD, YYYY 或 YYYY-MM-DD,或者任何其他对每个人来说首选的顺序。我找不到 Kotlin 的等价物,所以我不得不自己实现,它在 Android 上基于 java.time,在 wasm 上基于 Intl.DateTimeFormat,在 iOS 上基于 NSDate。

如果你正在计划 KMP 迁移,现在就运行这个:

grep -rl "java.time" - include=*.kt . | wc -l

你得到的数字是迁移时间中花在日期上的合理预测指标。

java.lang.Math

一个小得多的问题,但很隐蔽:java.lang.Math 在共享代码中不可用,而 kotlin.math 并不能一一对应地映射过去。

文件和流

Android 的文件处理无法移植。ContentResolver 和 InputStream 是 Android 的概念,浏览器有自己的文件处理方式,iOS 则有第三套。这是数据层中每个平台确实需要自己代码的唯一部分。

共享 API 的第一个版本接收字节:平台读取文件,生成 ByteArray,之后的所有操作都是公共的。它能工作,但它是错误的。将整个文件读入内存仅仅为了交给 HTTP 客户端,这种设计只在文件很小的时候才成立。

现在的边界看起来像共享代码中的一个小型类型:

class UploadFile(
val size: Long,
val source: () -> Source, // kotlinx-io
)

一个大小,以及一种打开字节的方式。而不是字节本身。这个签名的两个属性最终比看起来更重要。

lambda 是惰性的。在 HTTP 客户端实际开始写入请求体之前,什么都不会读取,这意味着上传是流式传输而非缓冲。

lambda 可以被多次调用。Ktor 的表单提供者是可重用的,因此重试或重定向可能会再次请求源。每个平台实现都必须能够按需生成新的源,这一单一需求塑造了所有三个平台的实现。

以下是每个平台的处理方式:

Android 在 lambda 内部打开 InputStream 并将其适配为 kotlinx-io 的 Source,因此每次调用都能获得一个新的流。大小来自 ContentResolver 查询。

UploadFile(size = sizeOf(contentUri)) {
context.contentResolver.openInputStream(contentUri)
?.asSource()?.buffered()
?: throw FileNotFoundException(“Cannot open $uri”)
}

iOS 是最短的,因为 kotlinx-io 直接覆盖了它。没有 NSData,没有指针运算,没有内存拷贝:

val path = Path(uri)
val metadata = SystemFileSystem.metadataOrNull(path) ?: error(“Cannot read file at: $uri”)
UploadFile(size = metadata.size) { SystemFileSystem.source(path).buffered() }

Web 仍然是缓冲的,原因值得说明。浏览器的 Blob 可以按切片读取,因此惰性读取应该是可能的。但读取切片是异步的:它返回一个需要等待的 Promise。kotlinx-io 的 RawSource 是同步接口,因此里面没有地方可以挂起。惰性读取和浏览器的文件 API 不兼容。

因此,Web 实现预先将文件分块读入 Buffer,然后分发 peek() —— 一个可以重复读取而不消耗底层内容的视图:

val content = Buffer()
var offset = 0
while (offset < size) {
val end = minOf(offset + ChunkSize, size)
content.write(jsReadSlice(file, offset, end).await<Int8Array>().toByteArray())
offset = end
}
UploadFile(size = content.size) { content.peek() }

本地存储

这是整个迁移的设计教训最清晰体现的地方。

Android 的 SharedPreferences 和浏览器的 localStorage 从远处看很相似:都是用于小值的键值存储。但一旦你将它们用于不仅仅是布尔值的东西,它们在几乎所有重要的方面都不同。

解决方案是使用 Multiplatform Settings 作为存储本身,配合 kotlinx.serialization 处理结构化值。

得分表

层迁移难度备注
Koin DI低接口 + 平台模块
Ktor 网络低只需引擎
SQLDelight低预选为多平台
日期 (java.time)高影响面广,格式化复杂
数学 (java.lang.Math)中低逐个替换
文件/流中每个平台不同
本地存储中使用 Multiplatform Settings

我的收获

审计你自己的代码,而不仅仅是依赖项。​ 一个依赖项的 KMP 支持是几分钟内就能得到的是/否答案。你自己与平台 API 的耦合遍布代码库,这才是真正决定迁移速度的因素。

让 DI 承载平台差异。​ 每一个通过 DI 模块到达的平台实现,都是一个你不需要跨目标维护的 expect/actual 对。

抽象需求,而不是机制。​ 一个按照 Android API 形状设计的接口,在第一个非 Android 目标上就会崩溃。一个按照你的应用实际需求形状设计的接口则不会。


下一篇

第三部分:共享 UI 而不失去安全网。​ 共享应用程序代码比共享其周围的生态系统更容易。

问题找早鸟出海,早鸟出海(NBOVERSEA)深耕出海近十年。专业提供谷歌、苹果APP代上架服务,支持PWA/W2A/H5/APP广告代投,提供Google/Facebook/Tiktok广告账户,谷歌/苹果开发者账户,一站式解决您的后顾之忧。如有任何疑问,请点击服务流程或咨询飞机:@gobird_bot @verygood_appear

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注