3. 共享模块结构设计
在 KMP 工程中,源集(Source Set)是组织代码的基本单位,也是理解整个共享逻辑工程结构的起点。一个典型的 KMP 模块并不是把所有代码都塞进一个目录,而是按照“公共程度”和“平台差异”把代码分层放置。最核心的目录是 commonMain,它承载所有平台都能编译和运行的共享逻辑;与之并列的还有 androidMain、iosMain、jvmMain、jsMain 等平台源集,用于放置平台特有的实现。理解这些源集之间的依赖方向,是设计可维护 KMP 模块的前提。
源集层级与依赖方向
KMP 的源集不是平铺的,而是存在明确的层级关系。以 Android 与 iOS 为例,commonMain 位于最上层,androidMain 和 iosMain 都依赖它;如果 iOS 下还细分了 iosX64Main、iosArm64Main,它们又依赖 iosMain。依赖方向始终是“平台源集 → 公共源集”,反向依赖是不允许的。这意味着 commonMain 中不能直接引用 Android 的 Context 或 iOS 的 NSUserDefaults,否则编译会失败。这条规则看似限制,实则是保证共享逻辑可移植性的关键约束。
expect/actual 机制:声明与实现的分离
当共享逻辑需要调用平台能力时,KMP 提供了 expect/actual 机制。在 commonMain 中用 expect 关键字声明一个函数、类或属性,表示“这里需要一个平台实现”;然后在每个平台源集中用 actual 关键字提供具体实现。编译器会检查每个平台是否都有对应的 actual,缺失则报错。
例如,共享模块需要一个获取当前时间戳的能力,但各平台获取方式不同。在 commonMain 中声明:
// commonMain
expect fun currentTimeMillis(): Long
expect class PlatformLogger() {
fun log(tag: String, message: String)
}
在 androidMain 中实现:
// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual class PlatformLogger actual constructor() {
actual fun log(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
在 iosMain 中实现:
// iosMain
actual fun currentTimeMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000).toLong()
actual class PlatformLogger actual constructor() {
actual fun log(tag: String, message: String) {
NSLog("[$tag] $message")
}
}
这里有几个容易出错的细节。第一,expect 声明不能有函数体,只能是签名;第二,actual 实现的签名必须与 expect 完全一致,包括参数名、返回类型和可见性;第三,如果 expect 声明的是类,actual 类必须用 actual constructor() 匹配构造函数。这些约束由编译器强制检查,一旦不匹配就会在编译期暴露,而不是等到运行时才崩溃。
模块拆分策略:按职责而非按平台
当项目规模增长时,把所有共享代码放在一个模块里会带来两个问题:一是编译时间变长,二是依赖关系变得混乱。合理的做法是按职责拆分模块,而不是按平台拆分。常见的拆分方式包括:core-model(数据模型与接口定义)、core-network(网络请求与序列化)、core-storage(键值存储与数据库抽象)、feature-login(登录业务逻辑)等。每个模块内部仍然遵循 commonMain + 平台源集 的结构,但模块之间的依赖是单向的:feature 模块依赖 core 模块,core 模块之间尽量不互相依赖。
拆分时有一个实用的判断标准:如果一个模块的 commonMain 中出现了大量 expect 声明,说明它承担了过多平台交互职责,可以考虑把平台相关部分单独拆成一个 platform-bridge 模块。反之,如果一个模块几乎没有 expect,说明它是纯逻辑模块,可以放心被多个 feature 复用。
| 模块类型 | 典型内容 | expect 数量 | 依赖方向 |
|---|---|---|---|
| core-model | 数据类、枚举、接口定义 | 极少或无 | 被所有模块依赖 |
| core-network | HTTP 客户端封装、JSON 解析 | 中等(引擎差异) | 依赖 core-model |
| core-storage | 键值存储、数据库抽象 | 较多(文件路径、加密) | 依赖 core-model |
| feature-xxx | 具体业务用例、状态管理 | 少(通过 core 间接使用) | 依赖 core-* |
源集配置的实际写法
在 build.gradle.kts 中,源集通过 kotlin { } 块声明。以下是一个包含 Android、iOS 和 JVM 目标的示例配置:
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
}
val androidMain by getting {
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
// iOS 平台依赖
}
}
val iosArm64Main by getting { dependsOn(iosMain) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain) }
}
}
注意 iosMain 是通过 creating 手动创建的中间源集,它让 iosArm64Main 和 iosSimulatorArm64Main 共享同一份 iOS 实现,避免重复代码。如果使用 Kotlin 1.9.20 及以上版本,默认的层级模板已经自动处理了这种中间源集,通常不需要手动创建。
commonMain 中直接使用 java.* 或 platform.* 包下的类。即使 IDE 不报错,编译到 iOS 或 JS 目标时也会失败。判断方法很简单:如果某个 import 在 Android 和 iOS 上都能用,它才属于 commonMain;否则必须通过 expect/actual 抽象。
总结来说,KMP 共享模块结构设计的核心是三条规则:源集依赖只能从平台指向公共;平台差异通过 expect/actual 收敛到窄接口;模块拆分按职责而非按平台。掌握这三条,就能在项目初期搭出清晰、可扩展的共享逻辑骨架,避免后期因结构混乱而被迫重构。