26、Kotlin Multiplatform:KMP项目结构、Gradle配置、平台源码集、依赖管理

Kotlin Multiplatform(简称 KMP)是 JetBrains 推出的一套跨平台方案。说实话,它跟 Flutter、React Native 不太一样。KMP 不是要你写一套 UI 跑所有平台,而是让你共享业务逻辑层——网络请求、数据校验、状态管理这些。UI 层你该怎么写还怎么写,用原生控件。

我个人觉得,KMP 最大的价值在于:你可以在不牺牲原生体验的前提下,把那些「脏活累活」统一掉。我在项目中遇到过好几次,iOS 和 Android 两边的网络层各写各的,接口字段解析不一致,bug 还互相传染。用 KMP 之后,这种问题基本绝迹了。

KMP 项目结构长什么样?

一个典型的 KMP 项目,目录结构大概是这样的:

MyKmpProject/
├── shared/
│   ├── src/
│   │   ├── commonMain/       // 共享代码
│   │   ├── androidMain/      // Android 平台实现
│   │   └── iosMain/          // iOS 平台实现
│   └── build.gradle.kts
├── androidApp/
│   └── src/
├── iosApp/
│   └── (Xcode 项目)
└── build.gradle.kts

这里最核心的就是 shared 模块。它下面有三个源码集:

  • commonMain:放所有平台通用的代码。比如数据模型、业务逻辑、接口定义。
  • androidMain:放 Android 特有的实现。比如用 OkHttp 做网络请求。
  • iosMain:放 iOS 特有的实现。比如用 NSURLSession 做网络请求。

你想想看,这种结构天然就逼着你做「面向接口编程」。你在 commonMain 里定义接口,然后在各平台源码集里提供实现。代码干净,职责清晰。

核心原则:commonMain 里不要出现任何平台相关的 API。一旦你写了 android.os.Build 或者 UIKit 的东西,编译直接报错。

Gradle 配置:怎么把 KMP 跑起来?

配置 KMP 的 Gradle 脚本,说简单也简单,说坑也多。我直接给你一个能用的模板,咱们一行一行拆开看。

首先,在根项目的 build.gradle.kts 里声明插件:

plugins {
    kotlin("multiplatform") version "1.9.22" apply false
    id("com.android.library") version "8.2.0" apply false
}

然后在 shared/build.gradle.kts 里应用插件并配置目标:

plugins {
    kotlin("multiplatform")
    id("com.android.library")
}

kotlin {
    // 配置 Android 目标
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "17"
            }
        }
    }

    // 配置 iOS 目标
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach {
        it.binaries.framework {
            baseName = "shared"
            isStatic = true
        }
    }

    // 源码集配置
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
            }
        }
        val androidMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-okhttp:2.3.7")
            }
        }
        val iosX64Main by getting
        val iosArm64Main by getting
        val iosSimulatorArm64Main by getting
        val iosMain by creating {
            dependsOn(commonMain)
            iosX64Main.dependsOn(this)
            iosArm64Main.dependsOn(this)
            iosSimulatorArm64Main.dependsOn(this)
            dependencies {
                implementation("io.ktor:ktor-client-darwin:2.3.7")
            }
        }
    }
}

android {
    namespace = "com.example.shared"
    compileSdk = 34
    defaultConfig {
        minSdk = 24
    }
}

嗯,这里要注意几个点:

  • iOS 目标要写全:x64(模拟器)、arm64(真机)、simulatorArm64(Apple Silicon 模拟器)。少一个,你打包的时候可能就找不到 framework。
  • iosMain 是手动创建的:KMP 默认不给你生成 iOS 的公共源码集。你得自己建一个 iosMain,然后让三个 iOS 子目标都依赖它。这样你只需要写一份 iOS 平台代码。
  • 依赖要分平台:commonMain 里用 ktor-client-core,androidMain 里用 ktor-client-okhttp,iosMain 里用 ktor-client-darwin。这是 Ktor 的设计哲学——核心库只定义接口,平台库提供实现。

