在第一部分中,我描述了基础架构:一个已经使用了 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