要点
开山篇讲了 KMP 的定位与工程结构,本篇把”共享层”的语法吃透,只回答三个问题:源集(source set)怎么决定”哪段代码在哪个目标上编译”、expect/actual 的规则与三种形态、语言层边界——哪些 API 在 commonMain 里天然不存在。版本基线:Kotlin 2.2 稳定线(2025-06 GA),2.1.x 仍是存量主力。速查见同系列参考字典,本篇讲”为什么”,字典管”忘了来查”。
一、源集:不是目录约定,是编译边界
先说死最容易误判的一点:commonMain、androidMain、iosMain 不是三份平行工程,而是一条编译边界的链。Kotlin Gradle 插件自 1.9.20 起自动应用默认层级模板,目标之间的公共层自动合成中间源集。Android 加三个 iOS 目标(iosArm64 真机、iosSimulatorArm64 Apple Silicon 模拟器、iosX64 Intel 模拟器)后,结构如下:
composeApp/src/
├── commonMain/kotlin/ # 所有目标共享:模型、业务规则、Compose UI
├── iosMain/kotlin/ # 中间源集:三个 iOS 目标自动共享(模板自动合成)
└── androidMain/kotlin/ # Android 目标独有:MainActivity 等平台件
| 源集 | 编译到哪 | 能引用什么 |
|---|---|---|
| commonMain | 全部目标各编译一份 | 纯 Kotlin,以及声明支持多平台的库(Ktor、kotlinx-serialization 等) |
| iosMain | 三个 iOS 目标 | commonMain 的一切 + platform.*(Foundation、UIKit 等 Kotlin/Native API) |
| androidMain | Android 目标 | commonMain 的一切 + android.* 与大部分 java.* |
反推一件事:commonMain 里每个文件会对着每个目标各编译一次,任何目标不认识的 API 就整体编译失败。”把 UIKit 调用塞进 commonMain 靠运行时判断绕过去”不是难,是不可能——平台泄漏在编译期被拦下,而不是上线后崩给你看。⚠️ 常见坑:新目标加进 targets 后,commonMain 里已有的每个 expect 都要补对应 actual,契约按目标数量倍增——expect 必须克制,只集中在平台能力的薄薄一层。
二、expect/actual:四条规则与三种形态
expect/actual 是编译期契约:commonMain 用 expect 声明”每个目标都得有”,各平台源集用 actual 兑现。规则不多,每条都由编译器强制:
| 规则 | 内容 | 违反的后果 |
|---|---|---|
| expect 不写实现 | 只声明签名,不许有实现体 | 编译报错 Expected declaration must not have a body |
| actual 逐字对齐 | 同包、同名、同签名;默认参数值只能写在 expect 侧 | 编译失败,点名不匹配的声明 |
| 每个目标必须有 actual | 缺一个目标就缺一个编译单元 | 编译失败,点名缺哪个目标 |
| actual typealias 可顶替 | expect class 可由平台现成类直接顶替 | 类型必须完全一致,不能只是长得像 |
三种形态与两个平台的 actual,一段看全:
// commonMain/Platform.kt —— 只写契约,没有一行实现
expect fun platformName(): String // 形态一:函数
expect class TaskQueue { // 形态二:类,只声明成员签名
fun enqueue(job: () -> Unit)
}
expect class Counter(value: Int) { fun addAndGet(delta: Int): Int }
// androidMain/Platform.android.kt
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.RELEASE}"
actual typealias Counter = java.util.concurrent.atomic.AtomicInteger // 平台现成类直接顶替
// iosMain/Platform.ios.kt
import platform.UIKit.UIDevice
actual fun platformName(): String = "iOS ${UIDevice.currentDevice.systemVersion}"
actual typealias Counter = kotlin.concurrent.AtomicInt // Kotlin/Native 自带原子类,签名恰好逐字一致
两个容易漏判的细节:其一,函数与属性的 expect/actual 完全稳定,类(class)的仍带 Beta 提示,公开 API 要留升级余地;其二,actual 不能重复 expect 侧的默认参数,默认值写一处各平台共享。反模式点名:用 expect/actual 携带业务逻辑——三个 actual 各有业务分支,说明差异没抽象掉。actual 应短到一眼看完,出现 if 分支和状态字段就要警惕。
三、语言层边界:commonMain 里没有的东西
会写 Android 的 Kotlin,进 commonMain 第一天就会撞墙,因为一大批”理所当然”的 API 不在:
| 需求 | JVM/Android 的直觉写法 | commonMain 的正确写法 |
|---|---|---|
| 时间与格式化 | java.time、SimpleDateFormat | kotlinx-datetime:Clock.System.now()、Instant/LocalDate,格式化取字段自己拼 |
| 正则 | java.util.regex | kotlin.text.Regex,全目标可用 |
| JSON 序列化 | Gson/Jackson(靠反射) | kotlinx-serialization(编译期生成,无反射也能跑) |
| 反射改字段 | kotlin-reflect | Kotlin/Native 没有反射,换显式映射或 sealed class 分派 |
| 异步并发 | 协程 + Flow | 原样可用——多平台库,一行不改跨端 |
记住两行的分量:协程与 Flow 在 commonMain 是原生公民,Flow<List<Task>>、suspend fun 随便写;而”没有反射”影响的是选库标准——靠反射的 JVM 库(Gson、Retrofit、大部分注解处理器)进不了 commonMain。⚠️ 常见坑:JVM 目标上 java.time 可用,于是”Android 上能编译、到 iOS 才炸”的代码很常见。纪律是业务逻辑从第一行起就写在 commonMain,让 iOS 编译器替你把关。
四、互操作注解:给 iOS 侧一个体面的 API
Kotlin 编译成 iOS 框架后,Swift 侧看到的是 Objective-C 头文件。三个注解决定”Swift 同事拿到的 API 长什么样”——其中 @Throws 是安全项:不标它,Kotlin 异常穿过 Swift 边界直接崩溃且无法被 catch:
import kotlin.Throws
import kotlin.native.HiddenFromObjC
@Throws(TaskParseException::class) // 列出的异常映射为 NSError,Swift 可 try/catch
fun parseTasks(raw: String): List<Task> = json.decodeFromString(raw)
@HiddenFromObjC // Kotlin 1.8.0+;内部工具类不给 Swift 生成接口
internal class TaskCache { /* ... */ }
| 注解 | 作用 | 有什么坑 |
|---|---|---|
| @Throws | 把列出的 Kotlin 异常映射为 NSError,Swift 可 try/catch | 只对标注的类型生效,没标就抛 = 崩溃 |
| @HiddenFromObjC | 声明不进 Obj-C 头文件,Swift 也看不到 | 需 @OptIn(ExperimentalObjCRefinement::class);想”留给 Swift”该用下一个 |
| @ShouldRefineInSwift | 提示 Swift 侧用 refinement 包装替换原始声明 | 只是提示,包装代码要 Swift 侧自己写,忘写等于没标 |
优先级:@Throws 守住所有会被 Swift 调用的业务函数,再考虑后两个 API 洁癖注解。
这篇教会了什么,下一步去哪
收束:源集是编译边界,commonMain 对每个目标各编译一次;expect/actual 是编译期契约,typealias 可顶替现成实现;语言层边界记三样——java.time 换 kotlinx-datetime、反射换 kotlinx-serialization、协程 Flow 原样跨端;@Throws 是 iOS 侧的安全底线。下一步:参考字典速查配置与库坐标,双端待办实战把语法放进可跑的工程;Kotlin 本体补课去Kotlin 语法要点,每一项在 commonMain 原样成立。
版本核实(以官方文档为准):Kotlin 2.2.0 于 2025-06 发布,小版本以 kotlinlang.org 发布页为准;KMP 与默认层级模板自 Kotlin 1.9.20(2023-11)起为现行行为;expect/actual 规则与 @Throws、@HiddenFromObjC、@ShouldRefineInSwift(1.8.0 引入)以 kotlinlang.org 互操作文档为准。