Administrator
发布于 2026-08-23 / 79 阅读
0
0

KMP 工程脚手架与目录约定:搭一个能撑过两年迭代的跨端工程

系列文章《Kotlin Multiplatform 进阶实战》· 第 1 篇 / 共 6 篇

一、开篇:为什么你的 KMP 工程半年后就改不动了

过去一年我在多个团队的 KMP 项目里观察到同一个现象:项目伊始都信心满满,半年后再没人愿意新增 commonMain 模块。追根溯源,几乎所有"烂尾"项目的共同病灶,不是 Kotlin 语言,不是个别库,而是工程脚手架——具体说就是 Gradle 配置混乱、目录约定缺失、模块边界模糊、commonMain 与平台源码相互侵蚀。

KMP 不是"装上插件就能用"的银弹。它把"代码如何组织"这件事比纯 Android 项目提早两个抽象层级摆在你面前:模块、源集(source set)、目标(target)、变体(variant)、中间表示(KLib)。如果脚手架不先把这些问题钉死,业务代码就会反噬构建配置,二三个月后 build.gradle.kts 变成粪山。

这一篇把过去几年沉淀下来的"能撑两年"的工程骨架完整给出,所有规则都基于真实改造经验,不是 Hello World 演示。

二、整体目录结构:一个被验证的 7 层骨架

下面这套结构在我们 6 个 KMP 项目里沿用并迭代过,是经过压力测试的形态:

project-root/
├── build-logic/                // ① 约定插件仓库(convention plugins)
│   └── src/main/kotlin/
│       ├── KmpAndroidLibraryConventionPlugin.kt
│       ├── KmpIosLibraryConventionPlugin.kt
│       └── KmpFeatureConventionPlugin.kt
├── gradle/
│   └── libs.versions.toml// ② 版本目录(version catalog)
├── core/
│   ├── common/                 // ③ 纯 commonMain 核心库(无平台依赖)
│   ├── platform/               // ④ 平台抽象层(expect 在这里)
│   └── network/                // ⑤ 共享网络/存储/序列化层
├── features/
│   ├── home/
│   │   ├── src/commonMain/
│   │   ├── src/androidMain/
│   │   └── src/iosMain/
│   └── detail/ ...
├── iosApp/                     // ⑥ iOS 应用壳工程(Xcode workspace)
├── androidApp/                 // ⑦ Android 应用壳工程
└── settings.gradle.kts

这 7 层各自承担唯一职责,下文逐一说清楚为什么这样分。

2.1 为什么把 common 与 platform 拆开

很多教程喜欢把"业务域核心 + 平台抽象"塞在同一个 commonMain 模块。初期爽,业务一来就崩——core-common 会被反复穿插 expect/actual,导致纯逻辑代码与平台桥混在一起,最后变成谁都不敢动的雷区。

我们强制这样切分:

模块

允许依赖

禁止依赖

示例内容

core/common

kotlin-stdlib、coroutines-core、serialization

任何 expect、任何平台 API

业务模型、用例、状态机

core/platform

core/common

Android、iOS Framework

expect 接口、Time/File/HTTP 抽象

core/network

core/common

、core/platform、Ktor

Android、iOS Framework

Ktor Client 配置、拦截器、序列化

这套规则让 core/common 保持 100% 可在 JVM 上单测,无需起 Android/iOS 模拟器,单元测试覆盖率能稳定在 85%+。

2.2 features 下放业务模块

每个特性都是独立 Gradle 子项目,至少包含 commonMain + 当前需要的平台源集。这样 CI 矩阵中"只跑变化的特性"才能真正做到。一行配置决定你能上多大的工程团队:

// features/home/build.gradle.kts
plugins {
    id("kmp.feature.convention")  // 由 build-logic 暴露
}

kmp.feature.convention 插件帮我们做了 7 件事:应用 KMP 插件、引入 kotlinx-coroutines、kotlinx-serialization、core-platform 依赖、注册 commonTest、打开 withJava()、显式指定 jvmTarget=17。这个约定先行于配置的设计,是后面所有 build 速度优化的前提。

三、版本目录(libs.versions.toml):消灭版本漂移

