同一套体系:KMP 中进阶的 expect/actual
原文作者:KMP Bits
2026 年 7 月 6 日 · 阅读约 7 分钟
平时到这里我会伸手去围场(paddock)。但这个月整个星球都在看同一场赛事,所以就这一次,我把维修巷换成了球场。下次再回到赛箱。
看一场世界杯,你会注意到一件事。几乎每支球队都排出同一种阵型的变体。后场四人、中场三人、锋线三人。体系是共享的。变的是谁填进每个位置。一个国家把改打边锋的人放在左后卫,另一个放一个职业生涯都是后卫的人,第三个则拿一个打这个位置才六个月的孩子来凑。战术板上的位置是固定的。穿那个号码的球员是本地决策。
那就是 expect/actual,也是 KMP 最接近招牌绝活的东西。你在 commonMain 里把位置声明一次。每个平台派出自己的球员。入门版——各边一行 actual 的 expect fun——出现在每一篇入门教程里,而且确实简单。本文讲的是那之后:当你开始声明类、构造函数和类型别名,编译器开始执行那些教程从未提过的规则时,会发生什么。
位置与球员
这是大家已经熟悉的形状,好让我们说同一种语言。expect 声明住在 commonMain,没有函数体。它是角色,不是具体的人。
// commonMain(公共主源集)
expect fun homeGround(): String
然后每个源集派出自己的球员:
// androidMain(Android 主源集)
actual fun homeGround(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain(iOS 主源集)
actual fun homeGround(): String =
"iOS ${UIDevice.currentDevice.systemVersion}"
编译器的工作是确保战术板上的每个位置,在每支阵容里都恰好有一名球员。在 iosMain 里漏掉一个 actual,iOS 就不会编译。不存在「左后卫以后再补」这种事。每个槽位、每个平台,哨响之前就要齐。这种严格性就是全部意义所在,也正是声明一旦比函数更野心勃勃时,大多数意外的来源。
把位置升格为类
函数适合单块行为。一旦你想要共享状态、生命周期,或在同一个公共类型背后放上一小撮相关方法,你就会伸手去用 expect class。这时球员资料就变得细致。你不再说「有人打左后卫」,而是说「能防守、能套边、还能发界外球的左后卫」,每支阵容都必须派出一个三者都会的球员。
// commonMain(公共主源集)
expect class PlatformClock() {
fun now(): Long
fun formatted(): String
}
// androidMain(Android 主源集)
actual class PlatformClock actual constructor() {
actual fun now(): Long = System.currentTimeMillis()
actual fun formatted(): String =
SimpleDateFormat.getInstance().format(Date(now()))
}
注意 actual constructor()。这是第一个绊倒人的地方。在 expect class 上,构造函数是契约的一部分,actual 一侧必须把它声明为 actual constructor,而不能默默继承。若期望类暴露了构造函数,你派出的球员就必须能从 commonMain 以完全相同的方式调用。角色定义了你如何征召一名球员。球员不能擅自改电话号码。
关于 expect class 还有第二件事,很容易漏掉,因为你的代码照样能编译。你会收到警告:expect/actual 类仍处于 Beta。要消掉它,加上 -Xexpect-actual-classes 编译器标志。我不喜欢交付一个每次构建都自报暂定状态的语言特性,这也是我把 expect class 当作最后手段而非默认选项的原因之一。文末再细说。
派出一个已经在那个联赛踢球的人
这个模式我花了尴尬地长的时间才真正吃透。你并不总是要写一个全新的 actual class。若平台上已有一个类型,恰好做了你的 expect 所描述的事,你可以直接用 actual typealias 派出那一个。
// commonMain(公共主源集)
expect class Uuid {
override fun toString(): String
}
fun newId(): Uuid = randomUuid()
expect fun randomUuid(): Uuid
// androidMain(Android 主源集)
actual typealias Uuid = java.util.UUID
actual fun randomUuid(): Uuid = java.util.UUID.randomUUID()
没有包装器,没有委托,没有手写类把调用转发到真家伙。在 Android 上,Uuid 字面地 就是 java.util.UUID。他是已经入籍、每周都为那个联赛出场的球员。你不是在培养新人,你是在征召国内赛季已经产出的人。
陷阱也正是这个类比会让你预期到的那种。球员必须已经精确地胜任这份工作。被别名的类型必须已经具备你的 expect 声明的每一个成员,且签名匹配。你不能通过类型别名再往上钉一个额外方法。若 java.util.UUID 缺少公共代码需要的某样东西,别名就会破裂,你又得回去写一个真正的、包一层的 actual class。所以当它合身时,actual typealias 是最干净的选项;合不身时,它也根本撑不开。在你押注之前,先弄清自己处在哪一种情况。
征召规则
这一节是我希望有人在第一天就塞给我的,因为这里每一条都是我真实撞过的编译错误或警告,而且没有一条显而易见。把编译器想成 VAR 工作间。它不是来帮你进攻的。它是来冷冰冰地禁止破规之事,除了一条红线之外几乎不解释。
默认值写在位置上,不写在球员上。 若参数有默认值,它只出现在 expect 声明上。actual 不得重复它。
// commonMain(公共主源集)
expect fun teamSheet(captain: String = "the usual skipper"): String
// androidMain(Android 主源集),正确
actual fun teamSheet(captain: String): String = "Captain: $captain"
// androidMain(Android 主源集),编译错误:actual 不得指定默认值
actual fun teamSheet(captain: String = "the usual skipper"): String = "Captain: $captain"
教练把常备指令定一次。球员不能在每支阵容里重新定义它,否则你会有十一份略有不同的同一道命令。
可见性必须对齐。 actual 不能比 expect 更严格。把位置声明为 public,再试图派出 private actual,编译器会拦住你。自 Kotlin 2.0 起,actual 允许比 expect 更宽松,但绝不能更严格。
每个位置、每支阵容,没有例外。 一个 expect 在每个平台源集上需要恰好一个 actual。不能是零个,也不能是两个。这是人们最先碰到的一条,也最不令人意外:槽位空着,那个平台就构建不了。
枚举条目一一对应。 你可以声明 expect enum class,但 actual 必须携带完全相同的条目集合。你不能在 iOS 侧偷偷塞一个仅平台才有的条目。若 Android 与 iOS 确实需要不同的 case,在枚举上用 expect/actual 就是错工具,我会显式建模差异,而不是硬逼一个枚举同时当两件事。
这些都不是缺陷。它们是让共享体系真正保持共享的资格规则。公共调用方之所以能信任 PlatformClock 在各处行为一致,正因为编译器拒绝让两侧漂移。
何时根本不该派出固定球员
现在说观点,因为我见过(包括我自己)过早伸手去用 expect class。把某个具体球员钉死在一个位置上,是你能做的最不灵活的事。那是没有替补席的首发十一人。
很多时候你真正想要的是一个任何人都能填的角色,在运行时再决定。那就是 commonMain 里的接口,由平台通过普通的 expect fun 工厂提供实现,或者更好——通过依赖注入。
// commonMain(公共主源集)
interface Clock {
fun now(): Long
}
expect fun systemClock(): Clock
// androidMain(Android 主源集)
actual fun systemClock(): Clock = object : Clock {
override fun now() = System.currentTimeMillis()
}
这看起来比 expect class 仪式感更重,对一个只有两个方法的类型来说,确实略重一点。你买到的是替补席。你可以在 commonTest 里换入一个假的 Clock 而不碰平台代码,你完全绕开 Beta 警告,并且你的公共代码依赖的是抽象,而不是一个带着平台形状的具体类。需要时,替换随时可用。
所以我的规则很窄。单块行为用 expect fun。想要可替换的东西时(大多数时候都是),用接口加工厂或依赖注入(DI)。把 expect class 留给真正需要的情况:共享公共类型必须携带状态与生命周期,且没有干净接缝可把接口横跨过去。它存在是有理由的,但它是为某一份工作请来的专员,不是你每周都写进名单的那十一人。
守住那条线
expect/actual 是同一套体系,在锦标赛每支阵容下运转。形状在 commonMain 声明一次,每个平台把自己的球员填进每个槽位,编译器是拒绝让两侧踢不同比赛的裁判。容易的是 expect fun。让你换来干净代码库的那部分,是知道 actual typealias 派出的是联赛已经产出的球员,构造函数、默认值与可见性都坐在位置上而不是人身上,以及大多数时候灵活的走法是一个任何人都能填的角色,而不是钉在战术板上的一个名字。
选能完成工作的最小声明。让编译器执行资格规则,而不是与之对抗。并且把 expect class 留在替补席上,直到比赛真正需要它。
好了,足球说到这儿够了。下周维修巷上的灯会重新亮起。🏁
KMP Bits 应用可在 App Store 与 Google Play 获取——完全用 KMP 构建。