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 字符串行为也有细微差别。

判断一条 API 能否进 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 与接口注入的分工关系画在一起,便于在写共享代码时快速定位。

共享代码分层决策与平台差异隔离 这段代码依赖平台能力吗? 否 commonMain 纯 Kotlin 逻辑 是 需要隔离 进入下一层判断 差异是平台能力还是业务策略? 平台能力 expect / actual 编译期绑定,零开销 业务策略 接口 + 依赖注入 可替换、可测试 平台源集提供 actual 实现 / 接口实现 androidMain iosMain jvmMain / desktop jsMain / wasm 验证方式 commonTest 用假实现跑逻辑 · 各平台源集跑 actual 冒烟测试 · 检查 JS 大整数精度 确认无 java.* 泄漏进 commonMain

验证共享代码是否真的“跨平台”,不能只看 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 实现中。这样共享代码才能既保持高复用率,又能在每个平台上稳定编译与运行。