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,否则编译会失败。这条规则看似限制,实则是保证共享逻辑可移植性的关键约束。

KMP 源集层级与依赖方向 commonMain 共享业务逻辑 · expect 声明 · 纯 Kotlin 代码 androidMain actual 实现 · Context · SharedPreferences iosMain actual 实现 · NSUserDefaults · Foundation jvmMain actual 实现 · JDBC · java.io iosArm64Main 真机 arm64 iosSimulatorArm64Main 模拟器 arm64 公共源集 Android 平台源集 iOS 平台源集 JVM 平台源集 箭头方向 = 依赖方向:平台源集依赖公共源集,不可反向

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() 匹配构造函数。这些约束由编译器强制检查,一旦不匹配就会在编译期暴露,而不是等到运行时才崩溃。

实践建议:expect/actual 应尽量保持“窄接口”。一个 expect 函数只做一件事,参数和返回值尽量使用 Kotlin 标准类型(如 String、Long、ByteArray),避免把平台特有类型暴露到 commonMain 的签名中。这样平台实现替换成本最低,也最容易编写公共测试。

模块拆分策略:按职责而非按平台

当项目规模增长时,把所有共享代码放在一个模块里会带来两个问题:一是编译时间变长,二是依赖关系变得混乱。合理的做法是按职责拆分模块,而不是按平台拆分。常见的拆分方式包括: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 收敛到窄接口;模块拆分按职责而非按平台。掌握这三条,就能在项目初期搭出清晰、可扩展的共享逻辑骨架,避免后期因结构混乱而被迫重构。