跨平台项目依赖来源分散:Compose Multiplatform、Compose Android、Compose iOS、kotlinx-coroutines、kotlinx-serialization、Ktor,每条都可能有自己的版本兼容范围。我们把所有版本集中到 libs.versions.toml:

[versions]
kotlin = "2.0.21"
agp = "8.7.2"
coroutines = "1.9.0"
serialization = "1.7.3"
ktor = "3.0.1"
compose-multiplatform = "1.7.0"
sqlDelight = "2.0.2"

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.cocoapods", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }

注意 ktor-client-okhttp 与 ktor-client-darwin 引用同一个 version,因为 Ktor 引擎是版本强绑定的——不锁定就会出现"OKHttp 引擎 3.0、Darwin 引擎 2.3"这种诡异组合。

代码里一律这样使用:

// 禁止:implementation("io.ktor:ktor-client-core:3.0.1")
// 推荐:
implementation(libs.ktor.client.core)

libs 由 Gradle 自动生成,任何直接写版本字符串的 PR 都会被 CI 拒绝——这条规则写进了 CODEOWNERS 强制。

四、build-logic:约定插件,把样板代码集中到一处

每个子模块原本要写 100~200 行 build.gradle.kts。一旦改一个 Gradle 任务(比如全项目切换到 JVM 17),你得改 50 个文件。约定插件(convention plugin)是 KMP 大型工程唯一的出路。

build-logic/ 是一个独立的 Gradle 复合构建(composite build),它提供三套插件,对应三种子工程类型:

// KmpAndroidLibraryConventionPlugin.kt
classKmpAndroidLibraryConventionPlugin : Plugin<Project> {
overridefunapply(target: Project) = with(target) {
        with(pluginManager) {
            apply("com.android.library")
            apply("org.jetbrains.kotlin.multiplatform")
        }
        extensions.configure<KotlinMultiplatformExtension> {
            targetHierarchy.default()
            jvmToolchain(17)
            applyDefaultHierarchyTemplate()
        }
        dependencies {
            add("commonMainImplementation", libs.findLibrary("kotlinx-coroutines-core").get())
        }
    }
}
// KmpFeatureConventionPlugin.kt:业务特性模块专用
classKmpFeatureConventionPlugin : Plugin<Project> {
overridefunapply(target: Project) = with(target) {
        with(pluginManager) {
            apply("kmp.android.library.convention")
        }
        extensions.configure<KotlinMultiplatformExtension> {
            applies("compose-multiplatform") // 暴露 UI 共享能力
            targets.named("androidLibrary") {
                compilations.named("main") {
// 业务模块默认 enable 严格空安全
                    kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict"
                }
            }
        }
    }
}

applies("compose-multiplatform") 不是手误——它来自 Compose Multiplatform 1.6+,仅作用于应用工程。我们故意写在 feature 层而非 platform 层,避免 platform 误带 UI 依赖。

这种约定的威力在迁移时完全显现:2024 年我们把 Kotlin 1.9.25 升到 2.0.21 全项目一共改 3 个文件,AGP 8.5 升 8.7.2 一共改 2 个文件,全部在 build-logic 完成。

五、源集(Source Set)分层:让"边界"长在工程结构里

commonMain 仍然只是一个目录。真正决定编译行为的是源集层级。我们固定用 4 层源集:

层级

路径

角色

可被谁依赖

Platform

src/<target>Main/kotlin

平台原生实现

仅 commonMain 同模块 expect

Common

src/commonMain/kotlin

跨端共享业务

Android、iOS 源集

Android

src/androidMain/kotlin

Android-only 桥接

Android 子工程

iOS

src/iosMain/kotlin

iOS-only 桥接

iOS 子工程

层级之间不允许互相跨层引用。platform 层在 KMP 官方语义里其实没有这种概念,这是我们项目里人为添加的隐喻,目的是让新成员一眼能看清"这个文件属于跨平台、属于平台桥、还是属于平台原生"。

我们还用一个自动检测的 Lint 任务守住规则:

