【KMP】-KMP 项目commonMain、androidMain、iosMain 到底放什么?
理解 KMP 项目结构最大的难点不是 Kotlin 语法,而是把原来的"Android Module 思维"升级为"Module + Target + Source Set"三维思维:Module 负责划分业务边界,Source Set 负责划分平台边界。commonMain、androidMain、iosMain 不是三个独立 Module,而是同一个 KMP Module 内部不同平台共享的源码集合。
判断一段代码放哪里的标准只有一个:它是否依赖某个平台 API。不依赖平台 → commonMain;依赖 Android → androidMain;依赖 iOS → iosMain;依赖浏览器 → jsMain / wasmJsMain。
为什么 KMP 结构让 Android 开发者困惑
普通 Android 项目里,一个目录就是一个 Module,一个 Module 对应一套 Android 代码,开发者主要接触 src/main,容易形成"一个目录 = 一个模块 = 一个运行平台"的直觉。
sharedLogic/
└── src/
├── commonMain/
├── androidMain/
├── iosMain/
├── jsMain/
└── commonTest/
而 KMP 项目打开是这样的:
于是产生一系列疑问:commonMain 是什么?androidMain 是另一个 Module 吗?iosMain 写的是 Kotlin 为什么能调 iOS API?iosMain 和 iosApp 什么关系?这些疑问都源于混淆了三个不同的概念。
三个核心概念:Module、Target、Source Set
|
概念 |
是什么 |
关键点 |
|---|---|---|
|
Module |
Gradle 构建单元 |
KMP 没有取消组件化,core-model、core-network、feature-device 等仍可各自成为独立 KMP Module |
|
Target |
代码最终编译到什么平台 |
一个 Module 可同时面向 Android、iOS、Web、Desktop;每个 Target 有对应的编译任务 |
|
Source Set |
某一组目标共享的源码集合 |
commonMain 供所有目标共享;androidMain 只参与 Android 编译;iosMain 供多个 iOS 目标共享 |
Target 声明示例
kotlin {
androidTarget()
iosArm64() // iPhone 真机
iosSimulatorArm64() // Apple Silicon 模拟器
js { browser() } // Kotlin/JS
wasmJs { browser() } // Kotlin/Wasm
}
三者的编译组合关系
编译某个平台时,编译器会把 commonMain 与该平台对应的 Source Set 组合起来,commonMain 并不会独立运行:
编译 Android = commonMain + androidMain
编译 iOS 模拟器 = commonMain + iosMain + iosSimulatorArm64Main
编译 iOS 真机 = commonMain + iosMain + iosArm64Main
编译 Kotlin/JS = commonMain + jsMain
commonMain 应该放什么
commonMain 是整个 KMP 业务底座的核心,存放所有目标平台(Android、iOS、Web、Desktop)都能共享的代码,典型包括八类:
-
公共数据模型:如 Device、DeviceStatus;
-
统一结果封装:如 ApiResult;
-
纯业务规则:如设备状态判断、巡检表单校验;
-
二维码结果解析:只负责"如何解析内容",不负责"如何打开相机";
-
地图业务模型:GeoPoint、MapMarkerModel,不依赖高德、MapKit;
-
Repository 接口:描述业务能力,不关心平台;
-
UseCase:保持平台无关;
-
跨平台网络逻辑:Ktor Client、kotlinx.serialization 等支持 KMP 的库。
典型代码示例
data class Device(
val id: String,
val name: String,
val status: DeviceStatus
)
enum class DeviceStatus { ONLINE, OFFLINE, WARNING }
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class Failure(val code: String, val message: String) : ApiResult<Nothing>
}
interface DeviceRepository {
suspend fun getDeviceList(): ApiResult<List<Device>>
suspend fun getDeviceDetail(deviceId: String): ApiResult<Device>
}
class GetDeviceListUseCase(
private val repository: DeviceRepository
) {
suspend operator fun invoke(): ApiResult<List<Device>> {
return repository.getDeviceList()
}
}
commonMain 不能放什么
判断代码能否进 commonMain,先问:这段代码离开 Android、iOS 或浏览器平台后,是否仍然成立?以下五类不能直接进入:
-
Android API:Context、Activity、Log、CameraX、WebView、SharedPreferences、DataStore Android 实现、高德地图 Android SDK、Android 权限;
-
iOS API:platform.UIKit.UIDevice、platform.Foundation.NSLog 等 Apple API;
-
Web API:kotlinx.browser.window 等只能用于 JS/Web Source Set;
-
只有 JVM 版本的第三方库:用 Kotlin 写的不等于支持 KMP,Retrofit 等只有 JVM/Android 变体的库不能进公共层;
-
带平台接口的数据模型:如 @Parcelize + Parcelable 的模型,需改为纯 data class,序列化交给 Android 层处理。
"使用 Kotlin 编写" ≠ "支持 Kotlin Multiplatform"。是否依赖平台,才是判断代码归属的唯一依据。
androidMain 应该放什么
androidMain 放只在 Android 平台编译运行的 Kotlin 代码,可正常使用 Context、Activity、Application、Log、DataStore、CameraX、高德地图 SDK、Android 权限等。常见做法是:公共层定义接口,androidMain 提供实现。
interface AppLogger {
fun d(tag: String, message: String)
}
interface TokenStorage {
suspend fun saveAccessToken(token: String)
suspend fun getAccessToken(): String?
suspend fun clear()
}
import android.util.Log
class AndroidLogger : AppLogger {
override fun d(tag: String, message: String) {
Log.d(tag, message)
}
}
class AndroidPlatformInfo : PlatformInfo {
override val platformName: String = "Android"
}
目录约定:sharedLogic/src/androidMain/kotlin/ 下按 platform、network、storage 等职责分包。注意:强依赖 Activity、Compose 页面、权限回调、相机生命周期的完整页面,应放在 androidApp 或独立 Android Module 中,而不是全部塞进 androidMain。
iosMain 应该放什么
iosMain 与 androidMain 定位类似,存放多个 iOS Target(真机 + 模拟器)共享的平台实现:iOS 日志、Keychain/NSUserDefaults 存储、Apple 平台信息、iOS SDK 适配、Apple Framework 调用。
为什么 iosMain 可以直接调用 iOS API
这是 Android 开发者最容易困惑的点:iosMain 里写的是 Kotlin,却能 import UIKit。原因在于 iOS 目标的 Kotlin 不是运行在 JVM 上,而是通过 Kotlin/Native 编译成本地二进制,并提供与 Objective-C、Swift 可见 API 及 Apple 系统 Framework 的互操作能力,因此能看到 platform.UIKit、platform.Foundation 等包,可调用 UIKit、Foundation、CoreLocation、AVFoundation。
import platform.UIKit.UIDevice
import platform.Foundation.NSLog
class IosPlatformInfo : PlatformInfo {
override val platformName: String
get() {
val device = UIDevice.currentDevice
return "${device.systemName} ${device.systemVersion}"
}
}
class IosLogger : AppLogger {
override fun d(tag: String, message: String) {
NSLog("[$tag] $message")
}
}
iosMain 能调用 iOS API,不等于所有纯 Swift 三方库都能直接 import。第三方 Apple 依赖通常需要处理 Objective-C 互操作、cinterop、CocoaPods 或 Swift Package 集成,取决于库暴露的接口。
iosMain 和 iosApp 是什么关系
|
对比项 |
说明 |
|---|---|
|
iosMain |
KMP Module 内的 Source Set,写 Kotlin 代码和 iOS 平台实现,最终参与 iOS Framework 编译 |
|
iosApp |
Xcode 工程(SwiftUI/UIKit App),包含 AppDelegate、SceneDelegate、页面和资源,通过集成 KMP 生成的 Framework 调用 sharedLogic |
iosApp(SwiftUI / UIKit)
│
▼
KMP Framework
│
▼
commonMain + iosMain
不要把 iosMain 理解成 iOS App,它只是 KMP Module 中面向 iOS 平台的一组 Kotlin 源码。
iosMain import UIKit 标红怎么办
在 Android Studio 中 platform.UIKit 相关 import 偶尔显示红色,但执行 ./gradlew :sharedLogic:compileKotlinIosSimulatorArm64 却 BUILD SUCCESSFUL。此时应区分两个结果:编辑器分析结果(可能因 IDE 索引未完成、KMP 插件兼容性、Gradle 同步状态等导致)和 Gradle 编译结果。对"代码能否真正构建",对应 Target 的编译任务更有判断价值。
jsMain 是不是就等于 Web
不能简单画等号,更准确的说法是:
-
jsMain:对应 Kotlin/JS Target,代码编译成 JavaScript,可访问浏览器 API(如 kotlinx.browser.window);
-
wasmJsMain:对应 Kotlin/Wasm 的 JavaScript/Web Target。
正式项目中需要区分 Kotlin/JS 与 Kotlin/Wasm 两种底层编译目标,Web 端可能是 commonMain + jsMain,也可能是 commonMain + wasmJsMain,还可能同时存在两个 Target。
expect 和 actual 到底是什么
当 commonMain 需要某项平台能力、但各平台实现不同时,用 expect/actual 声明平台无关 API 并由各目标提供实现。expect/actual 是编译期机制,不是运行时判断:编译 Android 时组合 androidMain 的 actual,编译 iOS 时组合 iosMain 的 actual;声明了 expect 却没有对应 actual 的目标无法完成编译。
// commonMain
expect fun getPlatformName(): String
// androidMain
actual fun getPlatformName(): String = "Android"
// iosMain
actual fun getPlatformName(): String =
UIDevice.currentDevice.systemName
// jsMain
actual fun getPlatformName(): String = "Web JavaScript"
不是所有平台差异都要用 expect/actual
|
场景 |
推荐做法 |
|---|---|
|
简单平台值 / 简单平台函数(如平台名称) |
可以考虑 expect / actual |
|
复杂平台能力或需要替换、测试的能力(日志、Token 存储、扫码、地图、定位、相机、权限) |
优先接口 + 平台实现 + 依赖注入 |
依赖应该怎么放
依赖按 Source Set 区分:commonMain 只能依赖所有目标都能用的跨平台库(如 kotlinx-coroutines-core、kotlinx-serialization-json);androidMain 可依赖 Android 专属库(如 DataStore);iosMain 使用 iOS 平台 API 或 Apple 依赖;jsMain 依赖 Kotlin/JS 平台库。不能在 commonMain 引入仅 Android/JVM 可用的库,否则其他 Target 无法解析依赖或完成编译。
命名约定与项目组织
sharedLogic、sharedUI、androidApp、iosApp 只是命名约定,不是 KMP 强制关键字。KMP 真正关心的是 Module 用了哪些插件、声明了哪些 Target、Source Set 如何组织与依赖、最终如何输出。示例目录结构:
sharedLogic/src/commonMain/kotlin/com/sqx/liftkmpcore/
├── core/ # result、error、network、storage、platform
├── domain/ # model、repository、usecase
├── feature/ # auth、device、inspection
├── qrcode/
└── map/
sharedLogic/src/androidMain/kotlin/... # platform、network、storage 的 Android 实现
sharedLogic/src/iosMain/kotlin/... # platform、network、storage 的 iOS 实现
若使用 Compose Multiplatform,可增加 sharedUI 模块:commonMain 放登录页、设备列表、详情、表单、通用组件,Android/iOS 的 main 放各自平台差异;CameraX 预览、高德 MapView、MapKit、权限弹窗等强平台能力不建议塞进共享 UI。
推荐的设备巡检 Demo 结构
lift-kmp-core/
├── settings.gradle.kts / build.gradle.kts
├── sharedLogic/ # 业务逻辑(commonMain/androidMain/iosMain/jsMain/commonTest)
├── sharedUI/ # 可共享 Compose UI(commonMain/androidMain/iosMain)
├── androidApp/ # Android 入口与强 Android 能力
├── iosApp/ # iOS 入口与强 iOS 能力
└── webApp/ # Web 入口
这只是推荐的职责划分,不是唯一结构;小项目可先合并为一个 KMP Module,扩大后再拆分。
代码归属速查表
|
代码 |
推荐位置 |
原因 |
|---|---|---|
|
Device、DeviceStatus |
commonMain |
公共业务模型 / 状态 |
|
ApiResult |
commonMain |
多平台统一结果 |
|
ScanParser |
commonMain |
纯字符串解析 |
|
GeoPoint、MapMarkerModel |
commonMain |
平台无关点位模型 |
|
InspectionValidator |
commonMain |
纯业务校验 |
|
DeviceRepository、UseCase |
commonMain |
业务能力抽象与流程 |
|
AndroidLogger |
androidMain |
依赖 Android Log |
|
IosLogger |
iosMain |
调用 Apple 平台日志 |
|
DataStore 实现 |
androidMain |
Android 专属存储 |
|
Keychain 实现 |
iosMain |
iOS 专属存储 |
|
CameraX / ML Kit |
androidMain 或 Android Module |
Android 相机能力 |
|
高德 MapView |
androidApp |
强 UI 与生命周期依赖 |
|
AVFoundation 扫码 |
iosMain 或 iosApp |
iOS 平台能力 |
|
MapKit 页面 |
iosApp |
iOS 地图 UI |
|
设备列表 Compose UI |
sharedUI/commonMain |
可以尝试共享 UI |
|
浏览器 Window API |
jsMain |
Kotlin/JS 平台能力 |
Android 开发者最容易犯的七个错误
-
把 commonMain 当成新的 utils 模块:什么都往里塞。commonMain 不是工具箱,仍应按 core、domain、feature、model、repository、usecase 组织。
-
认为所有 Kotlin 代码都能进 commonMain:Context、Parcelable、Log.d、DataStore、CameraX 都不行,判断依据是"是否依赖平台",不是"是不是 Kotlin"。
-
把 androidMain 当成 Android App:它只是 Source Set,完整 App 还需要 Manifest、Application、Activity、页面、资源,那些属于 androidApp。
-
把 iosMain 当成 Swift 目录:iosMain 里主要写 Kotlin,SwiftUI/UIKit 页面在 iosApp。
-
认为 iosMain 不能调用 iOS API:通过 Kotlin/Native 可以直接访问 Apple 平台 API。
-
所有平台差异都用 expect/actual:复杂能力用 expect class 会导致公共层与平台层高度绑定,更适合接口 + 平台实现 + 依赖注入。
-
在 commonMain 引入平台专属依赖:会导致其他 Target 无法解析依赖或完成编译。
思维转变:从 Module 思维到二维结构
Android 开发者以前只问"这段代码放在哪个 Module",KMP 后要同时问两个问题:
-
这段代码属于哪个业务 Module?(业务边界)
-
这段代码应该放在哪个 Source Set?(平台边界)
以二维码为例:解析逻辑 = core-qrcode 模块 + commonMain;Android 相机扫码 = core-qrcode 模块 + androidMain;iOS 扫码 = core-qrcode 模块 + iosMain。KMP 组件化与普通 Android 组件化最大的结构差异,就是多了 Source Set 这条平台边界。
总结
|
Source Set |
职责 |
|---|---|
|
commonMain |
所有目标共享:数据模型、Result 封装、业务规则、Repository、UseCase、网络逻辑、数据解析、表单校验 |
|
androidMain |
Android Log、DataStore 实现、Android 平台信息、Android SDK 适配、Android 网络与存储实现 |
|
iosMain |
iOS 日志、Keychain/NSUserDefaults 实现、Apple 平台信息、iOS SDK 适配、Apple Framework 调用 |
|
jsMain / wasmJsMain |
Kotlin/JS 与 Kotlin/Wasm 的 Web 平台实现 |
|
androidApp / iosApp / webApp |
各平台应用入口、UI、资源、生命周期与强平台能力 |
一句话记住:Module 负责划分业务边界,Source Set 负责划分平台边界。理解了这一点,就不会再把 commonMain、androidMain、iosMain 误认为三个独立 Module,也能理解为什么 platform.UIKit 可以出现在 iosMain 中——因为它本来就是面向 iOS Target 编译的平台实现代码。
更多推荐



所有评论(0)