Kotlin多平台开发:KMP的概念、共享代码模块、平台特定代码、KMP项目实战

聊到Kotlin多平台(KMP),我得先坦白一件事。几年前我第一次接触跨平台方案时,心里是有点抵触的。总觉得「一套代码跑多个平台」这种事,要么性能打折,要么平台特性用不上。直到我在一个实际项目中被迫尝试了KMP——嗯,真香了。

KMP不是要你写一套代码到处跑,而是让你尽可能共享业务逻辑,同时保留平台原生能力。说白了,就是把「要共享的」和「不能共享的」分开管理。

核心思想:KMP 允许你在 Kotlin 中编写跨平台共享代码(commonMain),同时通过 expect/actual 机制处理平台特定实现。编译时,Kotlin 会为每个目标平台生成对应的字节码或原生代码。

KMP 的概念与架构

KMP 的架构其实很清晰。你想想看,一个典型的 KMP 项目包含三个部分:

  • 共享模块(commonMain):存放所有平台通用的代码,比如数据模型、网络请求、业务逻辑。
  • 平台模块(androidMain / iosMain 等):存放各平台特有的实现,比如文件读写、UI 渲染、传感器调用。
  • 入口模块:通常是 Android App 模块或 iOS 的 Xcode 项目,依赖共享模块。

我个人习惯把共享模块想象成一个「核心引擎」,平台模块则是「适配器」。引擎负责计算和逻辑,适配器负责和具体平台打交道。

我的经验:刚开始做 KMP 项目时,我总想把所有代码都塞进 commonMain。后来发现,有些平台差异根本绕不开。比如 Android 的 SharedPreferences 和 iOS 的 NSUserDefaults,虽然功能类似,但 API 完全不同。这时候就需要 expect/actual 机制了。

共享代码模块:commonMain 的写法

共享模块的代码写起来和普通 Kotlin 没什么两样。唯一要注意的是,你不能直接调用平台 API。比如下面这个数据模型:

// commonMain 中的共享代码
data class User(
    val id: String,
    val name: String,
    val email: String
)

class UserRepository(private val api: ApiService) {
    suspend fun getUser(id: String): User {
        return api.fetchUser(id)
    }
}

这段代码在 Android 和 iOS 上都能编译通过。为什么?因为它只用了 Kotlin 标准库和协程,没有依赖任何平台框架。

注意:共享模块中不能使用 java.* 或 android.* 包。如果你不小心写了 java.io.File,Android 能编译,但 iOS 会直接报错。我曾经因为这个 bug 排查了整整一下午——嗯,从那以后我再也不敢在 commonMain 里写平台相关代码了。

平台特定代码:expect/actual 机制

有些功能必须依赖平台 API,比如获取当前设备的语言设置。这时候就需要 expect/actual 了。

在 commonMain 中声明一个 expect 函数:

// commonMain
expect fun getPlatformLanguage(): String

然后在 androidMain 中提供 actual 实现:

// androidMain
actual fun getPlatformLanguage(): String {
    return java.util.Locale.getDefault().language
}

在 iosMain 中提供另一个 actual 实现:

// iosMain
actual fun getPlatformLanguage(): String {
    return NSLocale.currentLocale.languageCode ?: "en"
}

你看,逻辑是一样的,但实现方式完全不同。KMP 在编译时会自动选择正确的 actual 实现。

避坑指南:我曾经在 expect 函数上加了 suspend 关键字,结果 iOS 端死活编译不过。后来发现,KMP 对 suspend 函数的 expect/actual 支持有限。建议尽量用普通函数或回调,别在 expect 里用协程——除非你确定所有平台都支持。

KMP 项目实战:一个简单的网络请求示例

我们来做一个实战小项目:一个跨平台的用户信息获取工具。共享模块负责网络请求和数据解析,平台模块负责提供 HTTP 客户端。

首先,在 commonMain 中定义接口和数据模型:

// commonMain
interface HttpClient {
    suspend fun get(url: String): String
}

data class UserResponse(
    val id: Int,
    val name: String,
    val avatar: String
)

class UserService(private val client: HttpClient) {
    suspend fun fetchUser(id: Int): UserResponse {
        val json = client.get("https://api.example.com/users/$id")
        // 这里可以用 kotlinx.serialization 解析 JSON
        return Json.decodeFromString(json)
    }
}

然后,在 androidMain 中实现 HttpClient:

// androidMain
class AndroidHttpClient : HttpClient {
    private val okHttp = OkHttpClient()

    override suspend fun get(url: String): String {
        val request = Request.Builder().url(url).build()
        val response = okHttp.newCall(request).execute()
        return response.body?.string() ?: ""
    }
}

在 iosMain 中实现另一个版本:

// iosMain
class IosHttpClient : HttpClient {
    override suspend fun get(url: String): String {
        val request = NSMutableURLRequest(url = NSURL(string = url))
        val (data, _, error) = NSURLSession.sharedSession.dataTaskWithRequest(request).await()
        if (error != null) throw error
        return NSString.create(data, encoding = NSUTF8StringEncoding) as? String ?: ""
    }
}

最后,在各自的入口模块中注入正确的 HttpClient 实现。这样,业务逻辑完全共享,只有网络请求的底层实现不同。

我的建议:实际项目中,别自己手写 HttpClient。直接用 Ktor 或 KMP 版的 Retrofit,它们已经帮你处理好了 expect/actual 的细节。我早期项目自己封装网络层,结果每个平台都要写一堆样板代码——后来换成 Ktor,代码量直接减半。

KMP 的知识体系结构

下面这张图是我自己整理的 KMP 核心知识脉络,你可以对照着理解:

KMP 项目结构 commonMain 业务逻辑、数据模型 平台模块 androidMain / iosMain expect 声明 actual 实现 编译时自动选择 actual 实现 Android → androidMain iOS → iosMain

总结一下

KMP 的核心就三件事:

  • 共享代码:把业务逻辑、数据模型、网络请求放在 commonMain 中。
  • 平台适配:用 expect/actual 处理平台差异,比如文件读写、传感器、UI 渲染。
  • 依赖注入:在入口模块中注入正确的平台实现。

我个人觉得,KMP 最大的价值不是「写一次跑所有平台」,而是「让团队可以专注在业务逻辑上,不用为每个平台重复造轮子」。当然,它也有局限性——比如 UI 层目前还是各写各的(Compose Multiplatform 正在解决这个问题)。但如果你做的是数据驱动的应用,KMP 绝对值得一试。

最后提醒一句:别在 KMP 项目里用反射。Kotlin/Native 对反射的支持非常有限。我有个同事在 iOS 上用了 Gson(依赖反射),结果运行时直接崩溃。换成 kotlinx.serialization 就好了——它用的是编译期代码生成,不依赖反射。


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