3. KMP项目结构解析

KMP 工程最容易让人“迷路”的地方,不是 Kotlin 语法,而是目录与 Gradle 脚本之间的对应关系:哪个目录会被编译进哪个平台、依赖应该写在哪一层、为什么 commonMain 里不能直接调用 Android 的 Context。理解这套结构,等于拿到了跨端工程的“地图”。本节以典型的 KMP 工程为例,逐层拆解 settings.gradle.kts、根 build.gradle.kts、模块 build.gradle.kts 以及各 sourceSet 目录的职责与依赖声明方式。

一、settings.gradle.kts:工程边界与模块清单

settings.gradle.kts 是 Gradle 构建的入口,它决定“这次构建包含哪些工程”。在 KMP 中它通常承担三件事:声明插件仓库与依赖仓库、定义工程名、通过 include 引入子模块。一个常见的写法如下:

// settings.gradle.kts
pluginManagement {
    repositories {
        google()
        gradlePluginPortal()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "KmpDemo"

include(":shared")
include(":androidApp")
include(":desktopApp")
include(":webApp")

这里的关键点是:shared 是承载跨端公共逻辑的 KMP 模块,而 androidApp、desktopApp、webApp 是各平台的应用壳工程。iOS 端通常不通过 Gradle 的 include 引入,而是由 Xcode 工程通过 Gradle 生成的 framework 或 XCFramework 来消费 shared,因此你会在 settings 中看不到 iOS 应用模块,这是正常现象。若把仓库配置写在 settings 的 dependencyResolutionManagement 中,子模块就无需重复声明仓库,能避免多模块仓库配置不一致的问题。

二、根 build.gradle.kts:插件版本与全局约定

根 build.gradle.kts 一般不写具体业务依赖,而是用 plugins 块配合 apply false 统一声明插件版本,供子模块按需启用:

// 根 build.gradle.kts
plugins {
    kotlin("multiplatform") version "2.0.20" apply false
    kotlin("plugin.serialization") version "2.0.20" apply false
    id("com.android.library") version "8.5.2" apply false
    id("com.android.application") version "8.5.2" apply false
    id("org.jetbrains.compose") version "1.7.0" apply false
}

这样做的好处是版本集中管理:子模块只写 kotlin("multiplatform") 而不带版本号,升级时只改根文件一处。若项目使用版本目录,还会出现 gradle/libs.versions.toml,把版本号与库坐标抽成别名,根脚本通过 alias(libs.plugins.kotlinMultiplatform) 引用,进一步降低重复。

三、模块 build.gradle.kts:target 与 sourceSet 的声明中心

真正定义“支持哪些平台、每个平台有哪些源集、各自依赖什么”的,是 shared/build.gradle.kts。它的骨架由 kotlin { } 块构成,内部先声明 target,再配置 sourceSets:

// shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    kotlin("plugin.serialization")
    id("com.android.library")
}

kotlin {
    androidTarget()
    jvm("desktop")
    iosX64(); iosArm64(); iosSimulatorArm64()
    js(IR) { browser() }

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
                implementation("io.ktor:ktor-client-core:2.3.12")
            }
        }
        val commonTest by getting {
            dependencies { implementation(kotlin("test")) }
        }
        val androidMain by getting {
            dependencies { implementation("io.ktor:ktor-client-okhttp:2.3.12") }
        }
        val iosMain by creating {
            dependsOn(commonMain)
            dependencies { implementation("io.ktor:ktor-client-darwin:2.3.12") }
        }
        val desktopMain by getting {
            dependencies { implementation("io.ktor:ktor-client-cio:2.3.12") }
        }
        val jsMain by getting {
            dependencies { implementation("io.ktor:ktor-client-js:2.3.12") }
        }
    }
}

