理解 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)都能共享的代码,典型包括八类:

  1. 公共数据模型:如 Device、DeviceStatus;

  2. 统一结果封装:如 ApiResult;

  3. 纯业务规则:如设备状态判断、巡检表单校验;

  4. 二维码结果解析:只负责"如何解析内容",不负责"如何打开相机";

  5. 地图业务模型:GeoPoint、MapMarkerModel,不依赖高德、MapKit;

  6. Repository 接口:描述业务能力,不关心平台;

  7. UseCase:保持平台无关;

  8. 跨平台网络逻辑: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 或浏览器平台后,是否仍然成立?以下五类不能直接进入:

  1. Android API:Context、Activity、Log、CameraX、WebView、SharedPreferences、DataStore Android 实现、高德地图 Android SDK、Android 权限;

  2. iOS API:platform.UIKit.UIDevice、platform.Foundation.NSLog 等 Apple API;

  3. Web API:kotlinx.browser.window 等只能用于 JS/Web Source Set;

  4. 只有 JVM 版本的第三方库:用 Kotlin 写的不等于支持 KMP,Retrofit 等只有 JVM/Android 变体的库不能进公共层;

  5. 带平台接口的数据模型:如 @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 开发者最容易犯的七个错误

  1. 把 commonMain 当成新的 utils 模块:什么都往里塞。commonMain 不是工具箱,仍应按 core、domain、feature、model、repository、usecase 组织。

  2. 认为所有 Kotlin 代码都能进 commonMain:Context、Parcelable、Log.d、DataStore、CameraX 都不行,判断依据是"是否依赖平台",不是"是不是 Kotlin"。

  3. 把 androidMain 当成 Android App:它只是 Source Set,完整 App 还需要 Manifest、Application、Activity、页面、资源,那些属于 androidApp。

  4. 把 iosMain 当成 Swift 目录:iosMain 里主要写 Kotlin,SwiftUI/UIKit 页面在 iosApp。

  5. 认为 iosMain 不能调用 iOS API:通过 Kotlin/Native 可以直接访问 Apple 平台 API。

  6. 所有平台差异都用 expect/actual:复杂能力用 expect class 会导致公共层与平台层高度绑定,更适合接口 + 平台实现 + 依赖注入。

  7. 在 commonMain 引入平台专属依赖:会导致其他 Target 无法解析依赖或完成编译。

思维转变:从 Module 思维到二维结构

Android 开发者以前只问"这段代码放在哪个 Module",KMP 后要同时问两个问题:

  1. 这段代码属于哪个业务 Module?(业务边界)

  2. 这段代码应该放在哪个 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 编译的平台实现代码。

Logo

一站式 AI 云服务平台

更多推荐