系列文章《Kotlin Multiplatform 进阶实战》· 第 2 篇 / 共 6 篇
上篇讲了工程脚手架;本篇解决 “common 该如何声明平台相关的东西”。
一、开篇:被滥用的 expect/actual
expect/actual 是 KMP 最独特的机制,也是最容易被滥用的机制。新人最常见的两种姿势:
过度抽象
:把
currentTimeMillis()也包一层 expect,结果commonMain一半代码都是空 expect interface,业务可读性归零。抽象不到位
:把 HTTP client 整块挪到 common 却不 expect 引擎选择,结果到 iOS 一编译就报
okhttp.jar not found。
这一篇不讲语法本身(语法 5 分钟看完),讲实战中如何在 5 类平台差异里正确地使用 expect/actual。
二、第一性原则:什么时候用 expect/actual
先给一条判断准则,当你犹豫时它能救命:
如果业务代码可以"在心智上忽略平台"地写下去,不要用 expect/actual。
只有当"业务必须依赖某个具体平台能力"时,才用。
判断矩阵:
记牢这个矩阵,90% 误用就消失了。
三、5 类典型平台差异:标准实现
下面 5 个 expect/actual 模板来自真实项目,直接复用即可。
3.1 时间:always use kotlinx-datetime
System.currentTimeMillis() 在 KMP 上仍是 JVM API,禁止跨平台使用。统一约定:
// commonMain
funtimestamp(): Long = Clock.System.now().toEpochMilliseconds()
不要自己写 expect fun now()。kotlinx-datetime 已经做完了,加依赖一行即可:
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
3.2 日志:expect 一个 Logger 接口
日志是 KMP 上少数需要 expect 的"基础设施型"能力。两个平台的日志 API 完全不同,做 expect 抽象能帮上层业务彻底解耦:
// commonMain
interfaceAppLogger {
fund(tag: String, message: String)
funw(tag: String, message: String, throwable: Throwable? = null)
fune(tag: String, message: String, throwable: Throwable? = null)
}
expectfuncreateAppLogger(): AppLogger
// androidMain
actualfuncreateAppLogger() = object : AppLogger {
overridefund(tag: String, message: String) = Log.d(tag, message)
overridefunw(tag: String, message: String, throwable: Throwable?) =
Log.w(tag, message, throwable)
overridefune(tag: String, message: String, throwable: Throwable?) =
Log.e(tag, message, throwable)
}
// iosMain
import platform.Foundation.NSLog
import platform.OSLog.OSLog
import platform.OSLog.os_log
actualfuncreateAppLogger() = object : AppLogger {
privateval log = OSLog(subsystem = "com.example.app", category = "default")
overridefund(tag: String, message: String) =
os_log("%{public}@", log = log, type = OSLogTypeDebug, message)
overridefunw(tag: String, message: String, throwable: Throwable?) =
os_log("%{public}@", log = log, type = OSLogTypeDefault, message)
overridefune(tag: String, message: String, throwable: Throwable?) =
os_log("%{public}@", log = log, type = OSLogTypeError, message)
}
业务代码再也不用关心"现在日志走哪条通道":
classLoginViewModel(privateval logger: AppLogger) {
suspendfunlogin(user: String) {
logger.d("Login", "user=$user try")
runCatching { auth.login(user) }
.onFailure { logger.e("Login", "fail", it) }
}
}
3.3 KV 存储:每个平台一个 actual
KV 存储是 KMP 抽象最容易做错的地方。错误做法是 expect 一个通用接口然后让两个平台都用文件实现——iOS 上文件操作比 NSUserDefaults 慢 30 倍。
正确做法:业务接口在 common 里声明,实现各自走平台最优路径。
// commonMain
interfaceKeyValueStore {
suspendfunput(key: String, value: String)
suspendfunget(key: String): String?
suspendfunremove(key: String)
funobserve(key: String): Flow<String?> // 关键:Flow
}
expectfuncreateKvStore(name: String): KeyValueStore
// androidMain
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
actualfuncreateKvStore(name: String): KeyValueStore {
val store = preferencesDataStore(name = name)
returnobject : KeyValueStore {
overridesuspendfunput(key: String, value: String) {
store.edit { it[stringPreferencesKey(key)] = value }
}
overridesuspendfunget(key: String): String? =
store.data.first()[stringPreferencesKey(key)]
overridesuspendfunremove(key: String) {
store.edit { it.remove(stringPreferencesKey(key)) }
}
overridefunobserve(key: String) =
store.data.map { it[stringPreferencesKey(key)] }
}
}
// iosMain
import platform.Foundation.NSUserDefaults
actualfuncreateKvStore(name: String): KeyValueStore {
val defaults = NSUserDefaults(suiteName = name)!!
returnobject : KeyValueStore {
overridesuspendfunput(key: String, value: String) {
defaults.setObject(value, forKey = key)
}
overridesuspendfunget(key: String): String? =
defaults.stringForKey(key)
overridesuspendfunremove(key: String) {
defaults.removeObjectForKey(key)
}
overridefunobserve(key: String): Flow<String?> = callbackFlow {
val center = NSNotificationCenter.defaultCenter
val observer = center.addObserverForName(
name = NSUserDefaultsDidChangeNotification, null, null
) { trySend(get(key)) }
awaitClose { center.removeObserver(observer) }
}
}
}
关键技术点:
DataStore 自带 Flow,NSUserDefaults 需要用
callbackFlow主动观察NSUserDefaultsDidChangeNotification。NSUserDefaults 的 Flow 是冷流,订阅时才挂监听,适合"设置页"这种按需监听场景。
单元测试通过
createKvStore("test")注入,统一接口确保commonTest写一次即可跨端跑。
3.4 HTTP 引擎:Ktor 的 expect 在哪一层
HTTP 业务接口在 common,但引擎选择在不同平台。Ktor 用了 Dependency 模式,这一层不需要 expect,而是依赖注入:
// commonMain
val httpClient = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpTimeout) { requestTimeoutMillis = 30_000 }
expectSuccess = true
}
// androidMain
HttpClient(OkHttp) {
engine { config { retryOnConnectionFailure(true) } }
install(Logging) { level = LogLevel.INFO }
commonConfig() // 上面 common 的配置
}
// iosMain
HttpClient(Darwin) {
engine {
configureRequest { setAllowsCellularAccess(true) }
// iOS 14+ 重要:处理 ATS/Cert 透明转发
}
install(Logging) { level = LogLevel.INFO }
commonConfig()
}
commonConfig() 是抽取出来的共享配置 DSL,业务、超时、JSON 解析等都在 common——这才是 Ktor 真正的价值。
3.5 原生能力:蓝牙 / 推送 / 相机
蓝牙、推送、相机这些"重原生"功能,业务上往往只在某几个 feature 用到。关键判断:把它做成独立 KMP 模块(不是 expect abstract class)。
// features/bluetooth/src/commonMain/kotlin/BluetoothScanner.kt
classBluetoothScanner(
privateval provider: BluetoothProvider // expect
) {
suspendfunscan(): List<Device> = provider.scan()
}
expectclassBluetoothProvider {
suspendfunscan(): List<Device>
}
每个平台自己实现 actual class:
// androidMain
actualclassBluetoothProvider {
privateval adapter = BluetoothAdapter.getDefaultAdapter()!!
actualsuspendfunscan(): List<Device> = withContext(Dispatchers.IO) {
// Android BluetoothLeScanner 调用
}
}
// iosMain
import CoreBluetooth.*
actualclassBluetoothScanner {
// CoreBluetooth 调用
}
为什么要把蓝牙做成独立 feature 模块而不是放进 core/platform? 因为蓝牙和账号体系一样,是"按需启用"的能力。把它们做成可选 feature 模块,能让不需要蓝牙的 App 编译时不依赖 Android SDK 或 CoreBluetooth,减少 APK 体积 1.5MB+。
四、expect/actual 的三大语法约束(容易踩)
KMP 编译器对 expect/actual 有严格约束,违反时编译器给出的错常常误导:
actual 函数签名必须 100% 对齐
:参数、返回、泛型限定一字不差,包括 default value 也要写两边。
actual 不允许拥有 expect 没有的默认参数
:
// common
expectfunlog(msg: String)
// iosMain - 错误!
actualfunlog(msg: String, level: Int = 0)// 编译失败
要么在 expect 上加默认参数,要么不写。
actual 不能使用 typealias
:
// common
expecttypealias UserId = String
// androidMain - 编译失败
// typealias 必须同步在每边
五、何时不用 expect/actual:interface + DI
下面场景优先考虑 interface + DI:
业务身份认证(容易单元测试 mock)
加密算法跨端实现(用
kotlincrypto不需要 expect)状态管理(StateFlow/Flow)
示例:把"鉴权"做成 interface:
// commonMain
interfaceAuthGateway {
suspendfuncurrentUser(): User?
suspendfunlogin(token: String): Result<User>
}
classLoginUseCase(privateval gateway: AuthGateway) {
suspendoperatorfuninvoke(token: String) = gateway.login(token)
}
注入由 Koin(下一篇会重点讲)完成,测试时替换成 FakeAuthGateway。
六、调试 expect/actual:常见错误现场
开发中 80% 的 expect/actual 错误信息长这样:
Expected function'xxx' has no corresponding actual declaration inmodule
排查步骤:
检查
iosArm64Main(或对应 target)下确实存在actual fun xxx()检查包路径一致:expect 函数所在文件包名是
com.app.platform,actual 必须是同一个检查 kotlin-stdlib 版本对齐:
gradle/libs.versions.toml里kotlin版本对应一致删掉
.build/重新编译:有时 IDE 缓存失真
另一种常见报错:Incompatible types: String and String (of platform declaration):
这是 expect 误把跨端 string 用 platform string 类型签名,导致两边签名"对得上但不相同"。用 kotlin.String 而非任何平台专属 string。
七、下篇预告
expect/actual 帮你把"差异"收敛到极致,但每加一个 actual,如何组织这些 actual 的注入、如何让 ViewModel 不依赖具体实现、如何避免在测试里反复 new actual——这些是依赖注入该登场的地方。下篇会重点讲 Koin 在 KMP 工程里的深度落地,包括跨端模块、平台模块与 ViewModel 注入,请关注。
参考阅读
官方 expect/actual 机制文档:https://kotlinlang.org/docs/multiplatform-expect-actual.html
kotlinx-datetime时间库 API
Ktor Client 多引擎配置官方指南
Touchlab 团队 “KMP Architecture Best Practices” 白皮书