从 Android 组件化到 KMP 跨平台底座(三):KMP 项目结构一次讲清,commonMain、androidMain、iosMain 到底放什么?
前言
Android 开发者第一次打开 KMP 项目时,通常会看到这样的目录:
sharedLogic/
└── src/
├── commonMain/
├── androidMain/
├── iosMain/
├── jsMain/
└── commonTest/
看到这里,很容易产生一连串疑问:
commonMain到底是什么?androidMain是不是另一个 Android Module?iosMain里面写的是 Kotlin,为什么可以调用 iOS API?iosMain和iosApp是什么关系?jsMain是不是就代表 Web?sharedLogic、sharedUI、androidApp、iosApp应该怎么组织?expect和actual到底放在哪里?- 一个 KMP Module 为什么会有这么多目录?
对于 Android 开发者来说,理解 KMP 项目结构最大的难点,不是 Kotlin 语法,而是需要从原来的:
Android Module 思维
逐步转变为:
Module
+
Target
+
Source Set
这三个概念共同组成的 KMP 项目结构思维。
这一篇就从 Android 开发者熟悉的组件化项目出发,把 commonMain、androidMain、iosMain 之间的关系彻底讲清楚。
一、先从 Android Module 思维说起
在普通 Android 项目中,我们比较熟悉的是 Module。
例如:
app
lib-network
lib-storage
lib-qrcode
lib-map
feature-device
每个 Module 都是一个相对独立的 Gradle 构建单元。
例如:
lib-network
可能包含:
lib-network/
├── build.gradle.kts
└── src/
├── main/
│ ├── kotlin/
│ └── AndroidManifest.xml
│
├── test/
└── androidTest/
在这种结构中,通常可以把它简单理解为:
一个 Module
=
一套 Android 代码
虽然 Android Module 内部还存在:
maindebugreleasetestandroidTest
这些不同的 Source Set,但在日常 Android 开发中,我们主要接触的还是:
src/main
所以很多 Android 开发者会形成一种直觉:
一个目录就是一个模块,一个模块对应一个运行平台。
但是,进入 KMP 之后,这种理解就不够了。
二、KMP 项目中有三个重要概念
理解 KMP 项目结构,需要先区分三个概念:
Module
Target
Source Set
它们不是一回事。
1. Module:Gradle 构建模块
Module 仍然是 Gradle 层面的构建单元。
例如:
sharedLogic
可以是一个 KMP Module。
它通常拥有自己的:
sharedLogic/
├── build.gradle.kts
└── src/
一个项目中可以有一个 KMP Module,也可以有多个 KMP Module。
例如:
core-model
core-result
core-network
feature-auth
feature-device
sharedLogic
这些都可以分别成为独立的 KMP Module。
所以,KMP 并没有取消组件化。
相反,KMP Module 内部还可以继续按照多模块方式拆分。
2. Target:代码最终编译到什么平台
Target 表示这个 KMP Module 要支持哪些目标平台。
例如:
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
js {
browser()
}
wasmJs {
browser()
}
}
这里声明了多个 Target:
Android Target
iOS 真机 ARM64 Target
iOS 模拟器 ARM64 Target
JavaScript Target
WebAssembly Target
一个 KMP Module 可以同时面向多个 Target。
这和普通 Android Library 最大的区别是:
Android Library
通常只面向 Android
KMP Module
可以同时面向 Android、iOS、Web、Desktop 等平台
Kotlin Multiplatform 会根据声明的目标,为不同平台创建对应的编译任务和平台代码集合。
3. Source Set:某一组目标共享的源码集合
Source Set 可以理解为:
一组具有相同可用 API、依赖关系和编译目标的源代码集合。
例如:
commonMain
androidMain
iosMain
jsMain
wasmJsMain
它们不是五个 Gradle Module。
它们属于同一个 KMP Module 内部的不同源码集合。
例如:
sharedLogic
└── src/
├── commonMain/
├── androidMain/
├── iosMain/
├── jsMain/
└── wasmJsMain/
它们之间的核心区别是:
commonMain
供所有目标共享
androidMain
只参与 Android 目标编译
iosMain
供多个 iOS 目标共享
jsMain
只参与 Kotlin/JS 目标编译
wasmJsMain
只参与 Kotlin/Wasm Web 目标编译
KMP 官方文档将 commonMain 定义为所有已声明目标共享的公共代码集合,而平台 Source Set 则只会编译到对应平台。
三、Module、Target、Source Set 到底是什么关系?
可以通过下面这个结构理解:
sharedLogic KMP Module
│
├── Android Target
│ ├── commonMain 公共代码
│ └── androidMain Android 专属代码
│
├── iOS 真机 Target
│ ├── commonMain 公共代码
│ ├── iosMain iOS 共享代码
│ └── iosArm64Main iOS 真机专属代码
│
└── iOS 模拟器 Target
├── commonMain 公共代码
├── iosMain iOS 共享代码
└── iosSimulatorArm64Main iOS 模拟器专属代码
编译 Android 版本时,编译器会组合:
commonMain
+
androidMain
编译 iOS 模拟器版本时,编译器会组合:
commonMain
+
iosMain
+
iosSimulatorArm64Main
编译 iOS 真机版本时,编译器会组合:
commonMain
+
iosMain
+
iosArm64Main
所以,commonMain 并不是独立运行的。
它会和目标平台对应的 Source Set 一起参与编译。
KMP 支持这种分层 Source Set 结构。默认层级模板还可以为多个 Apple Target 自动建立 iosMain 等中间 Source Set,让多个 iOS 目标共享同一套实现。
四、commonMain 到底应该放什么?
commonMain 是整个 KMP 业务底座中最核心的部分。
它的职责是:
存放所有目标平台都能够共享的代码。
例如:
Android
iOS
Web
Desktop
都需要使用的业务代码,可以考虑放进 commonMain。
1. 公共数据模型
例如设备模型:
data class Device(
val id: String,
val name: String,
val status: DeviceStatus
)
enum class DeviceStatus {
ONLINE,
OFFLINE,
WARNING
}
这段代码没有依赖任何平台 API。
因此可以放在:
sharedLogic/src/commonMain/kotlin/
2. 统一结果封装
例如:
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>
}
Android、iOS 和 Web 都可以使用同一个结果模型。
3. 纯业务规则
例如设备状态判断:
class DeviceStatusChecker {
fun isAvailable(device: Device): Boolean {
return device.status == DeviceStatus.ONLINE
}
}
或者巡检表单校验:
class InspectionValidator {
fun validate(
deviceId: String,
itemCount: Int
): ValidationResult {
if (deviceId.isBlank()) {
return ValidationResult.Failure(
message = "设备编号不能为空"
)
}
if (itemCount <= 0) {
return ValidationResult.Failure(
message = "巡检项目不能为空"
)
}
return ValidationResult.Success
}
}
这些代码都不关心运行平台。
4. 二维码结果解析
例如:
data class ScanResult(
val rawContent: String,
val deviceId: String?,
val type: String?
)
class ScanParser {
fun parse(rawContent: String): ScanResult {
val params = rawContent
.substringAfter("?", rawContent)
.split("&")
.mapNotNull { item ->
val pair = item.split("=")
if (pair.size == 2) {
pair[0] to pair[1]
} else {
null
}
}
.toMap()
return ScanResult(
rawContent = rawContent,
deviceId = params["deviceId"],
type = params["type"]
)
}
}
这里负责的是:
如何解析二维码内容
而不是:
如何打开相机
如何识别二维码
如何申请权限
因此,解析逻辑可以进入 commonMain。
5. 地图业务模型
例如:
data class GeoPoint(
val latitude: Double,
val longitude: Double
)
data class MapMarkerModel(
val id: String,
val title: String,
val point: GeoPoint
)
它们不依赖高德地图、MapKit 或浏览器地图 API,因此可以共享。
6. Repository 接口
例如:
interface DeviceRepository {
suspend fun getDeviceList(): ApiResult<List<Device>>
suspend fun getDeviceDetail(
deviceId: String
): ApiResult<Device>
}
Repository 接口描述的是业务能力,不需要知道具体运行平台。
7. UseCase
例如:
class GetDeviceListUseCase(
private val repository: DeviceRepository
) {
suspend operator fun invoke(): ApiResult<List<Device>> {
return repository.getDeviceList()
}
}
UseCase 一般也应该尽量保持平台无关。
8. 跨平台网络逻辑
如果使用 Ktor Client、kotlinx.serialization 等支持 KMP 的库,网络请求、序列化和部分数据层代码也可以进入 commonMain。
但是需要注意:
commonMain只能依赖支持对应 KMP 目标的平台库,不能直接依赖一个仅支持 Android 或 JVM 的库。
例如,不能因为 Retrofit 是 Kotlin 写的,就认为它一定能够直接放进所有目标共享的 commonMain。
KMP 的公共 Source Set 可以依赖跨平台库,Kotlin Gradle 插件会为不同目标选择对应的平台实现;平台专属依赖则不能放进公共 Source Set。
五、commonMain 不能放什么?
判断一段代码能不能放进 commonMain,可以先问一个问题:
这段代码离开 Android、iOS 或浏览器平台后,是否仍然能够成立?
如果代码依赖下面这些内容,就不能直接进入 commonMain。
1. Android API
例如:
import android.content.Context
import android.app.Activity
import android.util.Log
以及:
CameraX
Android WebView
Android Service
BroadcastReceiver
SharedPreferences
DataStore Android 实现
高德地图 Android SDK
Android 权限 API
这些都属于 Android 平台能力。
2. iOS API
例如:
import platform.UIKit.UIDevice
import platform.Foundation.NSLog
这些属于 Apple 平台 API,不能直接写在面向所有平台的 commonMain 中。
3. Web 平台 API
例如:
import kotlinx.browser.window
它只能用于 JavaScript 或对应的 Web Source Set,不能让 Android、iOS 一起编译。
4. 只有 JVM 版本的第三方库
即使某个库使用 Kotlin 编写,只要它只发布了 JVM 或 Android 版本,就不能直接作为 commonMain 的跨平台依赖。
所以:
使用 Kotlin 编写
不等于:
支持 Kotlin Multiplatform
5. 带有平台接口的数据模型
例如:
@Parcelize
data class Device(
val id: String,
val name: String
) : Parcelable
虽然 Device 是 data class,但它依赖:
Parcelable
Parcelize
所以不能原封不动地放进 commonMain。
公共模型应该改成:
data class Device(
val id: String,
val name: String
)
Android 页面需要传递参数时,再由 Android 层处理 Parcelable、序列化或路由参数。
六、androidMain 到底应该放什么?
androidMain 负责:
只在 Android 平台编译和运行的 Kotlin 代码。
在这里可以正常使用:
Context
Activity
Application
android.util.Log
DataStore
CameraX
高德地图 Android SDK
Android 权限 API
Android 文件系统
例如:
import android.util.Log
class AndroidLogger : AppLogger {
override fun d(
tag: String,
message: String
) {
Log.d(tag, message)
}
}
1. Android 日志实现
公共层定义:
interface AppLogger {
fun d(
tag: String,
message: String
)
}
Android 平台实现:
import android.util.Log
class AndroidLogger : AppLogger {
override fun d(
tag: String,
message: String
) {
Log.d(tag, message)
}
}
目录可以是:
sharedLogic/
└── src/
└── androidMain/
└── kotlin/
└── platform/
└── AndroidLogger.kt
2. Android Token 存储实现
公共层定义:
interface TokenStorage {
suspend fun saveAccessToken(token: String)
suspend fun getAccessToken(): String?
suspend fun clear()
}
Android 可以使用 DataStore 实现:
class AndroidTokenStorage(
private val dataStore: TokenDataStore
) : TokenStorage {
override suspend fun saveAccessToken(token: String) {
dataStore.saveToken(token)
}
override suspend fun getAccessToken(): String? {
return dataStore.getToken()
}
override suspend fun clear() {
dataStore.clear()
}
}
3. Android 平台信息
例如:
class AndroidPlatformInfo : PlatformInfo {
override val platformName: String = "Android"
}
也可以通过 expect 和 actual 实现,后面会讲。
4. Android SDK 适配器
例如扫码功能可以定义抽象:
interface QrScanner {
suspend fun scan(): ScanResult
}
Android 侧可以实现:
class AndroidQrScanner(
private val cameraScanner: CameraScanner
) : QrScanner {
override suspend fun scan(): ScanResult {
val rawContent = cameraScanner.scanRawContent()
return ScanParser().parse(rawContent)
}
}
真正调用 CameraX、ML Kit 的代码属于 Android 平台实现。
不过对于强依赖:
-
Activity
-
Compose 页面
-
权限回调
-
相机生命周期
的完整扫码页面,也可以继续放在 androidApp 或独立 Android 平台 Module 中,而不是全部塞进 sharedLogic/androidMain。
七、iosMain 到底应该放什么?
iosMain 的定位和 androidMain 类似:
存放多个 iOS Target 可以共享的平台实现代码。
例如,一个项目可能同时支持:
iosArm64
iosSimulatorArm64
前者用于 iPhone 真机,后者用于 Apple Silicon Mac 上的 iOS 模拟器。
这两个 Target 虽然编译目标不同,但绝大多数 iOS 业务实现是相同的。
因此可以共享:
iosMain
整体关系是:
commonMain
│
▼
iosMain
│
├── iosArm64Main
└── iosSimulatorArm64Main
更准确地说,是 iosArm64Main、iosSimulatorArm64Main 等目标 Source Set 依赖 iosMain,而 iosMain 再依赖 commonMain。默认 Source Set 层级可以自动建立这种关系。
八、为什么 iosMain 可以直接调用 iOS API?
这是 Android 开发者最容易产生疑问的地方。
例如下面这段代码可以写在 iosMain:
import platform.UIKit.UIDevice
class IosPlatformInfo : PlatformInfo {
override val platformName: String
get() = UIDevice.currentDevice.systemName
}
很多 Android 开发者第一次看到时会疑惑:
iosMain里面写的明明是 Kotlin,为什么可以调用 UIKit?
原因是:
iosMain
不是运行在 JVM 上的普通 Kotlin 代码
iOS 目标通过 Kotlin/Native 编译成本地二进制,并且 Kotlin/Native 提供了与 Objective-C、Swift 可见 API 以及 Apple 系统 Framework 的互操作能力。Apple 平台的部分系统 Framework 会以 Kotlin 可调用的形式暴露出来,因此可以看到 platform.UIKit、platform.Foundation 等包。
也就是说:
Android Kotlin
通过 JVM / Android Runtime 运行
iOS Kotlin
通过 Kotlin/Native 编译为本地代码
所以在 iosMain 中可以调用:
UIKit
Foundation
CoreLocation
AVFoundation
等 Apple 平台 API。
1. iOS 日志实现
例如:
import platform.Foundation.NSLog
class IosLogger : AppLogger {
override fun d(
tag: String,
message: String
) {
NSLog("[$tag] $message")
}
}
2. iOS 平台信息
例如:
import platform.UIKit.UIDevice
class IosPlatformInfo : PlatformInfo {
override val platformName: String
get() {
val device = UIDevice.currentDevice
return "${device.systemName} ${device.systemVersion}"
}
}
3. iOS 存储实现
例如,iOS 可以通过:
NSUserDefaults
Keychain
文件系统
实现 TokenStorage。
概念结构如下:
class IosTokenStorage : TokenStorage {
override suspend fun saveAccessToken(token: String) {
// 调用 iOS 存储能力
}
override suspend fun getAccessToken(): String? {
// 读取 iOS Token
return null
}
override suspend fun clear() {
// 清除 Token
}
}
4. iOS 第三方 SDK
如果需要在 KMP iOS Source Set 中使用第三方 Apple 依赖,通常需要考虑:
-
Objective-C 互操作
-
cinterop
-
CocoaPods 集成
-
Swift Package 相关集成方式
-
第三方库是否向 Objective-C 导出可见 API
并不是所有纯 Swift 库都能无条件直接在 Kotlin 中使用,具体取决于它暴露出来的接口和集成方式。
所以:
iosMain 能调用 iOS API
不等于:
所有 Swift 三方库都能直接 import
九、iosMain 和 iosApp 是什么关系?
这两个名字很像,但它们不是一回事。
1. iosMain 是 Source Set
iosMain 属于 KMP Module。
例如:
sharedLogic/
└── src/
└── iosMain/
里面主要写:
-
Kotlin 代码
-
iOS 平台实现
-
Apple 平台 API 调用
-
KMP 公共接口的 iOS 实现
它最终会参与 iOS Framework 的编译。
2. iosApp 是 iOS 应用宿主
iosApp 通常是:
-
Xcode Project
-
SwiftUI App
-
UIKit App
-
iOS 应用入口
-
AppDelegate
-
SceneDelegate
-
iOS 页面和资源
例如:
iosApp/
├── iosApp.xcodeproj
├── iosApp/
│ ├── ContentView.swift
│ ├── iOSApp.swift
│ ├── Assets.xcassets
│ └── Info.plist
iosApp 通过集成 KMP 生成的 Framework,调用 sharedLogic 中的代码。
整体关系可以理解为:
iosApp
SwiftUI / UIKit
│
▼
KMP Framework
│
▼
commonMain + iosMain
KMP 的 iOS 代码可以生成 Apple Framework,再通过直接集成、CocoaPods 等方式连接到 Xcode 应用。
所以不要把:
iosMain
理解成:
iOS App
它只是 KMP Module 中面向 iOS 平台的一组 Kotlin 源码。
十、为什么你的 iosMain import UIKit 可能会标红?
例如:
import platform.UIKit.UIDevice
有时在 Android Studio 中可能会显示红色,但执行:
./gradlew :sharedLogic:compileKotlinIosSimulatorArm64
却能够成功。
如果对应的 iOS 编译任务明确显示:
BUILD SUCCESSFUL
说明至少在 Kotlin 编译器和 Gradle Target 配置层面,这段 iOS 代码能够被正常识别和编译。
此时编辑器标红更可能与以下问题有关:
-
IDE 索引未完成
-
Android Studio 与 KMP 插件兼容性
-
Kotlin 插件版本
-
Gradle 同步状态
-
iOS Target 索引异常
-
当前 IDE 对 Kotlin/Native API 导航支持不完整
所以要区分:
编辑器分析结果
和:
Gradle 编译结果
两者并不总是完全同步。
对于代码能否真正构建,Gradle 对应 Target 的编译任务更有判断价值。
十一、jsMain 是不是就等于 Web?
不能简单地说:
jsMain = Web
更准确的说法是:
jsMain
对应 Kotlin/JS Target
wasmJsMain
对应 Kotlin/Wasm 的 JavaScript/Web Target
两者都可能用于 Web,但底层编译目标不同。
1. jsMain
jsMain 中的 Kotlin 代码会编译成 JavaScript。
例如:
import kotlinx.browser.window
fun showBrowserMessage(message: String) {
window.alert(message)
}
它可以访问浏览器 JavaScript 环境中的 API。
2. wasmJsMain
wasmJsMain 面向 Kotlin/Wasm 的 WebAssembly JavaScript 环境。
如果项目声明:
wasmJs {
browser()
}
通常会对应:
wasmJsMain
因此,Web 端结构可能是:
commonMain
+
jsMain
也可能是:
commonMain
+
wasmJsMain
还可能同时存在两个 Target。
所以,在系列最初的简化描述中:
jsMain = Web 平台实现
适合帮助入门理解,但在正式项目中需要进一步区分:
Kotlin/JS
和
Kotlin/Wasm
十二、expect 和 actual 到底是什么?
当 commonMain 需要使用某项平台能力,但每个平台的实现不同,可以使用 expect 和 actual。
例如,公共层需要获取平台名称。
在 commonMain 中声明:
expect fun getPlatformName(): String
这里没有具体实现。
它表达的是:
我期望每个目标平台都提供一个名为
getPlatformName的实现。
Android 在 androidMain 中实现:
actual fun getPlatformName(): String {
return "Android"
}
iOS 在 iosMain 中实现:
import platform.UIKit.UIDevice
actual fun getPlatformName(): String {
return UIDevice.currentDevice.systemName
}
Web 在 jsMain 中实现:
actual fun getPlatformName(): String {
return "Web JavaScript"
}
于是,commonMain 可以直接调用:
class PlatformService {
fun platformDescription(): String {
return "Current platform: ${getPlatformName()}"
}
}
公共层不需要知道具体运行平台。
KMP 的 expect 和 actual 机制允许公共代码声明平台无关 API,再由各目标 Source Set 提供对应实现。
十三、expect 和 actual 的编译关系
编译 Android 时:
commonMain
expect fun getPlatformName()
+
androidMain
actual fun getPlatformName()
组成完整实现。
编译 iOS 时:
commonMain
expect fun getPlatformName()
+
iosMain
actual fun getPlatformName()
组成 iOS 实现。
如果声明了 expect,但某个目标没有对应的 actual,该目标通常无法完成编译。
所以,expect 和 actual 不是运行时判断。
它不是:
if (platform == "Android") {
// Android
} else {
// iOS
}
而是:
在不同目标编译阶段,选择对应的平台实现。
十四、是不是所有平台差异都要使用 expect 和 actual?
不是。
这是 KMP 中非常容易出现的误区。
例如平台名称这种简单能力:
expect fun getPlatformName(): String
使用 expect 和 actual 很直观。
但是对于复杂能力,例如:
日志
Token 存储
扫码
地图
定位
文件选择
相机
权限
更适合考虑接口抽象:
interface AppLogger {
fun d(
tag: String,
message: String
)
}
Android 实现:
class AndroidLogger : AppLogger
iOS 实现:
class IosLogger : AppLogger
再通过构造函数注入:
class DeviceRepositoryImpl(
private val logger: AppLogger
)
可以先这样理解:
简单平台值或简单平台函数
可以考虑 expect / actual
复杂平台能力或需要替换、测试的能力
优先考虑接口 + 平台实现 + 依赖注入
expect 和 actual 是连接平台 API 的一种机制,但不是 KMP 平台差异设计的唯一方案。官方文档也分别介绍了 expect/actual、接口、工厂函数和依赖注入等平台 API 使用方式。
这个问题会在下一篇重点展开。
十五、不同 Source Set 的依赖应该怎么放?
KMP 的依赖也需要按照 Source Set 区分。
例如:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:...")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:...")
}
androidMain.dependencies {
implementation("androidx.datastore:datastore-preferences:...")
}
iosMain.dependencies {
// iOS 平台相关依赖
}
jsMain.dependencies {
// Kotlin/JS 平台依赖
}
}
}
核心原则是:
commonMain
只能依赖所有目标都能使用的跨平台库
androidMain
可以依赖 Android 专属库
iosMain
可以使用 iOS 平台 API或对应 Apple 依赖
jsMain
可以依赖 Kotlin/JS 平台库
不能在 commonMain 中直接写:
implementation("androidx.datastore:datastore-preferences:...")
然后期望 iOS 也能够编译。
平台专属依赖应该添加到对应平台 Source Set 中。公共依赖则通常添加到 commonMain,由 Kotlin Gradle 插件解析不同目标所需的平台变体。
十六、sharedLogic、sharedUI、androidApp、iosApp 是固定名称吗?
不是。
这些名称只是项目架构中的命名约定,不是 KMP 强制规定的关键字。
例如,KMP 业务模块可以叫:
shared
sharedLogic
sharedCore
core
business
Compose Multiplatform UI 模块可以叫:
composeApp
sharedUI
sharedPresentation
Android 应用模块可以叫:
androidApp
app
mobileAndroid
iOS 工程可以叫:
iosApp
iosClient
mobileIos
KMP 真正关心的是:
-
Module 使用了哪些插件
-
声明了哪些 Target
-
Source Set 如何组织
-
Source Set 之间如何依赖
-
最终如何输出和集成
而不是目录一定叫什么名字。
十七、sharedLogic 应该怎么组织?
对于设备巡检 Demo,可以先建立:
sharedLogic/
└── src/
├── commonMain/
├── androidMain/
├── iosMain/
├── jsMain/
└── commonTest/
其中 commonMain 可以进一步按职责拆分:
sharedLogic/src/commonMain/kotlin/com/sqx/liftkmpcore/
├── core/
│ ├── result/
│ ├── error/
│ ├── network/
│ ├── storage/
│ └── platform/
│
├── domain/
│ ├── model/
│ ├── repository/
│ └── usecase/
│
├── feature/
│ ├── auth/
│ ├── device/
│ └── inspection/
│
├── qrcode/
└── map/
Android 实现:
sharedLogic/src/androidMain/kotlin/com/sqx/liftkmpcore/
├── platform/
│ ├── AndroidLogger.kt
│ ├── AndroidPlatformInfo.kt
│ └── AndroidTokenStorage.kt
│
├── network/
│ └── AndroidNetworkEngine.kt
│
└── storage/
└── AndroidStorageFactory.kt
iOS 实现:
sharedLogic/src/iosMain/kotlin/com/sqx/liftkmpcore/
├── platform/
│ ├── IosLogger.kt
│ ├── IosPlatformInfo.kt
│ └── IosTokenStorage.kt
│
├── network/
│ └── IosNetworkEngine.kt
│
└── storage/
└── IosStorageFactory.kt
这样可以保持:
包名和职责大致对应
平台实现位置清晰
公共抽象与平台实现分离
十八、sharedUI 应该放在哪里?
如果使用 Compose Multiplatform,可以增加:
sharedUI
它负责可共享的 Compose UI。
例如:
sharedUI/
└── src/
├── commonMain/
│ └── kotlin/
│ ├── login/
│ ├── device/
│ ├── inspection/
│ └── components/
│
├── androidMain/
└── iosMain/
sharedUI/commonMain 可以放:
-
登录页面
-
设备列表
-
设备详情
-
巡检表单
-
通用按钮
-
加载页面
-
错误页面
例如:
@Composable
fun DeviceCard(
device: Device,
onClick: () -> Unit
) {
Card(
onClick = onClick
) {
Column {
Text(text = device.name)
Text(text = device.status.name)
}
}
}
而以下能力不一定适合直接塞进共享 UI:
-
CameraX 相机预览
-
高德地图 MapView
-
iOS MapKit
-
平台权限弹窗
-
Android Service
-
WebView
-
蓝牙系统交互
Compose Multiplatform 支持在共享 UI 与 UIKit、SwiftUI 等平台 UI 之间进行嵌入和互操作,因此共享 UI 与原生 UI 并不是只能二选一。
十九、推荐的设备巡检 Demo 结构
结合这个系列的目标,可以采用下面的结构:
lift-kmp-core/
├── settings.gradle.kts
├── build.gradle.kts
│
├── sharedLogic/
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/
│ ├── androidMain/
│ ├── iosMain/
│ ├── jsMain/
│ └── commonTest/
│
├── sharedUI/
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/
│ ├── androidMain/
│ └── iosMain/
│
├── androidApp/
│ ├── build.gradle.kts
│ └── src/main/
│
├── iosApp/
│ ├── iosApp.xcodeproj
│ └── iosApp/
│
└── webApp/
其中:
sharedLogic
负责业务逻辑
sharedUI
负责可共享 Compose UI
androidApp
负责 Android 入口和强 Android 能力
iosApp
负责 iOS 入口和强 iOS 能力
webApp
负责 Web 入口
需要注意:
这只是推荐的职责划分,不是唯一结构。
对于较小项目,也可以把:
sharedLogic
+
sharedUI
都放进一个 KMP Module。
等项目扩大后,再拆成多个模块。
二十、哪些代码应该放在哪里?
可以通过下面这张表快速判断。
| 代码 | 推荐位置 | 原因 |
|---|---|---|
Device |
commonMain |
公共业务模型 |
DeviceStatus |
commonMain |
公共业务状态 |
ApiResult |
commonMain |
多平台统一结果 |
ScanParser |
commonMain |
纯字符串解析 |
GeoPoint |
commonMain |
平台无关点位模型 |
InspectionValidator |
commonMain |
纯业务校验 |
DeviceRepository |
commonMain |
业务能力抽象 |
GetDeviceListUseCase |
commonMain |
公共业务流程 |
AndroidLogger |
androidMain |
依赖 Android Log |
IosLogger |
iosMain |
调用 Apple 平台日志 |
| DataStore 实现 | androidMain |
Android 专属存储 |
| Keychain 实现 | iosMain |
iOS 专属存储 |
| CameraX | androidMain 或 androidApp |
Android 相机能力 |
| ML Kit Android | androidMain 或 Android Module |
Android SDK |
| 高德 MapView | androidApp |
强 UI 和生命周期依赖 |
| AVFoundation 扫码 | iosMain 或 iosApp |
iOS 平台能力 |
| MapKit 页面 | iosApp |
iOS 地图 UI |
| 设备列表 Compose UI | sharedUI/commonMain |
可以尝试共享 UI |
| Android 前台服务 | androidApp |
Android 系统能力 |
| SwiftUI 应用入口 | iosApp |
iOS 应用宿主 |
| 浏览器 Window API | jsMain |
Kotlin/JS 平台能力 |
二十一、Android 开发者最容易犯的几个错误
错误一:把 commonMain 当成新的 utils 模块
很多开发者会把所有公共类都塞进:
commonMain
最后形成:
commonMain/
├── Utils
├── Manager
├── Helper
├── Base
└── Common
这种结构虽然跨平台了,但业务边界仍然混乱。
commonMain 不是一个大型工具箱。
它仍然应该按照:
-
core
-
domain
-
feature
-
model
-
repository
-
usecase
等职责组织。
错误二:认为所有 Kotlin 代码都能放进 commonMain
下面的代码虽然都是 Kotlin,但不能直接进入公共层:
Context
Parcelable
Log.d
DataStore
CameraX
判断依据不是:
是不是 Kotlin
而是:
是否依赖某个平台
错误三:把 androidMain 当成 Android App
androidMain 只是 KMP Module 中的 Android 平台 Source Set。
它不是完整的 Android Application。
完整 Android App 通常还需要:
AndroidManifest
Application
Activity
Compose 页面
导航
资源
打包配置
这些通常属于:
androidApp
错误四:把 iosMain 当成 Swift 目录
iosMain 里面主要写 Kotlin。
SwiftUI 和 UIKit App 页面通常仍然位于:
iosApp
iosMain 负责的是 KMP 模块中的 iOS 实现。
错误五:认为 iosMain 不能调用 iOS API
iosMain 可以通过 Kotlin/Native 访问 Apple 平台 API。
例如:
import platform.UIKit.UIDevice
所以,iOS 的平台实现不一定全部使用 Swift 编写。
错误六:所有平台差异都使用 expect 和 actual
复杂能力全部使用 expect class,可能会导致公共层和平台层高度绑定。
对于复杂能力,更适合使用:
接口
+
平台实现
+
依赖注入
错误七:直接在 commonMain 引入平台专属依赖
例如在 commonMain 引入 Android DataStore 或仅 JVM 可用的网络库,会导致其他 Target 无法解析依赖或完成编译。
二十二、从 Android Module 思维转向 KMP Source Set 思维
Android 开发者以前考虑的是:
这段代码应该放在哪个 Module?
进入 KMP 后,需要同时考虑两个问题:
这段代码属于哪个业务 Module?
这段代码应该放在哪个 Source Set?
例如二维码解析代码:
业务模块:
core-qrcode
Source Set:
commonMain
Android 相机扫码代码:
业务模块:
core-qrcode 或 android-qrcode
Source Set:
androidMain
iOS 扫码代码:
业务模块:
core-qrcode
Source Set:
iosMain
最终结构可能是:
core-qrcode/
└── src/
├── commonMain/
│ ├── ScanResult.kt
│ ├── ScanParser.kt
│ └── ScanValidator.kt
│
├── androidMain/
│ └── AndroidQrScanner.kt
│
└── iosMain/
└── IosQrScanner.kt
所以,KMP 项目结构实际上是二维的。
第一维是:
Module 业务边界
第二维是:
Source Set 平台边界
可以表示为:
平台边界
common Android iOS Web
业务边界
core-model √ - - -
core-network √ √ √ √
core-qrcode √ √ √ √
feature-device √ √ √ √
这就是 KMP 组件化与普通 Android 组件化最大的结构差异。
二十三、总结
理解 KMP 项目结构,首先要区分三个概念:
Module
=
Gradle 构建模块
Target
=
最终编译到哪个平台
Source Set
=
哪些源码由哪些目标共享
其中:
commonMain
=
所有目标共享的公共逻辑
androidMain
=
Android 平台实现
iosMain
=
多个 iOS 目标共享的平台实现
jsMain
=
Kotlin/JS 平台实现
wasmJsMain
=
Kotlin/Wasm Web 平台实现
commonMain 适合放:
-
数据模型
-
Result 封装
-
业务规则
-
Repository
-
UseCase
-
网络逻辑
-
数据解析
-
表单校验
androidMain 适合放:
-
Android Log
-
DataStore 实现
-
Android 平台信息
-
Android SDK 适配
-
Android 网络和存储实现
iosMain 适合放:
-
iOS 日志实现
-
Keychain 或 NSUserDefaults 实现
-
Apple 平台信息
-
iOS SDK 适配
-
Objective-C 和 Apple Framework 调用
而:
androidApp
iosApp
webApp
负责的是不同平台的应用入口、UI、资源、生命周期和强平台能力。
最终可以用一张图总结:
sharedLogic
│
┌─────────────┴─────────────┐
│ │
commonMain 平台 Source Set
│ │
公共业务逻辑 ┌────────────┼────────────┐
│ │ │
androidMain iosMain jsMain
│ │ │
Android iOS API Web API
实现 实现 实现
│ │ │
androidApp iosApp webApp
Android 开发者需要建立的新思维是:
Module 负责划分业务边界,Source Set 负责划分平台边界。
理解了这一点,再看 commonMain、androidMain 和 iosMain,就不会再把它们误认为三个独立 Module。
也能理解为什么:
import platform.UIKit.UIDevice
可以出现在 iosMain 中。
因为 iosMain 本来就是面向 iOS Target 编译的平台实现代码。
下一篇预告
下一篇将继续深入 KMP 的核心设计:
《KMP 的本质:公共业务逻辑 + 平台差异实现》
我们会重点讲清楚:
- 是不是所有代码都要抽接口
- 哪些代码可以直接写进
commonMain - 哪些能力才需要抽象平台接口
expect/actual和接口抽象应该怎么选择- 为什么平台差异不应该泄漏到业务层
- Logger、TokenStorage、扫码和地图应该分别怎么设计
- KMP 为什么不是魔法,本质仍然是抽象、依赖倒置和平台实现
下一篇会从“项目目录放在哪里”,进一步进入“代码边界应该怎么设计”。
更多推荐



所有评论(0)