KMP这名字,我在后台收到过无数次私信,问得最多的就是“它跟Flutter比到底强在哪”以及“我Android开发两年,学这个划算吗”。说实话,Kotlin Multiplatform(以下简称KMP)并不是什么新鲜概念,但它在2024到2025年这个节点上,确实到了值得投入精力去啃的阶段。原因很简单:生态成熟了、坑填平了、大厂背书也够多了,更重要的是,它解决的不只是“跨平台”这个表层的需求,而是“跨平台之后,业务逻辑和团队心智怎么统一”这个更深层的痛点。
这篇文章我不打算泛泛地讲“KMP能做什么”,那没意思。我更想跟你掰扯清楚的是,KMP这套东西从原理上到底是怎么运转的,以及在你真正动手写一个跨平台模块时,那些文档上没写清楚、但又绝对会踩到的现实问题。全文会涉及我从工程搭建到协程架构、再到各种编译报错排查的真实经历,内容偏长,但看完你应该能对KMP有一个从“听说过”到“能上手”的实质性认知。
1. 为什么是KMP:一套代码背后的博弈逻辑
在动手写第一行expect声明之前,你得先想明白一个根本问题——你为什么要让Kotlin代码在iOS上跑起来?这个问题的答案直接决定了你的架构设计和代码分层方式。
1.1 跨平台方案的取舍:UI层与逻辑层的分离
很多人一谈跨平台,第一时间想到的就是Flutter或者React Native,因为它们能做到“一套UI代码两端运行”。而KMP从一开始走的就是一条完全不同的路:它只共享逻辑层,不共享UI层。Android端你可以继续用Jetpack Compose,iOS端你可以继续用SwiftUI,二者互不干扰,但业务逻辑、数据仓库、网络请求、持久化缓存这些“硬核”部分,则通过KMP在Kotlin层统一实现。
KMP这个策略背后的逻辑其实特别务实:UI层是变化最频繁、与平台系统特性耦合最深的部分。Android的返回手势、iOS的侧滑返回、Android的曲面屏适配、iOS的灵动岛交互,这些细节天然就应该由原生代码来写。而真正消耗开发精力的,往往是那套“一万个页面都要用”的业务状态管理和数据流转逻辑。把这两者拆开,让最稳定的部分跨平台复用,让最灵活的部分留在各端手写,这就是KMP的核心博弈逻辑。
这套设计的直接好处是:改动一个下单逻辑,不再需要Android一个人、iOS一个人各写一遍,然后还要一帧一帧地对需求。代码共享之后,逻辑一致性是天然保证的,而不是靠两个同事的沟通来保证的。我自己体验下来,团队协调成本下降的幅度比代码编写成本下降的幅度还大。
1.2 KMP不是Android的附庸:它的定位是独立技术栈
还有一个很重要的认知需要纠正:KMP并不是“Android技术的延伸”,而是一套独立的、跨平台的技术栈。也就是说,你哪怕没有深厚的Android开发背景,只要Kotlin语言基础扎实,照样可以用KMP写出不错的共享模块。反过来,Android开发者用KMP时,也需要注意不能把iOS平台当成“二等公民”。
KMP在Gradle里配置的target,既包括androidTarget(),也包括iosArm64()、iosSimulatorArm64()、iosX64(),这些iOS target跟Android target是平级的。你在代码里写的expect声明,必须在iOS的actual实现里给出完整的、符合iOS平台规范的实现,不能因为Android是默认平台就优先实现Android的部分然后iOS随便糊弄。简单来说,KMP把Android和iOS放在了同一个天平上,这要求你用更中立的视角去看待两端的能力差异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零搭建第一个KMP工程:Gradle里藏着的门道
搭建KMP工程,第一关就是Gradle配置。这地方的门道特别多,很多人一上来就卡在SDK版本、插件版本和Kotlin版本的兼容性问题上。我记得有段时间,只要Kotlin版本跟Gradle版本稍微不对付,编译时就冒出各种莫名其妙的报错,比如热搜词里那条“module was compiled with an incompatible version of Kotlin”,就是典型的版本错乱导致的。
2.1 核心构建脚本的精读:每个配置项都在干什么
一个标准的KMP模块,build.gradle.kts大概长这样:
kotlin复制plugins {
kotlin("multiplatform") version "2.0.20"
kotlin("plugin.serialization") version "2.0.20"
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
listOf(
iosArm64(),
iosSimulatorArm64(),
iosX64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "SharedKit"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2")
implementation("io.ktor:ktor-client-core:3.0.0")
implementation("io.ktor:ktor-client-content-negotiation:3.0.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.0")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:3.0.0")
}
}
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin:3.0.0")
}
}
}
}
android {
namespace = "com.example.shared"
compileSdk = 35
}
这里我想重点说三个关键点。
第一,iosArm64()、iosSimulatorArm64()和iosX64()这三个target必须同时配置。真机是arm64架构,模拟器在Apple Silicon上是arm64模拟器架构,在Intel Mac上是x64架构。缺了任何一个,你都会在某种运行环境下拿不到framework包。我第一次搭工程的时候偷懒只配了iosArm64(),结果在模拟器上跑的时候死活引用不到共享模块,折腾了大半夜才反应过来是target没配全。
第二,isStatic = true这个参数。它决定生成的framework是静态库还是动态库。我强烈建议用静态库。理由是动态库在App启动时需要做动态链接,会增加启动耗时,而且偶尔会跟其他依赖的静态库产生符号冲突,排查起来特别痛苦。静态库则完全没有这个问题,代价只是编译时链接稍微慢一点,但换来的是运行时稳定。
第三,sourceSets的依赖声明。你在commonMain里写的依赖,是两端共享的。但网络引擎的实现细节在Android和iOS上完全不同——Android上通常用OkHttp,iOS上则需要用Darwin引擎(底层封装了NSURLSession)。所以ktor-client-core放在commonMain,而ktor-client-okhttp和ktor-client-darwin分别放在androidMain和iosMain里。KMP的sourceSets机制会自动帮你做这个“平台相关的依赖注入”,这也是它比Java或者传统JVM技术栈优雅得多的地方。
2.2 与现有Android工程的融合:构建产物怎么被消费
共享模块写好之后,Android端消费它最简单的方式就是直接加上依赖。如果你用的是同构项目,也就是Android App和KMP模块在同一个settings.gradle.kts里,那直接implementation(project(":shared"))就行。如果是独立的KMP模块通过maven私服分发,那就走普通的依赖坐标。
iOS端消费KMP的方式则完全不同。Xcode不认识Kotlin工程,它只认识framework。所以你需要把KMP模块构建成framework,然后在Xcode项目里嵌入。这一步通常是靠Gradle任务和Xcode的Run Script Phase配合完成,流程如下:
- 在Xcode的Build Phase里添加一个Run Script,调用
./gradlew :shared:embedAndSignAppleFrameworkForXcode。 - 这个Gradle任务会自动检测当前Xcode的环境变量(比如是模拟器还是真机、是哪个架构),然后生成对应的framework并配置好搜索路径。
- 在Swift代码里
import SharedKit,出现红色的import错误别慌,先保证Gradle任务成功执行一次。
或者用CocoaPods,在Podfile里添加pod 'SharedKit', :path => '../shared',然后pod install。不过这两年CocoaPods在Apple Silicon上偶发兼容问题,如果遇到Invalid podspec这类报错,优先考虑Swift Package Manager或者直接embedAndSign这种方式。
3. KMP运行原理的核心:expect/actual机制与平台桥接
搭建好工程之后,真正决定你共享代码能走多远的,是KMP核心的桥接机制。搞懂expect和actual这两个关键字,你基本就掌握了KMP的精髓。
3.1 expect/actual的运行时机:编译期还是运行期
很多人看到expect声明时,第一反应会把它跟接口或者抽象类类比。这个类比方向大方向没错,但有一个本质区别需要你注意——expect/actual的绑定发生在编译期,而不是运行期。
也就是说,当你声明了一个expect fun getSecureToken(): String,Kotlin编译器在编译commonMain时,会把所有用到了getSecureToken()的调用位置都标记出来。然后编译androidMain时,它会把Android的actual实现填进去;编译iosMain时,会把iOS的actual实现填进去。最终生成的字节码或二进制里,压根不存在“调用哪个平台实现”这个动态判断逻辑——它在编译的时候就已经把路由关系定死了。
这跟接口的“运行时多态”完全不同。接口是在运行期通过虚函数表找到具体实现类的,多一次间接跳转,而且需要有一个对象实例来承载。而actual实现通常被编译成静态方法或者顶层函数,调用路径更短,性能开销几乎为零。这也是KMP敢在性能敏感的核心逻辑层使用expect/actual的原因。
3.2 commonMain里能写什么,不能写什么
Kotlin的commonMain是一个特殊的source set,它的特殊性在于:编译它时,编译器并不知道最终会跑在哪个平台上。因此,所有只能在某个特定平台SDK中访问的API,都不能直接出现在commonMain里。比如Java的java.io.File、Android的android.util.Log、iOS的UIKit,统统不行。
commonMain里能用的能力,其实总结下来就是两大来源:Kotlin标准库中跨平台实现的部分,以及Kotlinx系列库中多平台化的部分。前者包括kotlin.collections、kotlin.text、kotlin.coroutines等,后者包括协程库、序列化库、日期时间库kotlinx-datetime,以及Ktor这种专门为KMP设计的网络库。
如果你的代码确实需要操作平台特有的能力,那就必须走expect/actual声明,由各平台自行实现。一个典型的例子是获取当前设备的语言设置:
kotlin复制// commonMain
expect fun getSystemLanguage(): String
// androidMain
actual fun getSystemLanguage(): String =
Locale.getDefault().toLanguageTag()
// iosMain
actual fun getSystemLanguage(): String =
NSLocale.preferredLanguages.firstOrNull() ?: NSLocale.current.languageCode ?: "en"
这样在共享代码里,你就能安全地调用getSystemLanguage(),而不用关心它是怎么取到的。整个模式的价值就在于:调用方永远只面对同一个接口形状,平台差异被隔离在最小的边界上。
3.3 与Swift的互操作:类型映射的边界问题
KMP生成的framework,最终要被Swift调用。这时候类型映射的细节就变得格外重要。Kotlin的基本类型映射到Swift大致如下:
| Kotlin类型 | Swift类型 | 备注 |
|---|---|---|
String |
String |
直接映射 |
Int |
Int32 |
注意不是Swift的Int(64位) |
Long |
Int64 |
跟Swift Int在64位设备上相同 |
Double |
Double |
直接映射 |
List<T> |
[T] |
映射为Array |
Map<K, V> |
[K: V] |
映射为Dictionary |
Unit |
Void |
对应无返回值的函数 |
| 自定义类 | 类实例 | Kotlin类直接暴露为Swift类 |
这里最大的坑是Int和Int32的映射关系。Kotlin的Int是32位整数,而Swift原生的Int在64位平台上代表64位整数。如果你在Swift里调用一个Kotlin函数,函数签名是fun getCount(): Int,那么Swift侧拿到的是Int32。如果这个值的语义是数组长度或者ID,可能没问题;但如果涉及时间戳这种可能超过32位范围的数值,就会发生溢出灾难。
规避方案有两种。第一,在共享代码里设计API时,凡是可能超过20亿的数值,一律显式使用Long;第二,如果必须用Int,在Swift侧转一下类型。我个人习惯是,凡是ID、时间戳、价格这种业务属性,全部用Long,只有纯UI层面的计数才用Int。这个习惯帮我避免了好多线上崩溃。
还有一点,Kotlin的顶层函数暴露给Swift时,会变成类方法或者全局函数,具体取决于@ObjCName注解的配置。想精细控制Swift侧的API外貌,可以在Kotlin代码里加注解适配:
kotlin复制@ObjCName("SharedUser")
class User(val id: Long, val name: String)
@ObjCName("fetchUsers")
suspend fun fetchUsers(): List<User> = ...
这样Swift侧面对的就是SharedUser和fetchUsers,命名自然,跟原生Swift API风格完全一致。不细节地打磨这一层,Swift同事会拿代码提刀来见你的。
4. 协程与跨平台异步:KMP架构里的精气神
跨平台代码最大的挑战之一就是异步编程。Android有协程和Flow,iOS有Combine和async/await。KMP怎么调和这两套异步体系?答案就是kotlinx-coroutines-core这个多平台协程库。
4.1 多平台协程的适配层:从MainScope到MainActor
协程库在KMP里的角色,比你在纯Android工程里看到的要复杂一些。它不仅要提供launch、async这些基础能力,还要为每个平台调度器提供适配。比如Dispatchers.Main,在Android上对应于主线程的Handler,在iOS上则要调度到GCD的主队列。
KMP对协程的依赖是嵌入在编译产物里的。当Swift调用一个suspend函数时,Kotlin编译器生成的框架会暴露成带completionHandler的函数,类似这样:
swift复制try await SharedKit.fetchUsers()
这背后其实是KMP框架自动把一个suspend函数编译成了Objective-C的block回调风格,然后Swift的async/await又能无缝消费。所以你在Swift侧写try await,跟调用原生Swift async函数的感觉几乎一模一样。
这带来一个重要的架构启示:在KMP共享代码里,你完全不用顾虑两端的异步生态差异,放心大胆地在commonMain里用suspend函数和Flow。你的共享仓库类、用例类,全部可以设计成suspend风格,这样两端调用方都能用自己最顺手的异步方式接入。
4.2 跨模块的协程作用域设计:谁负责取消,谁负责隔离
我的建议是,在commonMain里定义一个全局的协程作用域,但它在两端的实现要有所区别。Android上可以用SupervisorJob() + Dispatchers.Main,iOS上则用SupervisorJob() + Dispatchers.Main,其实效果差不多。但要小心一点:不要让你的共享代码随意创建GlobalScope。
全局作用域看似方便,但它最大的问题是“不可取消”。当用户在iOS上左滑退出一个页面时,SwiftUI会销毁页面,但共享代码里正在跑的协程并不会自动感知。结果就是这个请求还在后台执行,直到拿到结果却发现页面已经没了,白白浪费网络流量和电量。
更合理的做法是让调用方管理协程的生命周期:
swift复制Task {
let users = try await SharedKit.fetchUsers()
// UI更新
}
Swift的Task会自动绑定到当前的Actor上下文,页面销毁时Task也会被取消,进而取消底层的协程。我在共享代码里尽量不主动创建协程作用域,让调用方决定协程的生命周期。
如果确实需要常驻后台的任务,比如数据预拉取,那也应该在共享模块里暴露fun startPrefetch()和fun stopPrefetch(),由端侧在合适的时机(比如App进入后台、App被杀)来调度。这样比在共享代码里自作主张维护一个全局循环要安全得多。
4.3 Flow在KMP中的实际场景:连续状态流的跨端分发
Flow在KMP里很好用,尤其是做数据层观察的时候。比如你要监听一个本地数据库的变化,或者监听网络请求的状态(加载中、成功、失败),用Flow把状态发射到下端特别顺滑。
常见的设计是:
kotlin复制sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>
}
class HomeRepository(db: AppDatabase, client: HttpClient) {
fun observeHomeData(): Flow<UiState<List<HomeItem>>> = flow {
emit(UiState.Loading)
val localCache = db.homeDao().observeAll().first()
emit(UiState.Success(localCache))
val remoteItems = client.get(...)
db.homeDao().insertAll(remoteItems)
}
}
Swift侧对接Flow时,KMP会把Flow转换成AsyncSequence(需要开启实验性支持),或者你可以选择把Flow转换成回调风格的闭包。我个人更推荐Swift侧用for try await来消费Flow,语义清晰,又天然支持取消。在共享代码里用stateIn操作符把冷流转成热流时,要注意初始值和作用域的参数正确传入,否则会出现冷流多次启动、网络请求重复执行的问题。
5. 数据层架构与持久化:跨平台共享业务状态的最佳实践
跨平台共享代码里,数据层是最容易做出价值增量的一块。网络请求、JSON解析、数据库存储,这些在两端做的都是同一件事,把它们抽到共享模块里,能省下大量重复劳动。但数据层的实现细节也是坑最多的地方。
5.1 Ktor客户端的三层结构:引擎、序列化与拦截器
Ktor是KMP生态里最成熟的多平台网络库。它的设计是:核心API跨平台统一,引擎按平台分别提供,比如Android用OkHttp引擎,iOS用Darwin引擎。这个三层结构(核心层、引擎层、拦截器层)让Ktor在两端都能充分发挥平台底层网络能力的优势。
引擎层之外,序列化层你需要搭配kotlinx.serialization。注意,KMP项目里不要用Gson或者Moshi,它们不支持多平台。kotlinx.serialization是JetBrains官方为多平台设计的序列化方案,只需要在数据类上标注@Serializable,编译器插件会自动生成序列化器,基本零运行时反射。
拦截器是Ktor一个极其强大的能力。你可以把token注入、日志打印、错误码统一处理等逻辑,全部收敛到共享模块里,实现一次编写、两端生效。举个例子:
kotlin复制val client = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
logger = Logger.SIMPLE
}
defaultRequest {
header("Accept", "application/json")
}
}
client.plugin(HttpSend).intercept { request ->
val token = sessionManager.getToken()
if (token.isNotBlank()) {
request.headers.append("Authorization", "Bearer $token")
}
execute(request)
}
这种逻辑放在共享层,意味着Android和iOS的请求行为天然一致,不会再出现“Android端带上了token,iOS端忘了带”这种令人抓狂的不一致问题。
5.2 SQLDelight与多平台数据库选型
数据库层的选型,我实测推荐SQLDelight。它是KMP社区里最成熟的数据库方案,但不是唯一的选择,下面给出对比:
| 方案 | 跨平台能力 | 类型安全 | 学习成本 | 适合场景 |
|---|---|---|---|---|
| SQLDelight | 强:Android/iOS/JVM全支持 | 强:SQL编译期校验 | 中等 | 需要SQL复杂查询、成熟稳定的场景 |
| Room | 仅Android | 强 | 低 | 不推荐用于KMP,Android端可单独用 |
| DataStore | Android/iOS支持 | 弱:Key-Value | 低 | 简单配置持久化,不涉及表结构 |
| Realm | 强:Android/iOS/JS | 中等 | 中等 | 对象存储友好,但依赖较沉重 |
SQLDelight的核心理念是:你写.sq文件定义表和查询语句,编译时生成类型安全的Kotlin接口。它支持在commonMain里定义schema,然后自动在Android和iOS上创建对应的数据库实现。我在项目里用SQLDelight做离线缓存,效果非常好。特别是它支持Flow查询,数据库里任何表变化,查询方都能自动收到更新。
一个比较关键的配置问题是驱动的依赖差异。Android上需要加androidDriverFactory的依赖,iOS上则加nativeDriverFactory。好在SQLDelight的API被封装得足够好,你只需要在各自平台的source set里提供一个databaseDriverFactory的actual实现。大致逻辑是:
kotlin复制// androidMain
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(Database.Schema, context, "app.db")
}
// iosMain
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(Database.Schema, "app.db")
}
这样共享代码里所有跟数据库相关的仓库类,都可以直接用同一个Database引用来做增删改查。
5.3 缓存策略的跨平台统一:过期时间与网络回退
缓存策略是数据层最容易写出差异的地方。Android端写一个缓存30分钟、过期后走网络的逻辑,iOS端可能写的是缓存10分钟、失败后走缓存。逻辑不规范的话,两端体验差异巨大。
在KMP共享模块里,我通常把缓存和网络的编排逻辑也收进来。设计数据仓库类时,核心API如下:
kotlin复制class HomeFeedRepository(
private val local: HomeFeedLocalDataSource,
private val remote: HomeFeedRemoteDataSource
) {
fun observeHomeFeed(): Flow<HomeFeedUiModel> =
combine(
local.observeCacheFeed(),
networkStateMonitor.isOnline()
) { cached, isOnline ->
CacheUiState(cached, isOnline)
}.flatMapLatest { state ->
if (!state.hasValidCache()) {
flowOf(HomeFeedUiModel.Loading)
} else {
flowOf(state.cached.toUiModel())
}
}.onStart {
refreshIfNeeded()
}
}
这种编排逻辑放在共享层,效果立竿见影:两端用户在离线状态、弱网状态、缓存过期状态下看到的分发行为是完全一致的。你的产品经理再也不用为“为什么Android能看缓存而iOS不行”这种问题单独开需求会了。
6. 实战中的版本兼容性陷阱:那些编译报错的根源与解法
写KMP项目,你早晚会遇到各种诡异的编译问题。热搜词里那条“module was compiled with an incompatible version of Kotlin”几乎是每个KMP开发者的必经之路。这一节我把最典型的几个编译期坑都列出来,并给出我自己验证过可用的解法。
6.1 “incompatible version of Kotlin”报错的完整排查路径
这个报错的本质是:你当前项目的Kotlin编译器版本,跟你某个依赖库当初编译时使用的Kotlin编译器版本不一致,导致元数据无法被当前编译器读取。Kotlin的元数据版本兼容策略是:编译器只能向后兼容一个版本的元数据,跨度太大就会直接拒绝。
我遇到这个报错的典型场景是:项目里用了Kotlin 1.9.22,但某个库是Kotlin 2.0.20编译的,依赖解析时Gradle把库拉下来,编译器一读元数据直接爆炸。排查思路如下:
- 先用
./gradlew :app:dependencies --configuration debugRuntimeClasspath输出完整依赖树,看看是哪个库引入了高版本Kotlin编译的传递依赖。 - 检查所有Kotlin相关库的版本是否跟Kotlin版本匹配,特别是
kotlinx-coroutines、kotlinx-serialization、ktor这些。它们每个版本都有对应的最低Kotlin版本要求。 - 检查是否有库显式声明了打包了Kotlin编译器插件或者是compiler plugin的产物,比如Compose相关的库,这类库对元数据版本更敏感。
- 如果确认是某个传递依赖的原因,在
build.gradle.kts里用exclude或者resolutionStrategy强制指定成与主项目兼容的版本。
有一种特殊情况要特别留意:同一个模块被多个target编译时,如果其中某个target用了Kotlin 2.0的kotlin("multiplatform")插件,而另一个工程还在用1.9,两边同时把产物输出到同一个目录,也会出现这个报错。解决方式是清理~/.gradle/caches下对应模块的缓存,然后保证所有consuming工程使用统一的Kotlin版本。
6.2 Gradle版本与Kotlin插件的匹配关系
Gradle版本跟Kotlin插件的兼容性,严格来说并没有硬性的一一对应表,但实际踩坑经验告诉我,升级Kotlin插件版本时一定要看Gradle版本的底线要求。Kotlin 2.0的插件要求Gradle 7.6.3以上,而Kotlin 2.1则建议至少Gradle 8.4。
如果你用Gradle 5.6.4这种老版本去加载最新的Kotlin插件,报错会很直接,说插件需要更高的Gradle版本。这种情况下不要试图硬凑,老老实实升级Gradle wrapper。但升级Gradle本身也要谨慎,因为Android Gradle Plugin(AGP)和Gradle版本也有配套关系。比如AGP 8.5要求Gradle 8.7以上,AGP 8.2要求Gradle 8.2以上。所以一个KMP项目的构建工具链,实质上是Kotlin版本、AGP版本、Gradle版本三者之间找交集。
我项目里目前稳定运行的版本组合是:Kotlin 2.0.20 + AGP 8.5.2 + Gradle 8.7 + compileSdk 35。这套组合经过我的实测,在Android和iOS两端都没有出现明显的兼容性问题。
6.3 Xcode版本与KMP framework的符号冲突
iOS侧的坑,集中在framework集成阶段。最常见的错误是“duplicate symbol”或者“Undefined symbols”类的异常,通常是因为KMP framework依赖了某个库,而这个库跟App里另一个原生库也依赖了同一个底层C库,导致链接时符号重复。
处理手段有几种。第一,确认KMP framework是静态库而不是动态库,前面说过静态库在符号管理上更简单;第二,检查你是否在多个framework里嵌入了同一个底层库,如果是,考虑把底层库的依赖从KMP侧移除,由App统一提供;第三,使用xcodebuild查看详细的链接日志,定位是哪两个.o文件里的符号冲突了。
另外,Xcode的Build Setting里,ENABLE_USER_SCRIPT_SANDBOXING要设为NO,否则Run Script里的./gradlew可能没有权限执行,导致framework没生成就继续编译,报出一堆“cannot find SharedKit.framework”的诡异错误。这个问题特别隐蔽,我一度以为是路径配置错了,翻了好久的文档才发现是这个沙箱开关导致的。
7. 深入场景:用KMP做一个跨平台的实时热力图App
讲完了基础架构和技术细节,我来用一个完整示例串一遍:跨平台实时热力图App。这个场景其实非常能体现KMP的价值,因为热力图涉及实时位置数据、大量的网络请求、状态管理,还有对性能和耗电量的要求,这些恰好都在KMP的强项范畴内。
7.1 数据层:高频率坐标上报与合并
热力图App的最核心数据流是坐标点上报。用户的设备每隔几秒在后台收集位置,然后通过网络发给服务器,服务器聚合后返回热力图区块的数据。这个链路在Android和iOS上如果用两套代码写,大概率会出现某一端上报频率不对、另一端坐标序列化格式不对的问题。
用KMP实现这套逻辑,关键模块可以拆成:
kotlin复制class HeatmapClient(httpClient: HttpClient) {
fun reportLocation(point: GeoPoint) {
// 这里把经纬度、精度、时间戳推送到上报队列
}
fun observeHeatmapRegion(region: GeoRegion): Flow<HeatmapTile> {
// 观察指定区域的热力图数据,返回渐进式加载的tile结果
}
}
GeoPoint和GeoRegion都是commonMain里的纯数据类,用@Serializable注解。HeatmapTile则可以是分块返回的聚合结果,用Flow持续下发。这一整套逻辑写一次,两端直接用。
7.2 状态管理:共享ViewModel模式的边界
KMP里的ViewModel,在Android侧可以直接用androidx.lifecycle:lifecycle-viewmodel跟Jetpack Compose挂钩,在iOS侧则没有对应的官方绑定。所以“共享ViewModel”这种做法在KMP社区里一直有争论,有人觉得省事,有人觉得绑死了两端。
我的经验是:不要把UI状态管理全部塞进KMP共享层。更合理的做法是,KMP共享层只暴露数据源和业务用例,各端自行管理页面级的UI状态。比如Android端用Compose的viewModel(),iOS端用@Observable或者ObservableObject,各写各的UI状态转换逻辑。这样既保留了“业务逻辑共享”的核心收益,又避免了两端UI状态模型的强行统一带来的改造成本。
在热力图这个场景里,Android端用Compose的collectAsStateWithLifecycle收集Flow,iOS端用SwiftUI的task和for try await接收Flow数据,各自渲染自己的热力图覆盖层,而底层上报、拉取、合并逻辑完全一致。
7.3 性能优化:跨平台代码的启动速度与内存占用
跨平台代码在iOS上运行,性能有一个天然的考验:Kotlin代码编译成的是native二进制,它在启动速度和内存占用上是可以接近原生Swift代码的,但前提是你得做对优化。
首先,注意你的KMP framework的baseName是否合理。如果framework体积过大,静态链接会拖慢App启动时间。常规优化手段是启用R8混淆和资源缩减,但要注意,KMP生成的framework本身是native二进制,R8管不到它。你只能在代码层面做减法:减少不必要的依赖,避免使用重量级的反射机制,kotlinx.serialization的序列化要在编译期生成好,不要走运行时反射。
其次,热力图这种高频数据场景,协程的调度开销不可忽视。坐标上报的循环如果用delay(500)这种写法,在iOS上会正常触发主线程空闲时才运行的回调,但如果你用dispatcher = Dispatchers.Default,就要确保没有在UI线程做重量级计算。我的经验是,在commonMain里可以显式指定调度器,比如坐标聚合计算放Dispatchers.Default,UI刷新交给两端自己决定。
8. 一个案例复盘:用KMP重构一个双端业务模块的全过程记录
理论聊得再多,不如一次真实的重构经历来得有说服力。这里分享一个我之前做的示例项目:把一个同时存在于Android和iOS的“订单详情页”业务模块,用KMP做了一次彻底的重构。
8.1 重构前的痛点:双端维护一致性的成本
重构前的代码结构是典型的双端平行开发:Android端有一个OrderDetailPresenter,iOS端有一个OrderDetailPresenter,两套Presenter里的业务逻辑基本相同——拉取订单数据、处理退款状态机、管理倒计时、计算优惠明细。每次产品改一个字段,Android改一遍、iOS改一遍,大约会有两到三天的工时差,经常出现两端逻辑不一致的问题。更头疼的是,有个后退款规则涉及时间边界和金额计算,两端各写了一套,结果金额计算结果差了几毛钱,排查了整整两天才发现是浮点数取舍时机不同导致的。
8.2 重构计划:抽离逻辑层,保留表现层
重构的思路很明确:把订单状态机、金额计算、倒计时管理、退款规则检测这些核心逻辑抽到一个共享的KMP模块里,Android和iOS各自只保留UI展示和交互事件转发。
共享层定义的核心模型很快成型:
kotlin复制@Serializable
data class Order(val id: Long, val status: OrderStatus, val amount: Long, val paidAt: Long?)
enum class OrderStatus { CREATED, PAID, SHIPPED, COMPLETED, REFUNDING, REFUNDED }
class OrderValidator {
fun canApplyRefund(order: Order, now: Long): Boolean {
// 退款窗口规则:已支付且未超过30天
return order.status == OrderStatus.PAID &&
order.paidAt != null &&
now - order.paidAt < 30 * 24 * 60 * 60 * 1000L
}
fun calculateRefundAmount(order: Order): Long {
// 统一使用Long型分作为金额单位,避免浮点误差
return when (order.status) {
OrderStatus.COMPLETED -> (order.amount * 0.9).toLong()
else -> order.amount
}
}
}
这个共享模块的API不用太多,但必须把最容易出错的业务规则固化下来。
8.3 迁移过程中的意外:iOS侧的反射限制与泛型擦除
迁移到一半时,我遇到一个印象极深的坑。共享代码里有一个通用函数,负责把泛型接口的返回结果统一包装成网络异常对象。在Android上跑得好好的,编译成iOS framework后,一调用就崩溃,没有任何报错信息。
排查了很久,最后定位到原因:这个函数用了Kotlin的reified类型参数,配合kotlinx.serialization的decodeFromString。在JVM上,reified因为内联函数的机制,类型参数可以直接拿到,序列化器也能正常创建。但编译成iOS的native代码后,某些反射特性支持受限,这个类型参数在运行时拿不到完整的类型信息,导致序列化器创建失败。
解决方案是:不要在跨平台共享代码里过度依赖reified加反射的写法。要么显式传入KSerializer参数,要么把序列化逻辑按平台拆开。我最终改成了显式传SERIALIZER的方案,代码看起来啰嗦一些,但稳如老狗。
这个坑给我提了个醒:KMP共享代码在某些边界上跟JVM并不完全等价,尤其是反射、类加载、动态代理这几个领域,写共享代码前要有一个“平台能力裁剪”的思维。有些API在Android上顺手能用,在iOS上可能就会变成不稳定因素。
8.4 重构后的收益:明显的数据对比
重构完成后的代码量统计非常有说服力。共享模块约3000行Kotlin代码,Android端精简了约1500行重复逻辑,iOS端精简了约1800行重复逻辑。更关键的是,此后每次改订单规则,只改共享模块一处,两端自动同步。测试覆盖也集中到了共享模块上,两端的UI层只需要做基础的集成测试即可。
我看到网上有人质疑KMP“学习成本高、配置复杂”,但结合这次重构经历,我的真实感受是:KMP的配置复杂主要体现在搭建工程的第一周,这是固定成本;而收益体现在日后每一次需求变更上,这是持续复利。尤其对中小团队来说,养不起两套原生逻辑开发岗,KMP把业务逻辑收敛到一套代码上,维护压力确实降了一个量级。
9. 一些写在最后的实操建议
说几个我在KMP项目里踩了多次以后沉淀下来的经验,都是文档里不会细讲、但实战中一定会遇到的事。
第一,版本管理必须用统一目录。在项目根目录里搞一个libs.versions.toml,把Kotlin版本、AGP版本、Ktor版本、协程版本全部集中管理。KMP的依赖关系太复杂,散落在各个模块的build.gradle.kts里,迟早会出幺蛾子。我见过最惨烈的案例是,Android端某个模块用了Ktor 2.3,共享模块用了Ktor 3.0,编译通过但运行时直接抛找不到类的异常,因为新版Ktor改了包目录结构。
第二,从Android转到KMP,最容易忽略的是“测试策略的转变”。原来你依赖JVM上的单元测试,现在共享代码要跑在Android和iOS上,最好在commonTest里把测试用例写清楚,然后在两端分别执行。KMP的iosSimulatorArm64Test任务会生成一个可以跑在模拟器上的测试包,我建议你在CI里单独跑一下iOS侧的单元测试,别只在Android上验证完就以为万事大吉。
第三,如果团队里同时有Android和iOS同事协作,尽早统一网络层的数据模型命名和枚举值。因为两端在共享模块里消费同一个枚举类时,Swift代码里看到的是Kotlin风格的枚举名。如果你们在定义网络协议时用“created”、“paid”这种统一小写风格,在两端都会少很多无谓的转义操作。
第四,对于想入门KMP的开发者,我的建议是不要一上来就搞大型重构。先找一个小功能模块,比如“设置页的主题切换”或者“用户协议解析”,用KMP实现一遍,跑通Android和iOS全链路。这个过程中你会把Gradle配置、expect/actual、Ktor请求、持久化、导出framework、Swift集成这一整条链路都过一遍。走完这一遍,你对KMP的能力边界会有一个比任何教程都清晰的认识。
我在实际项目中,始终遵循一个原则:KMP是工程手段,不是技术信仰。它适合“逻辑重、交互轻”的业务模块,不适合那些大量依赖系统UI控件和复杂手势的页面。把KMP用在该用的地方,它就是你团队效率的最大助力;用在不该用的地方,它就是在给架构添乱。想清楚这一层,再去动手写第一行expect fun,你会从容很多。
