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 共享模块的核心结构。我把它画成了三层架构,方便你理解代码的流向。

KMP 共享模块架构 commonMain(公共层) 数据模型 · 业务逻辑 · 网络接口 · expect 声明 纯 Kotlin 代码,不依赖任何平台 API androidMain actual 实现 Android SDK · SharedPreferences · 原生网络 iosMain actual 实现 iOS SDK · NSUserDefaults · 原生网络 Android App / iOS App / 其他平台

你看,公共层在最上面,平台层在中间,最终输出到各端应用。数据流是单向的——公共层不依赖平台层,平台层实现公共层的接口。这样设计,代码耦合度最低。

一个完整的例子

假设我们要实现一个简单的日志工具。在 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 项目就能跑得稳、改得快、维护轻松。


公众号:蓝海资料掘金营,微信deep3321