2. KMP工程结构与数据层模块划分
在 KMP(Kotlin Multiplatform)工程中,真正决定“哪些代码能共享、哪些必须各写各的”的,不是语言本身,而是源集(Source Set)的目录结构与依赖关系。很多团队第一次搭建 KMP 项目时,会把网络与数据层代码随手放进 commonMain,结果一编译就报错——因为里面用到了 java.net.HttpURLConnection 或 NSURLSession 这类平台专属 API。要避免这种返工,必须先理解 KMP 的源集层级模型,再据此规划网络与数据层的落位。
一、源集层级:commonMain 是“契约层”,平台源集是“实现层”
KMP 的源集不是平铺的,而是一棵有继承关系的树。以典型的 Android + iOS 双端工程为例,其核心结构如下:
shared/
├── src/
│ ├── commonMain/kotlin/ // 公共契约与纯逻辑
│ ├── commonTest/kotlin/ // 公共测试
│ ├── androidMain/kotlin/ // Android 平台实现
│ ├── iosMain/kotlin/ // iOS 平台实现
│ └── nativeMain/kotlin/ // 可选:iOS 与桌面等 Native 共享
└── build.gradle.kts
关键规则是:子源集可以访问父源集的声明,反之不行。androidMain 和 iosMain 都继承自 commonMain,因此它们能看到 commonMain 中定义的接口、数据类和 expect 声明;而 commonMain 完全不知道 androidMain 里有什么。这条单向可见性,正是跨平台共享策略的物理基础。
由此可以推导出网络与数据层的划分原则:凡是“描述做什么”的代码放 commonMain,凡是“具体怎么做”的代码放平台源集。例如,定义 interface UserApi { suspend fun fetchUser(id: String): User } 属于契约,放 commonMain;而用 OkHttp 还是 Ktor 引擎去发这个请求,属于实现,放 androidMain / iosMain。
二、网络层:expect/actual 是跨平台共享的核心机制
网络层最典型的跨平台需求是“同一套请求逻辑,不同平台用不同引擎”。KMP 用 expect/actual 解决这个问题。在 commonMain 中声明期望:
// commonMain
expect fun createHttpClient(): HttpClient
class UserApi(private val client: HttpClient = createHttpClient()) {
suspend fun fetchUser(id: String): User =
client.get("https://api.example.com/users/$id").body()
}
然后在各平台源集提供实际实现:
// androidMain
actual fun createHttpClient(): HttpClient =
HttpClient(OkHttp) { install(ContentNegotiation) { json() } }
// iosMain
actual fun createHttpClient(): HttpClient =
HttpClient(Darwin) { install(ContentNegotiation) { json() } }
这样 UserApi 只写一次,两个平台各自注入合适的引擎。注意 UserApi 本身放在 commonMain,因为它只依赖 HttpClient 这个抽象类型,不依赖任何平台 API。判断一段网络代码能否放 commonMain,标准就是:它是否只引用了 commonMain 可见的类型。
三、数据层:Repository 放公共,DAO 与存储放平台
数据层的划分比网络层更细。通常采用“Repository 在 commonMain,数据源在平台源集”的策略:
- commonMain:
UserRepository接口、缓存过期策略、DTO 到领域模型的映射函数、Flow数据流编排逻辑。 - androidMain:Room 的
@Dao、@Database,DataStore 的Preferences实例。 - iosMain:SQLDelight 的
DatabaseDriver、NSUserDefaults封装。
如果两端都想用 SQLDelight,则可以把 SQLDelight 的 .sq 文件和生成的数据库代码统一放在 commonMain,只在平台源集提供 DatabaseDriver 的 actual 实现。这是“共享最大化”的典型做法:能放 common 的尽量放 common,只有真正平台绑定的部分才下沉。
四、依赖配置:源集依赖决定可见范围
目录结构只是物理位置,真正让源集“看见”库的是 build.gradle.kts 中的依赖声明。常见配置如下:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.7")
implementation("androidx.room:room-runtime:2.6.1")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.7")
implementation("app.cash.sqldelight:native-driver:2.0.1")
}
}
}
这里有一个容易踩的坑:commonMain 中只能声明所有目标平台都支持的依赖。如果把 ktor-client-okhttp 写进 commonMain,iOS 编译会直接失败,因为 OkHttp 是 JVM 专属库。因此引擎类依赖必须严格放在对应平台源集。
五、共享策略的取舍:不是共享越多越好
实践中常见两种极端:一是把所有代码都塞进 commonMain,导致大量 expect/actual 和平台判断,可读性极差;二是只共享数据模型,网络和存储各写各的,共享收益几乎为零。合理的策略是按“变化频率”分层:
| 层次 | 典型内容 | 建议落位 |
|---|---|---|
| 模型层 | DTO、领域实体、序列化注解 | commonMain |
| 契约层 | Repository/Api 接口、expect 声明 | commonMain |
| 编排层 | 请求重试、缓存读写顺序、Flow 组合 | commonMain |
| 引擎层 | HTTP 引擎、数据库驱动 | 平台源集 |
| 平台适配 | Context、NSUserDefaults、权限 | 平台源集 |
按这张表落位,通常能让网络与数据层 70% 以上的代码进入 commonMain,同时保持每个平台源集只包含真正无法共享的少量适配代码。这样既拿到了跨平台共享的收益,又不会因为过度抽象而牺牲可维护性。
总结来说,KMP 工程结构的关键在于理解源集的单向继承关系,并据此把网络与数据层拆成“commonMain 定义契约与编排、平台源集提供引擎与适配”的两层结构。目录组织、依赖声明、expect/actual 三者配合,才能让跨平台共享既充分又可控。