这段配置体现了 KMP 依赖声明的核心原则:公共依赖放 commonMain,平台专属依赖放对应平台源集。例如 Ktor 的引擎必须按平台选择——Android 用 OkHttp、iOS 用 Darwin、JVM 桌面用 CIO、JS 用 JS 引擎,而 ktor-client-core 这类纯公共 API 则放在 commonMain。若把 OkHttp 写进 commonMain,编译期就会因 Android 专属 API 无法在其他平台解析而失败。

当多个 iOS target(iosX64、iosArm64、iosSimulatorArm64)共享代码时,KMP 默认提供 iosMain 中间源集,它会自动 dependsOn(commonMain),无需手动创建。若手动 creating 一个 iosMain,务必显式声明 dependsOn(commonMain),否则公共代码不可见。

四、sourceSets 目录职责与可见性规则

每个 sourceSet 对应磁盘上一个同名目录,默认位于 src/<sourceSetName>/kotlin。它们的职责边界如下:

  • commonMain:存放所有平台共享的业务逻辑、数据模型、接口定义、网络请求封装等。只能使用 Kotlin 标准库与跨平台库,不能引用 android.*、platform.Foundation.* 等平台 API。
  • androidMain:Android 专属实现,可访问 Context、SharedPreferences、OkHttp 等。通常用于实现 commonMain 中定义的 expect 声明。
  • iosMain:iOS 专属实现,可调用 Foundation、UIKit 等,通过 cinterop 或 Kotlin/Native 提供的平台库访问。
  • jvmMain(示例中命名为 desktopMain):JVM 桌面端实现,可使用 Java 标准库、Swing/Compose Desktop 等。
  • jsMain:Kotlin/JS 实现,可访问浏览器 DOM API 或 Node.js API。

可见性方向是单向的:平台源集可以看到 commonMain 的内容,反之不行。因此当公共逻辑需要平台能力时,使用 expect/actual 机制:在 commonMain 写 expect fun platformName(): String,在各平台源集写 actual fun platformName(): String。这样 commonMain 只依赖抽象声明,具体实现由平台源集提供,编译时按 target 匹配。

KMP 模块 sourceSet 结构与依赖可见性 commonMain 共享业务逻辑 / 数据模型 / expect 声明 coroutines · serialization · ktor-client-core androidMain Context / OkHttp actual 实现 iosMain Foundation / Darwin actual 实现 jvmMain / desktopMain Java 标准库 / CIO actual 实现 jsMain DOM / Node.js actual 实现 平台源集可读取 commonMain(单向可见) expect / actual 协作机制 commonMain: expect fun platformName(): String androidMain / iosMain / jvmMain / jsMain: actual fun platformName(): String 编译时按 target 匹配,未提供 actual 会报错 依赖声明 公共库 → commonMain 平台库 → 对应源集 目录位置 src/ commonMain/ androidMain/ iosMain/ ...

五、依赖声明中的常见陷阱与判断依据

实际配置时,最容易出问题的是“依赖放错层”。判断方法很简单:问自己“这个库是否在所有目标平台都有对应产物”。如果只有 Android 版本,就必须放 androidMain;如果所有平台都有(如 kotlinx-coroutines-core),才放 commonMain。另一个常见问题是 implementation 与 api 的选择:若某个依赖的类型出现在公共 API 签名中(例如 commonMain 中公开函数返回 HttpClient),应使用 api,否则下游模块编译时找不到该类型;若仅内部使用,用 implementation 即可,能减少传递依赖暴露。

此外,Android target 需要额外配置 android { } 块,指定 namespace、compileSdk、minSdk 等;iOS target 若需要生成 framework,还要在 kotlin { } 中配置 binaries.framework { baseName = "shared"; isStatic = true },Xcode 才能正确链接。这些配置与 sourceSet 共同构成 KMP 模块的完整声明。

不要把所有依赖都堆在 commonMain 试图“省事”。一旦某个平台缺少对应产物,Gradle 同步阶段就会报 Could not resolve,且错误信息往往指向 commonMain,容易误导排查方向。正确做法是先确认库的多平台支持矩阵,再决定放置层级。