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。

KMP 源集继承关系与数据层落位 commonMain(契约层) expect class HttpClientFactory interface UserApi / UserRepository data class User、DTO、序列化模型 纯业务逻辑、缓存策略接口 继承 继承 androidMain(实现层) actual HttpClientFactory OkHttp / Ktor CIO 引擎 Room 数据库、DataStore Android Context 相关初始化 可访问 commonMain 全部声明 iosMain(实现层) actual HttpClientFactory Darwin 引擎 / NSURLSession SQLDelight、NSUserDefaults iOS 平台初始化配置 可访问 commonMain 全部声明 可见性方向:androidMain / iosMain → commonMain(单向) commonMain 无法引用任何平台源集的类型

二、网络层: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,只有真正平台绑定的部分才下沉。

判断落位的一个实用口诀:先问“这段代码编译时是否需要平台 SDK”,需要则下沉到平台源集;再问“两个平台是否逻辑一致”,一致则上提到 commonMain。两步就能定位绝大多数数据层代码。

四、依赖配置:源集依赖决定可见范围

目录结构只是物理位置,真正让源集“看见”库的是 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,同时保持每个平台源集只包含真正无法共享的少量适配代码。这样既拿到了跨平台共享的收益,又不会因为过度抽象而牺牲可维护性。

不要为了“看起来共享得多”而把平台专属逻辑硬塞进 commonMain 再用 expect/actual 包一层。如果某个功能在两端实现差异极大(例如推送注册、后台任务调度),直接在各平台源集独立实现反而更清晰,强行共享只会增加维护成本。

总结来说,KMP 工程结构的关键在于理解源集的单向继承关系,并据此把网络与数据层拆成“commonMain 定义契约与编排、平台源集提供引擎与适配”的两层结构。目录组织、依赖声明、expect/actual 三者配合,才能让跨平台共享既充分又可控。