我曾经踩过一个坑:在 iosMain 里忘了加 dependsOn(commonMain),结果 iOS 那边死活找不到 commonMain 里定义的接口。编译报错信息还特别隐晦,查了半天才发现是源码集依赖没配好。

平台源码集:怎么写平台特定代码?

源码集的核心机制是 expectactual 关键字。你在 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() 就行。这就是 KMP 的优雅之处。

不过要注意,expect/actual 不是万能的。有些场景下,你更适合用「接口 + 平台工厂」的模式。比如网络客户端:

// commonMain
interface HttpClientFactory {
    fun create(): HttpClient
}

expect fun createHttpClientFactory(): HttpClientFactory

// androidMain
actual fun createHttpClientFactory(): HttpClientFactory = AndroidHttpClientFactory()

// iosMain
actual fun createHttpClientFactory(): HttpClientFactory = IosHttpClientFactory()

我个人习惯是:能用 expect/actual 就用,太复杂了再考虑接口模式。expect/actual 的编译期检查更严格,不容易漏实现。

依赖管理:怎么处理第三方库?

KMP 的依赖管理,说白了就是「分平台声明」。同一个库,不同平台可能要用不同的 artifact。

常见的做法是:

  • 纯 Kotlin 库(比如 kotlinx-coroutines、kotlinx-serialization):直接放在 commonMain 里,所有平台通用。
  • 平台相关库(比如 Ktor 的 engine、SQLDelight 的 driver):分别放在 androidMain 和 iosMain 里。
  • Android 专属库(比如 Room、OkHttp):只放在 androidMain 里。
  • iOS 专属库(比如 Alamofire):一般通过 CocoaPods 或 Swift Package Manager 集成,KMP 这边用 cinterop 调用。

这里我建议你用一个表格来梳理依赖关系,方便团队对齐:

库名 commonMain androidMain iosMain
kotlinx-coroutines-core
kotlinx-serialization-json
ktor-client-core
ktor-client-okhttp
ktor-client-darwin

另外,如果你用 Ktor 做网络请求,记得在 commonMain 里只依赖 ktor-client-core,不要直接依赖 engine。engine 的选择是平台源码集的事,业务层不应该知道。

一个小技巧:在 commonMain 里写一个 expect fun httpClient(): HttpClient,然后在各平台返回配置好的 client。这样业务层调用的时候,根本不知道底层用的是 OkHttp 还是 Darwin,耦合度降到最低。

KMP 核心知识体系

下面这张图帮你梳理了 KMP 的整体结构。你可以看到,commonMain 是中心,各平台源码集围绕它提供实现。依赖也分两层:通用依赖在中心,平台依赖在外围。

commonMain 业务逻辑 / 接口定义 androidMain OkHttp / Room iosMain Darwin / NSURLSession 依赖管理 commonMain: kotlinx-coroutines / ktor-core androidMain: ktor-okhttp | iosMain: ktor-darwin

这张图其实就概括了 KMP 的全部核心:一个中心(commonMain),两个基本点(androidMain、iosMain),外加一套依赖管理规则。你把这个结构记熟了,KMP 就算入门了。

避坑指南:我踩过的几个坑

最后分享几个实战中容易翻车的地方:

  • iOS 模拟器和真机的 framework 不通用:你编译的时候要指定架构。我建议在 Gradle 里用 listOf(iosX64(), iosArm64(), iosSimulatorArm64()) 一次性全配好,省得来回切。
  • Ktor 的 engine 别漏了:commonMain 里只加 core,平台源码集里一定要加对应的 engine。漏了的话,运行时会报 NoTransformationException,而且堆栈信息很不友好。
  • 序列化注解要加在 commonMain 里@Serializable 必须写在 commonMain 的数据类上。如果你写在 androidMain 里,iOS 那边根本看不到。
  • iOS 端的 framework 名字要跟 Xcode 里一致baseName = "shared" 这个值,Xcode 引入的时候要用。改名字的话两边都要同步,不然链接失败。

嗯,KMP 这块内容其实不少,但核心就是「分平台、分源码集、分依赖」。你只要把这三个「分」字理解透了,剩下的就是熟练度的问题了。


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