68、Kotlin Multiplatform(KMP):共享模块、expect/actual、平台特定实现
跨平台开发,说白了就是「一次编写,多端运行」。Kotlin Multiplatform(KMP)就是干这个的。我最早接触 KMP 是在一个内部工具项目里,当时要在 iOS 和 Android 上共享一套数据校验逻辑。嗯,那感觉确实不错——不用写两遍正则表达式了。
但 KMP 不是魔法。它不能让你所有代码都 100% 共享。有些东西,比如 UI 渲染、文件读写、网络请求,不同平台有不同实现。这时候就需要 expect/actual 机制出场了。
共享模块是什么?
共享模块(shared module)是 KMP 项目的核心。它存放所有平台通用的代码。你把它编译成 Android Library 和 iOS Framework,两边都能用。
我习惯把共享模块分成三层:
- 公共层(commonMain):纯 Kotlin 代码,所有平台共享。比如数据模型、业务逻辑、网络请求接口。
- 平台层(androidMain / iosMain):各平台特有的实现。比如 Android 用 SharedPreferences,iOS 用 NSUserDefaults。
- 测试层(commonTest):共享的单元测试,一次编写,多端运行。
核心原则:能放 commonMain 的,绝不放平台层。平台层越薄,维护成本越低。
expect/actual 机制
expect/actual 是 KMP 的「声明与实现分离」方案。你在 commonMain 里用 expect 声明一个函数或类,然后在每个平台模块里用 actual 给出具体实现。
举个例子。我需要获取当前设备的平台名称:
// commonMain
expect fun getPlatformName(): String
// androidMain
actual fun getPlatformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
actual fun getPlatformName(): String = "iOS ${UIDevice.currentDevice.systemVersion}"
调用方完全不用关心底层实现。它只知道调用 getPlatformName() 就能拿到结果。这就是抽象的力量。
我的习惯:expect 声明尽量简单,只暴露最少的接口。actual 实现里可以写很多平台细节,但对外不可见。
哪些东西需要 expect/actual?
不是所有东西都需要。我总结了几类常见场景:
| 场景 | 说明 | 示例 |
|---|---|---|
| 系统 API | 获取设备信息、系统语言等 | expect fun getDeviceId(): String |
| 持久化存储 | SharedPreferences vs NSUserDefaults | expect class Settings |
| 网络请求 | Ktor 引擎不同平台不同 | expect fun createHttpClient(): HttpClient |
| 文件操作 | 路径、读写方式不同 | expect fun readFile(path: String): String |
| 加密算法 | 平台原生加密库 | expect fun encrypt(data: ByteArray): ByteArray |
你想想看,如果没有 expect/actual,这些代码就得写两遍,而且很容易出现「Android 改了 iOS 忘改」的情况。我就在项目里踩过这个坑——iOS 端的加密算法升级了,Android 端忘了同步,结果两个平台的数据互相解不开。嗯,从那以后我再也不敢轻视 expect/actual 了。
平台特定实现的最佳实践
写 actual 实现时,有几个要点需要注意:
- 保持接口一致:actual 的签名必须和 expect 完全一致,包括参数名和返回值。
- 不要暴露平台类型:actual 实现里可以用 Android 的
Context或 iOS 的NSObject,但返回值要转成 Kotlin 标准类型。 - 用接口隔离:如果 expect 声明的是一个类,尽量用接口而不是具体类。这样 future 扩展更方便。
注意:expect/actual 不支持默认参数。如果你需要默认值,可以在 commonMain 里写一个包装函数。
知识体系结构图
下面这张图展示了 KMP 共享模块的核心结构。我把它画成了三层架构,方便你理解代码的流向。
你看,公共层在最上面,平台层在中间,最终输出到各端应用。数据流是单向的——公共层不依赖平台层,平台层实现公共层的接口。这样设计,代码耦合度最低。
一个完整的例子
假设我们要实现一个简单的日志工具。在 Android 上用 Logcat,在 iOS 上用 NSLog。看看代码怎么写:
// commonMain
expect class Logger {
fun d(tag: String, message: String)
fun e(tag: String, message: String)
}
// androidMain
actual class Logger {
actual fun d(tag: String, message: String) {
android.util.Log.d(tag, message)
}
actual fun e(tag: String, message: String) {
android.util.Log.e(tag, message)
}
}
// iosMain
actual class Logger {
actual fun d(tag: String, message: String) {
println("[$tag] $message") // iOS 上可以用 NSLog,但 println 更简单
}
actual fun e(tag: String, message: String) {
println("ERROR [$tag] $message")
}
}
调用方只需要 Logger().d("MyApp", "hello"),完全不用管底层是 Logcat 还是 NSLog。这就是 KMP 的魅力。
小技巧:如果某个 actual 实现特别复杂,可以把它拆成多个文件。比如 androidMain 里建一个 LoggerImpl.kt,只放 actual 实现,保持代码整洁。
避坑指南
我曾经在一个项目里犯过一个低级错误:在 commonMain 里直接用了 java.util.Date。编译 Android 没问题,但 iOS 端直接报错。因为 java.util 包在 iOS 上不存在。
所以记住:commonMain 里只能用 Kotlin 标准库和 KMP 兼容的第三方库。比如日期用 kotlinx.datetime,序列化用 kotlinx.serialization。
另一个坑是 expect 类不能有构造函数体。如果你需要初始化逻辑,可以在 actual 类里写,但 expect 类只能声明构造函数签名。
嗯,这些坑踩过一次就记住了。希望你不用再踩一遍。
总结
KMP 的共享模块 + expect/actual 机制,说白了就是「声明一次,实现多次」。它让你在保持平台原生能力的同时,最大化代码复用。我个人觉得,对于业务逻辑、数据模型、网络层这些「与 UI 无关」的部分,KMP 是目前最好的跨平台方案之一。
记住三个要点:
- commonMain 放纯逻辑,越纯越好
- expect 声明要简洁,actual 实现要完整
- 平台层只做「适配」,不做「业务」
做到这三点,你的 KMP 项目就能跑得稳、改得快、维护轻松。