// 在 build-logic 加一个 checkTask,禁止 platform/ 目录被 features/ 直接引用
tasks.register("checkModuleBoundary") {
    dependsOn("dependencies")
    doLast {
val violations = mutableListOf<String>()
        rootProject.subprojects.forEach { sub ->
            sub.configurations.findByName("commonMainImplementation")?.dependencies?.forEach { dep ->
if (dep.name.contains("platform") && sub.path.startsWith(":features:")) {
                    violations += "${sub.path} 非法依赖 ${dep.name}"
                }
            }
        }
if (violations.isNotEmpty()) throw GradleException(violations.joinToString("\n"))
    }
}

六、iOS 工程集成:用 CocoaPods 而非手动 framework

iOS 侧集成是新手最容易踩坑的地方。两种主流方式:

方式

优点

缺点

适用场景

手动 build framework + Xcode

启动快、调试可断点

需要脚本同步、CI 配置复杂

团队懂 Xcode 工程

CocoaPods 集成

Gradle 配置即配即用、依赖透明

Xcode 启动慢 30 秒、首次编译久

90% 团队首选

我们强制走 CocoaPods。关键配置:

// core/platform/build.gradle.kts
plugins {
    id("kmp.android.library.convention")
    kotlin("plugin.cocoapods")
}

kotlin {
    cocoapods {
        summary = "Shared KMP module"
        version = "1.0.0"
        ios.deploymentTarget = "15.0"
        framework {
            baseName = "SharedPlatform"
            isStatic = true// 静态库,避免与宿主 App 重复符号
        }
        pod("SQLite", linkOnly = true)  // 系统库不打包
    }
}

isStatic = true 是血泪经验。动态库会让宿主 App 的 Launch Time 多 80~150ms,并且会和宿主已有的 dynamic framework 冲突。

iOS 侧 Podfile:

target 'iOSApp'do
  pod 'SharedPlatform', :path => '../core/platform'
  pod 'SharedNetwork', :path => '../core/network'
  pod 'HomeFeature', :path => '../features/home'
end

三个 pod 各自嵌入,保证 Pod 缓存粒度到模块。

七、可观测性:从脚手架期就埋点

脚手架阶段最容易遗漏的一步:构建可观测性。半年后没人知道 core-network 编译花了 1 分 20 秒,没人知道 iosArm64 编译耗时占总时长 38%。

我们在根 build.gradle.kts 加一个 Build Analyzer:

plugins {
    id("build-time-tracker")
}

gradle.projectsLoaded {
    rootProject.allprojects {
        tasks.configureEach {
if (name.startsWith("compile") || name.contains("Test")) {
// 给关键任务加计时器
            }
        }
    }
}

然后把数据上报到公司 build-scan。两年后,这个埋点至少节省了 2 个工程师的"为什么构建慢了"事故。

八、避坑清单(实战总结)

下面这些坑每一个都至少让我们烧过半天以上时间,新项目务必绕开:

  1. commonMain 别用 System.currentTimeMillis()

    :它跑在 JVM target 上时返回 0 epoch,要用 kotlin.time.Clock.System.now().toEpochMilliseconds()。

  2. iOS 端别暴露 suspend fun 给 OC 调用

    :Objective-C 不认识协程,必须用 Flow + 回调桥接。

  3. SQLDelight 的 .sq 文件要放在 commonMain/sqldelight

    :放在 androidMain 会导致 iOS 编译失败。

  4. Compose Multiplatform 1.6 前不要共享 UI

    :1.6 之前 iOS 侧稳定性极差,1.7+ 才算能用。

  5. 别在 iOS 单元测试里引用 Ktor engine

    :单元测试跑在 host 而不是模拟器,Darwin 引擎找不到。统一在 iOSTest 源集注入 mock 引擎。

九、下篇预告

脚手架搭好,最关键的问题来了:业务逻辑写到哪一层为止、平台代码从哪一层介入。下篇会系统讲透 expect/actual——这一被吹爆又被滥用、被低估又被误解的跨端抽象机制。我们会给出 5 类真实平台差异的标准答案:时间、文件、网络、并发、原生能力,请关注。


参考阅读

  • Kotlin 官方 KMP 工程结构指引:https://kotlinlang.org/docs/multiplatform-discover-project.html

  • JetBrains applyDefaultHierarchyTemplate 文档

  • Gradle Version Catalogs 规范(1.0+ 稳定)

  • 实战项目参考:nowinandroid、KaMP Kit、Touchlab KMP Starter


评论