前言

Android 开发者第一次打开 KMP 项目时,通常会看到这样的目录:

sharedLogic/
└── src/
    ├── commonMain/
    ├── androidMain/
    ├── iosMain/
    ├── jsMain/
    └── commonTest/

看到这里,很容易产生一连串疑问:

  • commonMain 到底是什么?
  • androidMain 是不是另一个 Android Module?
  • iosMain 里面写的是 Kotlin,为什么可以调用 iOS API?
  • iosMain 和 iosApp 是什么关系?
  • jsMain 是不是就代表 Web?
  • sharedLogicsharedUIandroidAppiosApp 应该怎么组织?
  • expect 和 actual 到底放在哪里?
  • 一个 KMP Module 为什么会有这么多目录?

对于 Android 开发者来说,理解 KMP 项目结构最大的难点,不是 Kotlin 语法,而是需要从原来的:

Android Module 思维

逐步转变为:

Module
+
Target
+
Source Set

这三个概念共同组成的 KMP 项目结构思维。

这一篇就从 Android 开发者熟悉的组件化项目出发,把 commonMainandroidMainiosMain 之间的关系彻底讲清楚。


一、先从 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 内部还存在:

  • main
  • debug
  • release
  • test
  • androidTest

这些不同的 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

更准确地说,是 iosArm64MainiosSimulatorArm64Main 等目标 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.UIKitplatform.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 负责划分平台边界。

理解了这一点,再看 commonMainandroidMain 和 iosMain,就不会再把它们误认为三个独立 Module。

也能理解为什么:

import platform.UIKit.UIDevice

可以出现在 iosMain 中。

因为 iosMain 本来就是面向 iOS Target 编译的平台实现代码。


下一篇预告

下一篇将继续深入 KMP 的核心设计:

《KMP 的本质:公共业务逻辑 + 平台差异实现》

我们会重点讲清楚:

  • 是不是所有代码都要抽接口
  • 哪些代码可以直接写进 commonMain
  • 哪些能力才需要抽象平台接口
  • expect/actual 和接口抽象应该怎么选择
  • 为什么平台差异不应该泄漏到业务层
  • Logger、TokenStorage、扫码和地图应该分别怎么设计
  • KMP 为什么不是魔法,本质仍然是抽象、依赖倒置和平台实现

下一篇会从“项目目录放在哪里”,进一步进入“代码边界应该怎么设计”。

Logo

一站式 AI 云服务平台

更多推荐