前面几节都在 Android / JVM 范围内,这一节把视野打开到 Kotlin Multiplatform(KMP)。这是近年 Android 面试里的新兴高频题——不是因为它有多难,而是因为很多人只知道"跨平台"四个字,答不出边界在哪里、什么时候不该用。这一节的判据很清楚:KMP 的价值在"共享业务逻辑",不在"共享 UI"。
先把结论放在前面:KMP 的核心能力是"同一份 Kotlin 代码编译到多个平台"。工程上真正划算的共享范围是 domain 层与 data 层(模型、网络、存储、算法),UI 层仍由各端原生实现(Android 用 Compose/View,iOS 用 SwiftUI/UIKit)。原因是各端 UI 体系与交互规范差异太大,硬共享 UI 会让 expect/actual 失控,反而拖慢迭代。
机制背后的执行路径
KMP 的技术底座是 expect / actual 这对关键字:
// commonMain:声明"这里需要一个平台实现"
expect class PlatformClock {
fun nowMillis(): Long
fun locale(): String
}
// androidMain:Android 侧真实实现
actual class PlatformClock {
actual fun nowMillis() = System.currentTimeMillis()
actual fun locale() = Locale.getDefault().toLanguageTag()
}
// iosMain:iOS 侧真实实现(Kotlin/Native,Objective-C 互操作)
actual class PlatformClock {
actual fun nowMillis() = NSDate().timeIntervalSince1970.toLong() * 1000
actual fun locale() = NSLocale.currentLocale.languageCode
}
编译时,编译器把 common 里的 expect 声明与各端的 actual 实现绑定。这条机制是理解 KMP 一切取舍的钥匙:每一个 expect 都要在每个目标平台各写一份 actual,所以它的成本是"平台数 × 抽象数量"。
KMP 目标平台演进路线也值得知道:早期是 JVM + JS + Native(Native 成熟后 Android 桌面端共用),后来 Kotlin/Wasm 加入。commonMain 可以按源集再细分:commonMain(全平台共享)、androidMain(JVM 系共享,含 Android + 桌面)、nativeMain(所有原生共享)、iosMain(iOS 专属)。这个层次结构直接决定了代码该写在哪一层。
真实工程场景的推演
场景一:网络层共享。Retrofit/OkHttp 是 JVM 库,Android 直接用;iOS 侧要用 Ktor(Kotlin/Native 有实现)。于是接口定义与响应模型放 common 层,HTTP 客户端用 expect 注入:
// commonMain
interface UserApi {
suspend fun fetch(id: String): User }
expect class HttpClientFactory {
fun create(): HttpEngine
}
代价是:接口定义要避开 JVM 特有类型(不能用 java.io.File、不能用 CompletableFuture),否则 iOS 侧无法实现。这是 KMP 最常见的"写着写着才发现不通用"的时刻。
场景二:存储层共享。Room 是 Android 专属,iOS 用 Core Data/SQLite。两个都写一遍 ORM 成本极高,通常做法是共享数据模型与查询逻辑的定义,平台侧各自实现。KMP 现在有 KMP 版的 Room 支持多端,把这一块简化了不少。
场景三:应该拒绝共享的部分。导航、权限、UI 组件——各端交互规范不同(Android 的返回键、iOS 的手势、桌面端的窗口系统),共享后要么 expect 爆炸,要么做成"四不像"。这是"会复盘的人答得好"的原因:能说清哪些不该共享,比能列共享清单更有价值。
场景四:收益要算账。KMP 适合"多端发布 + 业务逻辑稳定 + 团队有 Kotlin 基础"。若只有 Android 一个端、或业务还在快速试错、或两端 UI 差异极大,KMP 的抽象成本大于收益。
最常见的坑是
把平台 UI 逻辑塞进共享层,expect/actual 失控反而拖慢迭代。 根因是把"能共享"当成"应该共享"。修法:划定共享边界——common 只放 data 与 domain,ui 各端独立;每个新增 expect 都要问"iOS 与桌面端各自实现会不会比写两遍更麻烦"。
其次是在 common 层用了 JVM 特有 API(如 java.util.Date、java.io.File、Regex 的部分重载)。修法:common 层严格依赖 Kotlin 标准库与 KMP 版本库(kotlinx-datetime、kotlinx-io),配 IDE 提示确保没有 JVM-only import。
还有一个更隐蔽的坑:iOS 侧 Objective-C 互操作的桥接成本被低估。Kotlin/Native 生成的类型要在 Swift 侧可用,需要处理可空性、集合类型映射、密封类暴露等问题,前期投入不小。修法:共享层返回"扁平"的简单类型(基本类型、String、List),复杂对象在平台侧再包一层。
现场手写这一段就够了
// commonMain:只依赖 Kotlin 标准库与 KMP 库
expect class Log {
fun d(tag: String, msg: String)
fun i(tag: String, msg: String)
}
expect fun platformName(): String
expect class Files private constructor() {
fun readText(path: String): String
fun writeText(path: String, text: String)
companion object {
fun default(): Files }
}
// commonMain:业务层完全共享,零 expect
data class User(val id: String, val name: String, val age: Int)
class UserRepository(private val api: UserApi, private val log: Log) {
suspend fun profile(id: String): Result<User> = runCatching {
log.d("UserRepo", "fetch $id")
api.fetch(id).also {
log.i("UserRepo", "ok ${it.id}") }
}
}
// androidMain:JVM 实现
actual class Log {
actual fun d(tag: String, msg: String) = Log.d(tag, msg)
actual fun i(tag: String, msg: String) = Log.i(tag, msg)
}
actual fun platformName() = "Android"
actual class Files private constructor() {
actual fun readText(path: String) = java.io.File(path).readText()
actual fun writeText(path: String, text: String) = java.io.File(path).writeText(text)
actual companion object {
actual fun default() = Files() }
}
// iosMain:Kotlin/Native 实现
actual class Log {
actual fun d(tag: String, msg: String) = NSLog("D/%@: %@", tag, msg)
actual fun i(tag: String, msg: String) = NSLog("I/%@: %@", tag, msg)
}
actual fun platformName() = "iOS"
actual class Files private constructor() {
actual fun readText(path: String) = NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncodingEncoding, error = null).toString()
actual fun writeText(path: String, text: String) {
text.writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null) }
actual companion object {
actual fun default() = Files() }
}
// 源集层次:commonMain → androidMain / nativeMain → iosMain
// 依赖声明(build.gradle.kts 片段)
// commonMain deps: kotlinx-coroutines-core, kotlinx-datetime
// androidMain deps: retrofit, okhttp
// iosMain deps: ktor-client-darwin
关键行解读:expect 只在 common 声明契约;actual 在各平台实现,必须都实现否则编译不过——这正是约束边界的机制;UserRepository 里零 expect,说明业务层完全可共享;iOS 侧返回扁平类型降低 Swift 桥接成本;源集层次决定"能放什么代码"。
面试追问四连
"KMP 适合共享哪些代码?" 答:模型、算法、网络客户端抽象、本地存储、纯业务规则。UI、导航、权限各端独立。判据是"两端实现是否比共享更麻烦"。
"expect/actual 的代价是什么?" 答:每个 expect 要在每个目标平台各写一份 actual,成本是"平台数 × 抽象数";抽象会阻碍两端的差异化优化,平台特有的最佳实践无法直接落地。
"KMP 和 React Native / Flutter 有什么不同?" 答:RN/Flutter 共享 UI 层(自绘或同构组件),KMP 共享业务层而 UI 各端原生。KMP 的产物体积与性能更接近原生,UI 一致性成本更高;RN/Flutter 反之。
"为什么 iOS 侧成本比 Android 高?" 答:Kotlin/Native 与 Objective-C/Swift 互操作需要处理可空性标注、集合映射、密封类暴露等问题,且无法直接用 iOS 生态的成熟库(要么等 KMP 移植,要么走 actual 封装)。
落地建议
1. 先划边界再写代码:在项目启动时定好"common 只放 data/domain",UI 各端独立,并写进架构文档;每个新增 expect 走一次边界评估。 2. common 层严格限制依赖:只用 Kotlin 标准库与 KMP 版本库;配 IDE 检查禁止 java.* import 出现在 commonMain。 3. iOS 侧返回扁平简单类型(基本类型、String、List、Map),复杂模型在平台侧包一层,降低 Swift 互操作成本。 4. 自动化预防:为 common 层写纯 JVM 单测(可跑在 JVM 目标上,成本最低),业务规则用这套测试保证跨端一致;平台特有逻辑各端单独写测试。
给正在准备面试的你 把这题画成"分层共享图"。从上到下四层:最上层"UI 层"分成左右两个框(Android: Compose/View ‖ iOS: SwiftUI/UIKit),中间用虚线隔开,标"各端原生,不共享";下面"domain 层"是一个横贯的大框(模型、业务规则、UseCase),标"✅ 全量共享";再下面"data 层"横贯大框(网络抽象、存储抽象),标"✅ 共享接口,实现用 expect/actual";最底层"platform 层"分成 Android(JVM)与 iOS(Native)两个实心框,标"平台实现"。图右侧竖排写"expect 成本 = 平台数 × 抽象数"。
再补一个高分追问答案:"什么情况下 KMP 是错的选择?" 四种:①只有单一端发布,共享没有收益只有抽象成本;②业务仍在快速试错,抽象会拖慢变化;③两端 UI 与交互差异极大(如桌面端与移动端),共享 UI 反成拖累;④团队没有 Kotlin 基础,KMP 的编译配置与心智成本高于收益。能主动说出"这个方案不适用"的场景,通常比只会夸方案更能赢得信任。
复习时别孤立刷题:Flow 与 RxJava 对比——上一节做的是"同一问题的两种库选型",本节做的是"同一份代码的两种平台落地",都在训练技术选型的判断力。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Flow-与-RxJava-对比:响应式迁移指南
下一篇预告:Kotlin-2.x-演进:K2-编译器带来了什么
有任何问题欢迎在评论区留言交流。