4. Kotlin共享代码基础
在 KMP 工程中,commonMain 里的 Kotlin 代码会被编译到多个目标平台:JVM、Native(iOS/macOS/Linux)、JS 甚至 Wasm。这意味着同一份源码必须满足所有目标平台的编译约束。理解“哪些语言特性可以放心用、哪些标准库 API 只能写在平台源集里、平台差异如何隔离”,是写好共享代码的第一道门槛。
一、跨平台语言特性:能用,但要用得克制
Kotlin 语言本身在 KMP 中是统一的,绝大多数语法特性在 commonMain 中都可以直接使用:类与继承、接口与默认实现、数据类、密封类与密封接口、泛型、扩展函数、委托属性、协程(kotlinx.coroutines 是官方多平台库)、内联函数与 reified 类型参数等。这些是共享业务逻辑的主力工具。
但有几类特性需要特别留意。第一是 Java 互操作:java.* 包、@JvmStatic、@JvmOverloads、synchronized、Thread、System.currentTimeMillis() 等只在 JVM 目标可用,写进 commonMain 会直接编译失败。第二是 反射:kotlin.reflect.full 在 Native 与 JS 上支持有限,序列化应优先使用 kotlinx.serialization 的编译期方案,而不是运行时反射。第三是 平台相关的类型:Long 在 JS 上实际是 Number 语义,超过 2^53 的整数会丢精度;Char 在 Native 上按 UTF-16 处理,与 JS 的 UTF-16 字符串行为也有细微差别。
commonMain 的简单方法:把光标放在符号上,看 IDE 是否提示“该声明仅在 JVM/JS/Native 中可用”。如果提示,就必须下沉到对应平台源集。二、标准库使用边界:common 与平台的分层
Kotlin 标准库在 KMP 中按源集分层:kotlin-stdlib-common 提供跨平台公共 API,各平台再叠加自己的扩展。常见的边界如下:
- 可放心使用:集合(
List/Map/Set及其操作符)、字符串基础操作、Result、require/check/error、runCatching、Duration、UInt等无符号类型、kotlin.math、kotlin.text的大部分函数。 - 需要平台实现:时间(
Clock.System.now()来自kotlinx-datetime,而非System.currentTimeMillis())、随机数(kotlin.random.Random可用,但加密安全随机需平台实现)、文件与 IO、网络、日志、UUID、加密、剪贴板、设备信息。 - 协程调度器:
Dispatchers.Default与Dispatchers.Main在 common 中可引用,但Main在 iOS 上需要主线程绑定,Android 上依赖kotlinx-coroutines-android,桌面端则依赖 Swing/Compose 的调度器。
一个典型陷阱是 String.format:它在 JVM 上存在,但在 common 中不可用。跨平台格式化应使用 kotlin.text 的 padStart、buildString 或 kotlinx-datetime 的格式化能力自行拼接。
三、平台差异处理:expect/actual 与接口注入
KMP 提供两种主流隔离手段。expect/actual 适合“同一能力、不同实现”的场景:在 commonMain 声明 expect fun platformName(): String,在 androidMain、iosMain 分别给出 actual 实现。它编译期绑定,零运行时开销,但要求每个目标都必须提供实现,且不能用于需要动态替换的场景。
接口 + 依赖注入适合“可测试、可替换”的场景:在 common 中定义 interface TimeProvider { fun nowMillis(): Long },各平台提供实现,业务代码通过构造函数注入。这样在单元测试中可以注入固定时间的假实现,避免依赖真实时钟。
// commonMain
expect fun currentTimeMillis(): Long
interface Logger {
fun log(level: String, message: String)
}
class OrderService(
private val logger: Logger,
private val clock: () -> Long = ::currentTimeMillis
) {
fun createOrder(id: String): Order {
val ts = clock()
logger.log("INFO", "order=$id created at $ts")
return Order(id, ts)
}
}
// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain
actual fun currentTimeMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000).toLong()
选择原则:如果差异是“平台能力本身”(如系统时间、文件路径、设备型号),用 expect/actual;如果差异是“业务策略或可测试性”(如日志后端、缓存实现、网络客户端),用接口注入。两者可以组合:expect/actual 提供默认实现,接口用于替换。
四、差异处理决策与验证
下面这张图把“一段代码该放在哪一层”的判断路径、以及 expect/actual 与接口注入的分工关系画在一起,便于在写共享代码时快速定位。
验证共享代码是否真的“跨平台”,不能只看 IDE 是否报错。建议在 commonTest 中为业务逻辑编写纯 Kotlin 单元测试,用假实现替换时钟、日志、存储;再在各平台源集写少量冒烟测试,确认 actual 实现能正常调用系统 API。特别要针对 JS 目标检查大整数与浮点精度,针对 Native 检查内存与线程模型(如 Dispatchers.Main 是否绑定主线程)。
commonMain 中直接使用 System.currentTimeMillis()、java.util.UUID、String.format、Thread.sleep。这些在 JVM 上编译通过,但一旦加入 iOS 或 JS 目标就会失败。建议在项目初期就配置好全部目标,让编译器尽早暴露边界问题。总结来说,KMP 共享代码的核心不是“把所有代码都塞进 common”,而是按能力边界分层:纯逻辑放 common,平台能力用 expect/actual,可替换策略用接口注入。语言特性尽量使用跨平台子集,标准库只依赖 common 层,平台差异集中到少量 actual 实现中。这样共享代码才能既保持高复用率,又能在每个平台上稳定编译与运行。