expect/actual — Kotlin Multiplatform的机制,允许在通用代码中声明平台相关的API。关键字expect在commonMain中创建函数、类或属性的约定,而关键字actual为每个平台提供具体实现。编译器检查每个expect声明在所有目标平台上都有对应的actual实现。根据JetBrains, 2025的数据,80%的KMM项目使用该机制实现平台业务逻辑。
要点
expect/actual — 是Kotlin Multiplatform用于实现面向平台编程的声明式机制。它允许在通用模块中描述一次API(expect),并为每个平台单独实现(actual)。与接口不同,expect/actual不会创建虚拟调用——编译器在编译阶段连接expect和actual声明,消除了动态分派的开销。
expect/actual的历史始于2017年Kotlin Multiplatform的出现。最初该机制被称为expect/actual declarations,是实验性的。在Kotlin 1.2中添加了expect注解,在Kotlin 1.3中expect/actual对类和函数成为稳定特性。该机制逐步扩展:Kotlin 1.6增加了对companion对象的expect/actual支持,Kotlin 1.7——对enum类,Kotlin 2.0——对typealias。
expect/actual的关键特性是编译级安全性。如果开发者在commonMain中添加了expect声明但忘记为iOS提供actual实现,编译器将报错。这防止了反射或动态加载平台代码方法中典型的运行时错误。
expect/actual机制在source set级别工作——Kotlin Multiplatform的模块系统。所有平台可访问的通用代码位于commonMain source set中。平台相关代码——在iosMain、androidMain、macosMain等中。关键字expect在commonMain中声明API,而关键字actual在平台source set中提供实现。编译器在代码生成阶段将它们连接起来,将对expect函数的调用替换为目标平台对应的actual实现。
典型KMM项目中的source set层次结构如下:commonMain包含expect声明,iosMain和androidMain包含actual实现。编译iOS时使用iosMain中的actual,编译Android时使用androidMain中的actual。Source set可以是中间级别的(例如针对特定架构的iosArm64Main),从而允许为不同设备细化实现。
// commonMain — expect声明
expect fun getPlatformName(): String
// androidMain — Android的actual
actual fun getPlatformName(): String = "Android"
// iosMain — iOS的actual
actual fun getPlatformName(): String = "iOS"
Kotlin编译器在处理expect/actual时检查几个条件。每个expect声明必须为每个活动平台具有actual实现。actual声明的签名必须与expect签名匹配(@OptionalExpectation注解可以放宽此要求)。访问修饰符、返回类型和参数必须相同。编译器还检查expect和actual声明之间没有循环依赖。
expect/actual支持多种声明类型。最常用的是expect/actual函数用于平台操作,expect/actual类用于需要原生实现的对象,以及expect/actual属性用于常量和设置。每种类型都有自己的使用规则和限制。
Expect/actual函数——最简单和最常用的类型。它们用于调用平台API,如获取时间、读取文件或发送HTTP请求。Expect/actual类用于创建直接与原生代码交互的对象(例如访问相机、地理位置或安全存储)。Expect/actual属性(val)适用于平台常量——操作系统名称、SDK版本或系统目录路径。
| 声明类型 | 关键字 | 使用示例 |
|---|---|---|
| 函数 | expect fun / actual fun | 获取设备唯一标识符 |
| 类 | expect class / actual class | 访问SecureStorage(Keychain / EncryptedSharedPreferences) |
| 属性 | expect val / actual val | 当前平台(iOS / Android) |
| 枚举类 | expect enum / actual enum | 可用应用权限列表 |
| 类型别名 | expect typealias / actual typealias | 平台特定的网络响应类型 |
并非所有Kotlin构造都可以与expect/actual一起使用。Expect声明不能包含主体——只有签名。Expect类不能有带参数的构造函数(必须为空的主构造函数)。对于enum expect/actual,所有常量在expect和actual中必须相同。Expect属性必须是val(而非var),因为在通用模块中为平台属性存储状态没有意义。
让我们看看从简单函数到完整类的expect/actual实际示例。基本案例——获取平台名称用于UI。更复杂的示例包括访问原生存储和处理平台线程。
// commonMain — 安全存储的expect类
expect class PlatformStorage {
fun save(key: String, value: String)
fun get(key: String): String?
fun remove(key: String)
}
// androidMain — Android上的actual
actual class PlatformStorage {
private val prefs = AppContext.getSharedPreferences("secure", 0)
actual fun save(key: String, value: String) { prefs.edit().putString(key, value).apply() }
actual fun get(key: String): String? = prefs.getString(key, null)
actual fun remove(key: String) { prefs.edit().remove(key).apply() }
}
在这个示例中,expect类PlatformStorage定义了一个简单键值存储的约定。在Android上,实现使用SharedPreferences;在iOS上——Keychain或NSUserDefaults。得益于expect/actual,commonMain中的业务逻辑调用save/get/remove,而无需了解平台实现。
// iosMain — 使用Keychain在iOS上的actual
actual class PlatformStorage {
actual fun save(key: String, value: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecValueData to value.encodeToByteArray()
)
SecItemAdd(query, null)
}
actual fun get(key: String): String? {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecReturnData to true
)
val result = mutableMapOf<String, Any>()
return if (SecItemCopyMatching(query, result) == errSecSuccess)
result[kSecValueData]?.toString()
else null
}
actual fun remove(key: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key
)
SecItemDelete(query)
}
}
在设计expect/actual API时应遵循几个原则。最小化expect声明的数量——通用代码越多,维护越容易。只对那些在平台间确实不同的API使用expect/actual。对于其余代码使用带有工厂或依赖注入的接口,这简化了测试。
建议按主题模块对expect声明进行分组,而不是混合在同一个文件中。例如,Storage.kt用于存储的expect声明,Platform.kt用于操作系统相关的expect函数,Analytics.kt用于分析类的expect声明。这简化了KMM项目平台表面的导航和理解。每个actual文件应位于相应的source set中:androidMain、iosMain、desktopMain等。
通过expect fun与actual fun(其中actual使用通用代码)实现默认实现是一种常见的反模式。如果平台实现与默认实现没有区别,则不需要expect/actual。在这种情况下,在commonMain中使用简单函数。同时避免对简单的getter使用expect/actual——使用带有常量的expect val。
正确的expect/actual代码结构对项目的可读性至关重要。每个expect/actual模块应有一个单一的入口点。组织示例:commonMain/kotlin/com/project/platform包含expect声明,androidMain/kotlin/com/project/platform——Android的actual,iosMain/kotlin/com/project/platform——iOS的actual。文件和包名称在expect和actual中应一致,以便开发者快速找到相应的实现。
带有平台工厂的接口——expect/actual的主要替代方案。不必使用expect类,可以在commonMain中声明接口,并在平台模块中创建具体类。工厂或依赖注入容器在运行时提供正确的实现。这种方法更适合测试,因为接口可以被模拟。
依赖注入(Koin、Kodein)——更灵活但性能较低的方法。DI容器为每个平台单独配置,并向通用代码提供平台依赖。与expect/actual不同,注入发生在运行时,允许替换实现以进行测试。另一方面,DI配置错误只有在运行时才会被发现,而不是在编译阶段。
| 方法 | 编译时检查 | 测试灵活性 | 运行时开销 |
|---|---|---|---|
| expect/actual | 完全 | 低(actual无法模拟) | 零(编译时绑定) |
| 接口 + 工厂 | 部分 | 高(可模拟) | 最小(虚拟调用) |
| 依赖注入 | 无(运行时) | 高 | 中等(DI代理) |
expect/actual与替代方案之间的选择取决于上下文。对于关键性能(游戏引擎、实时处理),expect/actual因零开销而更受青睐。对于业务逻辑(仓库、用例),最好使用带有DI的接口以简化测试。组合方法——expect/actual用于底层平台操作,接口用于业务逻辑层——在大多数生产级KMM项目中采用。
常见问题
expect/actual在编译阶段绑定实现而不需要虚拟调用,而接口在运行时绑定。expect/actual保证所有平台都有实现,接口需要运行时检查。
是的,从Kotlin 1.7开始支持expect enum。expect和actual枚举中的所有常量必须相同。不同平台上常量值不同是编译错误。
编译器会为每个缺少actual实现的平台报错。在为所有expect声明添加相应的actual实现之前,项目将无法构建。
不可以。expect和actual必须位于不同的source set中。expect在commonMain或中间source set中,actual在平台source set中。将expect和actual放在同一个source set中是编译错误。
使用带有平台测试source set的commonTest来测试expect/actual。在commonTest中编写expect测试,为每个平台编写actual测试。集成测试在每个目标平台上单独运行。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。