2025年还在观望KMP的人,基本可以放弃犹豫了。Kotlin Multiplatform从当年的“玩具框架”走到今天,已经能在实际商业项目里扛起核心业务逻辑的多端复用了。我过去两年在几个App项目里陆续把账号体系、数据层、网络层迁到了KMP,最近又在用Compose Multiplatform尝试共享UI层,踩了不少坑,也沉淀了一套可复用的落地流程。这篇东西不聊概念,只讲在真实业务里怎么把KMP用起来——模块怎么切、expect/actual怎么规避坑、网络层怎么优雅共享、构建速度怎么不拖垮CI,全是一线操盘时验证过的方案。
如果你正打算在团队里引入KMP,或者已经写了几个Demo但不知道怎么落到业务上,这篇文章应该能帮你省掉不少试错成本。
1. 为什么是KMP:跨平台方案选型的底层逻辑
1.1 三个主流方案的取舍对比
选KMP之前,团队大概率已经对比过Flutter和React Native。我自己的判断标准很简单:业务是否强依赖原生体验,以及团队现有技术栈能不能复用。Flutter在UI一致性上确实无解,一套Dart代码渲染所有端,但代价是脱离了Android和iOS的系统组件生态,凡是涉及系统级能力、深色模式联动、无障碍支持,都要额外做桥接。React Native思路类似,JS生态丰富,但性能瓶颈和依赖管理的老问题一直没根治。
KMP的定位完全不同。它不碰UI层,只共享业务逻辑——数据模型、网络请求、本地存储、账号登录这些和平台耦合度低但又最容易写重复的代码。Android端用Kotlin直接跑,iOS端编译成Framework给Swift调用,逻辑只有一份,UI各自用原生写。2025年的KMP体验相当顺滑,Android Studio直接支持多平台调试,Xcode里也能断点调试Kotlin代码,不再是早年那种写完逻辑还得靠日志看结果的狼狈状态。
1.2 KMP真正适合的业务场景
我要泼一盆冷水:不是所有项目都适合立刻上KMP。如果你维护的是一个纯内部工具型App,或者业务逻辑极薄、大部分时间都在写UI,KMP带来的收益有限,反而徒增构建复杂度。KMP最适合的场景,是业务逻辑足够厚实的App——电商、社交、金融、内容资讯这类,登录注册、商品列表、订单流程、消息推送这些横跨所有端的逻辑,能占到整个App代码量的四成以上。把这部分抽成共享模块,收益几乎是立竿见影的。
我在团队里推KMP的时候,第一个试点选的就是账号注册登录链路。原因很直接:逻辑复杂(密码校验、验证码、token刷新、多设备互踢)、各端UI却相对简单,是最典型的“逻辑重、UI轻”模块。跑通这条路之后,再逐步把网络层、缓存层、埋点层都下沉进去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程结构设计:共享代码的边界应该画在哪里
2.1 模块划分的推荐实践
KMP项目最忌讳的就是一个共享模块搞定一切。我见过不少团队把整个业务逻辑塞进一个模块,几百个文件堆在一起,最后谁都动不了。正确的做法是按功能域拆模块。
以我的实际项目为例,工程里一共有五个共享模块:
shared-core:纯Kotlin代码,包含基础工具类、协程扩展、通用数据模型,不含任何平台API。shared-network:基于Ktor Client封装网络请求层,包含统一的拦截器、错误处理、Cookie管理。shared-database:封装SQLDelight的数据访问层。shared-domain:业务领域层,每个业务域一个子包——account、order、message这种层级。shared-remote-config:远程配置拉取与缓存。
模块之间依赖关系必须单向:shared-domain依赖shared-network和shared-database,shared-core被所有模块依赖,但shared-core不依赖任何人。这套结构的好处是,平台相关的expect声明只出现在shared-core的最低层,上层共享代码全是纯Kotlin,数据流清晰,测试也好写。
2.2 工厂模式与依赖注入的解耦思路
多平台环境下的依赖注入,我强烈建议别上重量级框架。Koin虽然从3.4版本开始支持KMP,但初始化时的平台差异处理确实让人头疼。我在第一个项目里用了Koin,后期调试难度比预期大,第二个项目直接改成了手写工厂模式。
kotlin复制// 统一的服务定位器,每个业务模块注册自己的工厂函数
object ServiceLocator {
private val factories = mutableMapOf<Class<*>, () -> Any>()
fun <T : Any> register(clazz: Class<T>, factory: () -> T) {
factories[clazz] = factory
}
@Suppress("UNCHECKED_CAST")
fun <T : Any> resolve(clazz: Class<T>): T {
return (factories[clazz]?.invoke() ?: error("Service ${clazz.simpleName} not registered")) as T
}
}
使用的时候在平台入口注册具体实现:
kotlin复制// Android Application类里
ServiceLocator.register(TokenStorage::class.java) { AndroidTokenStorage(context) }
ServiceLocator.register(ApiService::class.java) { KtorApiService() }
// iOS侧通过MainController初始化时注册对应的实例
手写工厂模式在几十个依赖项的规模下完全够用,还能避免反射开销,构建体积也更小。等业务真膨胀到几百个依赖项时,再考虑上框架也不迟。
3. expect/actual机制的实战用法与踩坑
3.1 编译期分发机制的工作原理
expect/actual是KMP的基石,理解它才能少踩坑。简单说,expect声明了一个“接口契约”,actual是平台上真正的实现。编译器在编译各个目标平台时,会自动把expect替换成对应的actual实现。
我举一个实际例子,在shared-core里需要获取当前时间戳,但Android和iOS的精度要求不一样:
kotlin复制// commonMain
expect fun currentTimeMillis(): Long
// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain
actual fun currentTimeMillis(): Long = NSDate().timeIntervalSince1970 * 1000
这里值得注意的点是,expect/actual不是接口多态,而是编译期绑定。同一个expect可以只有一份声明,但actual在每个平台都必须存在,否则编译失败。这个机制要用在刀刃上——只在确实存在平台差异时用,不要定义一堆没有实质差异的expect。
3.2 如何避免空壳函数和平台分支爆炸
我在评审代码时最反感的就是actual里全是空实现。见过有人定义了个expect fun logToServer(),Android端的actual真的写了网络请求,iOS端的actual却是空函数,理由是“iOS暂时不改”。这种写法让共享逻辑产生了隐蔽的平台分支,后续维护的人根本不知道哪些端是实盘、哪些是空转,迟早出事。
正确的做法是把差异收敛到一个绝对小的范围。比如日志功能,Android本身有Log,iOS没有对应的东西,就需要actual。但网络层用Ktor Client时,引擎选择才需要expect:
kotlin复制// commonMain
expect fun createHttpClientEngine(): HttpClientEngine
// androidMain
actual fun createHttpClientEngine(): HttpClientEngine = OkHttp.create()
// iosMain
actual fun createHttpClientEngine(): HttpClientEngine = Darwin.create()
这样设计后,共享上层代码只依赖HttpClient,不用关心底层引擎的差异。等哪天Android想换掉OkHttp,只需要改一个函数。
还有个常见的坑是类型API在不同平台的缓存策略不同。我在项目里实现图片缓存时,需要获取缓存目录路径,最开始写的是:
kotlin复制expect fun getCacheDirPath(): String
结果Android端取的是context.cacheDir.path,iOS端取的是NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, true).first()。但这不是问题的关键,关键是要把“取路径”和“用路径”分开,前者做actual,后者用纯Kotlin实现,不然测试时就只能连平台路径一起mock。
4. 网络与数据层的多平台落地实践
4.1 Ktor Client和kotlinx.serialization的高效组合
KMP网络层在2025年基本是Ktor Client一家独大,原因没什么悬念——它是JetBrains官方维护的,天然支持多平台引擎适配。我在项目里用的是OkHttp(Android)和Darwin(iOS),整体的构建速度不受太大影响。
使用Ktor Client时要特别注意序列化框架的选择。kotlinx.serialization是配合KMP最顺滑的,因为它支持编译期生成序列化器,不需要反射,这对iOS平台的性能至关重要。我在项目中定义了统一的数据模型:
kotlin复制@Serializable
data class ApiResponse<T>(
val code: Int,
val message: String,
val data: T? = null
)
@Serializable
data class UserProfile(
val userId: String,
val nickname: String,
val avatarUrl: String,
val vipLevel: Int
)
网络层的封装要考虑异常处理的统一。Ktor Client的HttpResponseValidator可以全局统一处理错误码,我的做法是:
kotlin复制val client = HttpClient(engine) {
expectSuccess = true
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 30_000
}
HttpResponseValidator {
validateResponse { response ->
val body = response.bodyAsText()
// 全局处理业务错误码
if (body.contains("\"code\":401")) {
throw UnauthorizedException()
}
}
}
}
这套封装跑起来之后,各端UI层只需要调用挂起函数拿数据,异常已经被网络层加工成业务异常,UI层不用关心401还是500的区别。
4.2 SQLDelight和DataStore的选型心得
本地存储的选型,我在对比了SQLDelight和Realm之后选了SQLDelight。原因很实在:SQLDelight用SQL语法写查询,类型安全是编译期保证的,而且对KMP支持得最成熟。Realm在KMP上虽然也能跑,但配置复杂度高出不少,对于只想存业务数据的场景来说有点杀鸡用牛刀。
SQLDelight的典型用法是定义.sq文件,自动生成数据类:
sql复制-- UserDao.sq
SELECT * FROM user WHERE userId = ?;
INSERT INTO user(userId, nickname, avatarUrl, vipLevel)
VALUES (?, ?, ?, ?);
UPDATE user SET nickname = ?, avatarUrl = ?, vipLevel = ?
WHERE userId = ?;
生成的Kotlin代码可以在所有平台使用:
kotlin复制class UserRepository(private val userDao: UserDao) {
suspend fun getUser(userId: String): UserProfile? =
withContext(Dispatchers.IO) {
userDao.selectByUserId(userId)?.toDomain()
}
suspend fun saveUser(user: UserProfile) =
withContext(Dispatchers.IO) {
userDao.insertUser(user.userId, user.nickname, user.avatarUrl, user.vipLevel)
}
}
如果只是存一些键值对,比如用户偏好设置、上次登录时间这种轻量数据,用DataStore就够。DataStore的KMP实现也很成熟了,封装方式是:
kotlin复制class UserPreferences(private val dataStore: DataStore<Preferences>) {
val lastLoginTime: Flow<Long?> =
dataStore.data.map { prefs -> prefs[Keys.LAST_LOGIN_TIME] }
suspend fun setLastLoginTime(timestamp: Long) {
dataStore.edit { prefs ->
prefs[Keys.LAST_LOGIN_TIME] = timestamp
}
}
private object Keys {
val LAST_LOGIN_TIME = longPreferencesKey("last_login_time")
}
}
从项目实测来看,SQLDelight负责结构化业务数据,DataStore负责轻量偏好,两套配合基本覆盖了所有本地存储场景。
5. Compose Multiplatform:UI层的共享与平台差异化处理
5.1 什么时候该用Compose共享UI
Compose Multiplatform在2025年已经能用稳定版本做业务了,但我要说一句实话——不是每个页面都适合用Compose共享UI。我在项目里做了个简单但有效的分界线:信息展示型页面和表单型页面适合共享,高度依赖手势交互或系统组件的页面不适合。
适合共享的典型例子是“用户个人中心”页面,它的结构无非是头像、昵称、会员等级、菜单列表,这些用Compose写好跨端排版完全一致。不适合共享的典型是地图页面,因为需要和原生地图SDK深度交互,强行用Compose包装反而多一层间接层。
在项目里,我的策略是主框架用原生导航,页面内部用Compose渲染。这样既享受Compose的布局便利,又不会掉进“一套导航方案两端适配”的坑。
5.2 平台差异的处理技巧
即使用了Compose共享UI,平台差异仍然存在。我在实际代码里常用两种方式处理。
第一种是用CompositionLocal提供平台差异能力:
kotlin复制// commonMain
@Composable
expect fun AppTheme(content: @Composable () -> Unit)
// androidMain
@Composable
actual fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
content = content
)
}
// iosMain
@Composable
actual fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) IosDarkColors else IosLightColors,
content = content
)
}
这样共享代码里调用的所有组件都统一用一套主题,但Android和iOS可以各自调整颜色值,保持系统的视觉习惯。
第二种差异处理是在共享UI里预留“插槽”给原生页面。比如一个订单详情页,顶部和底部是共享的Compose组件,中间的商品地图需要调用原生能力,这时候就定义一个槽位:
kotlin复制@Composable
fun OrderDetailScreen(
orderInfo: OrderInfo,
contentSlot: @Composable () -> Unit
) {
Column {
OrderHeader(orderInfo)
contentSlot() // 各端自行传入地图组件
OrderFooter(orderInfo)
}
}
这个设计让我在保留共享优势的基础上,给各端留出了定制空间。从团队反馈来看,协作效率比纯原生开发提升不少,UI还原度也更容易做到。
6. 构建、调试与CI的那些细节
6.1 Gradle配置中常见的三个坑
KMP项目里Gradle配置是最磨人的地方,我踩过的坑完全可以写一篇专项排查文档。第一个坑是Kotlin版本和Android插件版本不对齐。KMP对Kotlin/Native的并发模型有要求,如果版本太旧,iOS编译时会出现奇怪的链接错误——当时排查了半天,发现是Kotlin/Gradle插件和AGP版本不兼容,升级之后问题迎刃而解。
第二个坑是依赖源集配置错误。很多人在配置androidTarget和iosArm64时,会把公共依赖误放到androidMain或者iosMain,导致另一端编译时找不到类。我的经验是,凡是跨平台通用的库(Ktor Client、kotlinx.serialization、SQLDelight)全部放commonMain,只有真正和平台相关的才放各自源集。
第三个坑是Apple Silicon和Intel Mac下的构建产物不一致。团队里如果混用两种Mac,生成的iOS Framework需要分别指定架构。我的Gradle配置里固定成这样:
kotlin复制kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "17"
}
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { target ->
target.binaries.framework {
baseName = "SharedModule"
isStatic = true
}
}
}
需要注意的是iosX64()(Intel模拟器)在某些新版本默认不再生成,团队里如果有旧Mac还需要显式加回来。
6.2 调试多平台Kotlin代码的实用技巧
KMP调试最大的痛点是跨语言边界时很难追踪问题。我在项目里总结了几个比较有效的调试方式。
第一,给共享代码加统一日志模块。这个日志模块在Android上走Log.i,在iOS上走NSLog,但统一带上了文件、函数、行号信息。这样在两端看日志时,格式一致,方便对比。
kotlin复制expect fun log(tag: String, message: String)
actual fun log(tag: String, message: String) {
Log.d("KMP/$tag", message)
}
// iOS
actual fun log(tag: String, message: String) {
NSLog("KMP/%@ %@", tag, message)
}
第二,善用Kotlin/Native的println到stderr。在iOS上调试时,如果NSLog在Release配置里被裁剪,println输出会直接进Xcode的控制台,虽然不如LLDB强大,但至少能看关键日志。
第三,在Android Studio里直接调试iOS代码。从Kotlin 1.9.20开始,Android Studio的KMP插件支持直接把iOS模拟器跑起来,然后断点调试Kotlin代码,这个体验和调Android没什么区别。唯一要提醒的,是iOS模拟器每次冷启动比较慢,调试前先把模拟器预热,不然每次等半天。
6.3 CI流水线加速的实践
KMP项目最让人抓狂的是CI构建时间,尤其是全量编译iOS Framework的冷启动,动辄十几分钟甚至半小时。我优化的方向有三个。
第一个是 Gradle构建缓存。开启org.gradle.caching=true,如果Kotlin/Native的编译环境一致(镜像里预装了Xcode版本),所有平台的编译缓存都能复用,效果立竿见影。
第二个是 只编译变更模块。在CI里用-Ptarget=android或-Ptarget=ios这类参数,让Git分支检测变化,只跑对应平台的编译任务。团队里默认的CI流程分两条线:Android PR只跑Android编译,iOS PR只跑iOS编译,合并到主干后再跑双端全量编译和单元测试。
第三个是缓存Kotlin/Native的构建产物到CI缓存服务器。Kotlin/Native使用kotlin.native.cacheKind=none会避开一些缓存问题,但代价是构建变慢。合理配置是把~/.konan做CI缓存,比每次重新下载依赖要快得多。我实测下来,配置了缓存之后,iOS单平台的编译时间从十八分钟降到了七分钟左右。
7. 2025年的KMP落地方案经验沉淀
7.1 团队协作和工作流调整的细节
KMP落地不只是技术问题,更是团队协作问题。我这边最大的感受是,需要让Android和iOS同学都进同一个仓库协作。
以前两个团队各管各的仓库,引入KMP后,共享代码只有一份,Android同学改完要合入,iOS同学又要在同一块代码上改动,如果没有清晰的代码所有权规则,冲突会很频繁。我的做法是每个共享模块指定一个负责人,比如shared-network由服务端或Android资深同学负责,shared-domain由业务架构师负责,平台上各写各的UI层,涉及共享模块的改动必须过对应负责人审查。
另一个容易被忽略的点是 Code Review的视角切换。Android同学注意的多是Java/Kotlin API层面的问题,iOS同学更关心的则是内存管理和Swift互操作,双方审查同一份Kotlin代码时视角完全不同。我们团队专门开了几次KMP代码审查培训,把各自的高频问题列出来,比如Android侧注意协程泄漏,iOS侧注意Kotlin/Native的自动引用计数导致的内存泄漏,这样审查效率提升明显。
7.2 性能和数据安全的实测体验
很多团队担心KMP会引入性能损耗。我的实测结论是,纯业务逻辑的损耗可以忽略不计,因为Kotlin/Native编译后的代码和原生代码性能在一个量级。但是要留意两点:第一是数据序列化的开销,如果接口返回的数据量特别大(比如超过1MB的列表),Ktor Client和kotlinx.serialization的组合在iOS上的解析耗时明显比Android高,需要做分页端到端的性能压测;第二是跨语言调用的边界开销,尽量避免在Swift和Kotlin之间高频调用,比如每帧都调用共享方法渲染画面,这种情况就该考虑把那部分逻辑放到UI层原生化。
数据安全方面,KMP共享代码里如果有密钥、AccessKey这类东西,千万不要硬编码——Kotlin/Native编译后的二进制里,字符串常量是能搜出来的。我的方案是所有密钥统一放到平台侧的加密存储里(Android用Keystore,iOS用Keychain),然后在平台层通过expect读取:
kotlin复制expect fun getSecretKey(): String
这样即使共享二进制被逆向,也拿不到真正加密的密钥。
7.3 下一个阶段可以继续扩展的方向
KMP已经走过了从“能不能用”到“怎么用好”的阶段,下一步在团队里我想继续推进的是 共享UI层的比例提高。2025年的Compose Multiplatform稳定度已经相当高,我的目标是把60%以上的纯展示型页面都切到Compose共享,只保留高交互页面用原生。另外WatchOS、桌面端的扩展也在调研,KMP在这类非主流平台上带来的价值更加明显——本来团队就没资源养多端开发者,一套代码搞定手表和桌面,成本优势非常突出。
最后再分享一个我自己项目里的观察:KMP不是银弹,但它是“业务逻辑复用”这件事当前最靠谱的解法。关键在心态,别想着一步到位全端共享,从一条业务链路做起,把坑踩平了再扩张,效果反而最好。
