Kotlin跨平台开发与Android架构实战:KMM全栈指南
最近几个月,团队一直在折腾Kotlin Multiplatform(KMM)的落地改造。我们有两个原生App,Android端用Kotlin+Jetpack Compose,iOS端用SwiftUI,两边业务逻辑重复率实在太高了——登录校验、缓存策略、数据模型映射、埋点字段拼接,几乎每改一个接口都要在两端各写一遍。当时摆在我们面前的选择其实不少:Flutter、React Native、KMM。我们在测试了三种方案之后,最终选择了KMM,而且不是拿来做UI跨端,是只做业务逻辑层的跨平台共享。这篇博文就把我们这几个月从选型、架构设计到实际落地的完整过程记录下来,包括那些文档里不会写的坑。
这套方案适合谁?如果你的团队已经有原生Android和iOS两个客户端,短期内不可能完全放弃原生去写Flutter或RN,但又被双端重复逻辑折磨得够呛,那KMM是一个值得认真考虑的方向。本文会从选型逻辑、共享代码边界划分、Android端架构对接、iOS集成细节、构建上架和团队协作五个维度,把我们踩过的坑和验证过的方法完整讲清楚。
1. 选型判断:KMM的价值边界和它跟Flutter、RN的本质差异
先说结论:KMM不是用来替代Flutter或React Native的,它解决的是另一类问题。想清楚这一点,后面的一切决策都会顺畅很多。
1.1 三种跨平台方案解决的问题并不相同
Flutter和React Native解决的是UI跨端问题——你写一套界面代码,同时跑在Android和iOS上,连像素级差异都给你抹平。代价是你在很大程度上放弃了原生控件的使用,要么接受框架自带的设计语言,要么费劲去做平台通道。如果你的产品是重UI、轻业务逻辑,或者你对界面的完全一致性有执念,Flutter确实好用。
但我们的业务不是这样的。我们的App有大量表单校验、复杂的状态机、缓存同步、服务端数据映射逻辑,这些逻辑跟界面没有一分钱关系,纯粹是数据处理。它们才是双端维护成本的大头。UI部分我们反而希望保留原生实现——Android用Compose,iOS继续SwiftUI,因为这样能最大程度保留各平台的原生交互体验和系统集成能力。
KMM的价值恰恰在这里:共享的only业务逻辑层,UI各写各的。它的核心机制是expect/actual——在共享代码里声明一个预期函数,在Android和iOS各自实现一个实际版本,编译时自动选择对应实现。这样网络请求、数据解析、业务规则判断就只需要写一遍了。
1.2 KMM对现有Android技术栈的友好程度
另外还有个现实因素:我们的Android团队全员Kotlin,iOS团队也熟悉Kotlin语法但不熟Android框架。KMM的共享模块完全用Kotlin写,学习曲线基本可以被忽略。相比Flutter的Dart语言和RN的JavaScript桥接,KMM让Android工程师可以直接上手写共享逻辑,不用重新学一门语言。
如果评估一下三种方案在我们项目中的适配性,大致是这样的:
| 维度 | Flutter | React Native | KMM |
|---|---|---|---|
| UI跨端 | 完全共享 | 基本共享 | 不共享 |
| 业务逻辑共享 | 通过Dart代码 | 通过JS代码 | 通过Kotlin |
| 原生体验保留 | 较弱 | 中等 | 最强 |
| 团队学习成本 | 需学Dart | 需学JS/TS | Android端零成本 |
| 与Android原生架构兼容 | 一般 | 一般 | 极佳 |
1.3 什么情况下不建议选KMM
如果你是从零起步、只有两三个人、没有原生团队,那我不建议选KMM——因为UI还是要写两套,你的成本并没有降下来。KMM最适合的场景是:已经有了成熟的双端团队,业务逻辑重复度高,但UI层希望保持原生。如果没有这个前提,Flutter对你的性价比会高得多。
另外一个不适合KMM的情况是:你的团队没有专门的工具链建设能力。KMM的构建、调试、依赖管理比普通Android项目复杂,后面会详细讲。如果团队里没人愿意折腾Gradle配置和CocoaPods的集成问题,建议谨慎评估。
2. 共享模块的第一版边界:别再什么都往commonMain里塞
选定了KMM之后,第一个要解决的问题就是:共享什么?很多人走KMM的坑就是从“什么都想共享”开始的。我们第一版也差点栽在这里。
2.1 commonMain、androidMain、iosMain三层结构的正确理解
KMM的标准工程结构是三层:commonMain、androidMain、iosMain。commonMain里放跨平台代码,androidMain和iosMain里放各平台的实现。
这个结构本身不难理解,难的是判断哪些代码能进commonMain。一个非常朴素的判断标准是:这段代码是否依赖UI框架、系统服务或者平台SDK。如果依赖,就不能直接放共享模块;如果不依赖,只有纯逻辑,那就可以共享。
但这里面有个梯度。我整理了一下我们项目里的代码,按共享难度排了个序:
- 第一梯队(最易共享):纯数据模型、枚举定义、常量、工具函数、序列化逻辑。
- 第二梯队(较易共享):网络请求封装、接口定义、数据解析、缓存策略(用跨平台文件存储或内存缓存实现)。
- 第三梯队(较难共享):本地数据库操作(Room只在Android可用,SQLDelight是跨平台方案)、文件读写、系统权限申请。
- 第四梯队(不应共享):UI状态和生命周期绑定特别重的业务逻辑、涉及平台SDK回调的逻辑。
2.2 expect/actual:跨平台接口的核心机制
共享代码要调用平台能力时,就用expect/actual。我举个例子,比如我们要在共享代码里获取当前语言环境:
// commonMain expect fun getSystemLanguage(): String // androidMain actual fun getSystemLanguage(): String { return Locale.getDefault().toLanguageTag() } // iosMain actual fun getSystemLanguage(): String { return NSLocale.currentLocale.languageCode }这个机制本身不难,但有一个很隐蔽的坑:expect/actual的匹配是编译期强制检查的。如果commonMain里声明了一个expect函数,而某个平台的actual缺失,编译直接失败。这对于一条代码只改一处的场景是好事,但也意味着你每声明一个expect,就得同步维护两个平台版本的实现。
我们的经验是:expect/actual的粒度要尽量小,最好是只暴露一个函数接口,让平台方处理复杂逻辑。千万别在commonMain里写大段的expect逻辑,然后把大量细节甩给平台实现——那样等于把双端差异搬回了应用层,共享的意义就没了。
2.3 第一版边界划分:我们实际敲定的方案
经过几轮讨论,我们第一版共享模块最终确定了如下边界。
共享部分:数据模型(所有接口的请求/响应DTO,同时用kotlinx.serialization注解)、网络层(Ktor client统一配置,封装get/post方法)、业务逻辑层(登录态管理、权限校验状态机、数据同步引擎、所有涉及纯算法和规则判断的逻辑)、工具层(日期处理、格式化、加密摘要、通用扩展函数)。
不共享部分:UI层(Compose和SwiftUI各自开发)、本地数据库(一开始用各自平台方案,后期再评估SQLDelight)、平台通知处理、相机/相册等硬件相关逻辑。
这样划分的直接收益是:后端接口定义改动时,只需要改共享模块里的DTO和网络封装,两端重新编译即可。以前改一个接口字段要跨两个仓库、两个技术栈、至少四五个文件的改动,现在基本只需要动一处。
2.4 依赖注入:KMM项目里最容易忽略的设计环节
共享模块里的类如果直接new来new去,跨平台开发会特别痛苦。因为你的共享代码可能被两个模块同时引用,如果依赖关系写死了,测试和扩展都会受限。
我们引入了一个非常轻量的手动依赖注入容器,没有用Hilt这种重量级框架——因为Hilt在KMM中的支持还不算完善,而且我们共享模块里的服务大多是无状态或轻状态的,手动管理就够了。核心思路就是建一个ServiceLocator:
// commonMain object ServiceLocator { private val services = mutableMapOf<String, Any>() fun <T> register(name: String, service: T) { services[name] = service } @Suppress("UNCHECKED_CAST") fun <T> resolve(name: String): T { return services[name] as T } }Android端初始化时把网络层、缓存层的实例注册进去,iOS端做同样的事情。共享业务代码里就不直接依赖任何一个具体的网络实现,而是通过ServiceLocator取用。这种做法在双端运行时的容错度非常高,也方便在共享逻辑里做单元测试时替换mock对象。
3. Android端对接:单Activity架构下的ViewModel与Repository落地
KMM共享模块装好之后,Android端要真正用起来,架构上还是有一些设计工作要做的。我们Android端采用的是比较成熟的分层架构:Activity保持单入口,UI层用Compose,状态层用ViewModel,数据层用Repository。KMM共享模块在这套架构里的定位很明确——它替代的是原来的Data层和一部分Domain层。
3.1 ViewModel与共享代码的调用边界
我们的做法是:ViewModel直接调用共享模块暴露的业务服务。比如要处理登录,ViewModel里会持有共享的AuthService:
class LoginViewModel( private val authService: AuthService ) : ViewModel() { fun login(username: String, password: String) { viewModelScope.launch { val result = authService.login(username, password) _uiState.value = when (result) { is AuthResult.Success -> LoginState.Success(result.user) is AuthResult.Failed -> LoginState.Error(result.message) } } } }共享模块里的AuthService本身是用挂起函数写的,返回的是一个密封类的结果。Android端ViewModel拿到这个结果后,再转换成UI层的状态。这个转换逻辑必须放在Android端,不能在共享代码里写UI状态——共享代码不知道Compose的StateFlow应该怎么用是次要的,主要是UI状态本身就是平台相关的概念,不应该进入共享边界。
这里我们最开始犯过一个错误:试图在commonMain里定义ViewModel基类。后来发现KMM的ViewModel和Android的ViewModel生命周期模型差异很大,iOS端根本用不上Android的ViewModel机制,硬要共享反而会让共享代码变得特别别扭。后来我们把共享模块的定位调整成了纯业务服务层,ViewModel完全留在Android端,一切反而顺畅了。
3.2 Repository模式在KMM项目里的变形
传统的Android项目里,Repository负责从本地缓存或远程接口获取数据并做聚合映射。在KMM架构下,这个Repository角色直接下沉到了共享模块里。我们不再在Android端单独建Repository类,而是让共享模块里的服务直接承担数据提供者的角色。
对于缓存策略,KMM也有成熟的跨平台方案。我们在共享模块里用了multiplatform-settings这个库来管理键值对存取,用它实现了简单的本地缓存:
class UserSettingsStore( private val settings: Settings ) { fun saveAuthToken(token: String) { settings.putString(KEY_AUTH_TOKEN, token) } fun getAuthToken(): String? { return settings.getString(KEY_AUTH_TOKEN, null) } }multiplatform-settings在Android底层用的是SharedPreferences,在iOS底层用的是NSUserDefaults。这样像登录态保存、用户偏好这类最常见的存储需求,就能完全在共享模块里实现了,两端的ViewModel和UI层根本不用关心token存在哪、怎么读的。
3.3 网络层封装:Ktor client在Android端的配置与踩坑
网络层我们选了Ktor client,这是JetBrains官方出的网络框架,天然支持KMM。但也因为它比较年轻,网上资料少,实际用起来还是有不少细节要注意。
先说Android端的配置。Ktor client在Android上默认引擎是OkHttp,需要额外声明的依赖有:ktor-client-core、ktor-client-okhttp、ktor-client-content-negotiation、ktor-serialization-kotlinx-json。用kts脚本写的话大概是这样:
commonMain.dependencies { implementation("io.ktor:ktor-client-core:2.3.7") implementation("io.ktor:ktor-client-content-negotiation:2.3.7") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7") } androidMain.dependencies { implementation("io.ktor:ktor-client-okhttp:2.3.7") } iosMain.dependencies { implementation("io.ktor:ktor-client-darwin:2.3.7") }一个比较大的坑是:Ktor 2.x版本在Android端的日志输出默认是关闭的。你如果直接跑网络请求,发现Logcat里没有任何HTTP日志,可能会以为请求没发出去。实际上请求是正常的,只是没有开启日志拦截器。做网络调试的时候记得加Logging插件:
val client = HttpClient { install(Logging) { level = LogLevel.HEADERS } install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } }还有个细节是超时设置。Ktor默认的连接超时和请求超时在Android端跑大型文件传输时不太够用,我们一般会单独调一下:
install(HttpTimeout) { requestTimeoutMillis = 15000 connectTimeoutMillis = 15000 socketTimeoutMillis = 15000 }3.4 Compose与KMM共享模块的配合
Compose对KMM共享模块的消费方式很简单——直接在Composable函数里调用共享服务,然后通过collectAsState收集Flow即可。这里要注意的是,共享模块里的挂起函数返回的往往是一个组合结果,比如要么成功要么失败,不建议在共享层直接抛异常。我们统一用sealed class包装结果,让调用方不用写大段的try-catch。
sealed class NetworkResult<out T> { data class Success<out T>(val data: T) : NetworkResult<T>() data class Error(val code: Int, val message: String) : NetworkResult<Nothing>() object Loading : NetworkResult<Nothing>() }共享模块里的所有网络方法都返回NetworkResult,Android端和iOS端拿到这个结果后,只需要做一个简单的when分支就可以更新UI状态。这个设计模式非常适合跨平台场景,因为两端语言对sealed class的模式匹配都支持得很好,不用写镜像实现。
4. iOS端绕不开的集成细节:从Pod到真机联调
KMM的iOS端集成是很多人望而却步的地方——确实不简单,但也没想象中那么难。我们把过程完整梳理一遍。
4.1 framework导出与CocoaPods集成
KMM的共享代码要编译成iOS能用的framework,JetBrains官方推荐的方式是通过CocoaPods插件。在共享模块的build.gradle.kts里配一下:
plugins { kotlin("multiplatform") kotlin("native.cocoapods") } kotlin { cocoapods { summary = "Shared business logic" homepage = "https://example.com" ios.deploymentTarget = "14.0" framework { baseName = "SharedModule" } } }然后在iOS工程的Podfile里加上:
target 'YourApp' do use_frameworks! pod 'SharedModule', :path => '../shared' end执行pod install之后,Xcode工程里就能看到SharedModule这个framework了。
这套机制本身没太大问题,但有一个很恼人的点是:每次修改了Kotlin代码,都需要重新跑一次gradle构建,framework才会更新,然后再pod install一次。频繁改动共享代码的时候,这个流程相当繁琐。
4.2 真机调试时的签名问题
iOS的真机调试有个很经典的坑:framework的签名和App的签名不一致,导致xcodebuild报错。后来发现这是因为Kotlin/Native编译产物的签名机制和Xcode默认的签名机制有冲突。解决方案是在framework构建时不设置签名:
binaries.framework { isStatic = true }用静态framework可以绕开动态framework的签名检查。代价是ipa包会略微变大,但对我们来说完全可接受。
另外,iOS的模拟器架构和真机架构是不同的——模拟器可能是x86_64,真机是arm64。想让framework同时支持模拟器和真机,就得配置多个架构目标。我们日常开发用模拟器跑,发布前用真机验证,所以发布前要单独跑一次真机构建,这时候最容易遇到的就是架构不匹配的报错。后来我们在构建脚本里加了两个task,分别输出模拟器和真机的framework,省事不少。
4.3 Swift端调用Kotlin代码的体验
Swift调用Kotlin共享代码的体验,说实话比Kotlin调用Java还顺畅。因为Kotlin/Native生成的framework是原生的Objective-C+Swift接口,Kotlin的类直接映射成Swift类,大部分体验是无感的。唯一要适应的是:Kotlin的挂起函数在Swift里默认暴露为带completionHandler的方法。
比如Kotlin里有这样一个挂起函数:
suspend fun fetchUserProfile(userId: String): UserProfileSwift端调用时是这样的:
SharedModule.sharedApi.fetchUserProfile(userId: userId) { profile, error in if let profile = profile { // handle success } }第一次看这段代码会觉得奇怪,但熟悉了callback模式之后就习惯了。如果你实在想保留async/await的写法,也可以给Swift加个异步封装,不过我们项目里没做这层优化——直接用callback并不影响功能。
4.4 iOS端网络层的特殊处理
iOS端有一个必须单独处理的地方:网络权限。iOS上http明文请求默认是禁止的,必须在Info.plist里配置App Transport Security的例外规则,否则Ktor client发出的http请求会被直接拦截。这里说的还不是刚上线时遇到的问题——很多教程里都没提,我们第一次真机调试时所有请求都报The resource could not be loaded because the App Transport Security policy requires the use of a secure connection,排查了半天才想起来是这个原因。
如果你的后端接口是https的,那问题不大;但如果开发阶段用的是http的调试环境,或者有些内部接口没上https,就得在Info.plist里加上NSAppTransportSecurity配置。这里提一句,别在开发环境里把所有NSAllowsArbitraryLoads都开成true,虽然调试方便,但不小心发到线上会有安全隐患。
5. 构建、上架与团队协作:KMM项目实际运营中的那些坑
共享代码写完只是开始,真正让KMM项目运转起来的,是构建流水线、版本管理和团队协作方式。这一部分的内容在官方文档里几乎找不到,全是我们踩出来的。
5.1 Android构建集成:一件看似简单但容易翻车的事
Android端接入KMM共享模块的方式有两种:一种是把共享模块作为独立Gradle项目引入,另一种是把共享模块发布到Maven仓库或本地仓库。我们一开始走的是本地Gradle项目方式——两个工程在同一个仓库下,settings.gradle.kts里include一下,然后App模块依赖共享模块:
implementation(project(":shared"))这种方式的好处是修改共享代码后,Android Studio能直接热重载,不需要额外发布。坏处是,如果iOS团队也在另一个仓库里开发,共享代码的版本同步就成了一个很大的问题。后来我们把共享模块提升为一个独立仓库,通过版本号发布到内部的Maven仓库。Android端引用固定版本,iOS端通过CocoaPods引固定版本,两边都能锁定版本,反而清晰多了。
5.2 iOS构建速度:KMM的一个槽点
KMM项目的痛点之一就是:iOS构建第一次编译共享框架的时间非常长。Kotlin/Native要生成Objective-C桥接代码,加上framework打包,第一次构建可能十几分钟起步。后续有增量编译会快一些,但相比纯Swift工程,整体构建时间还是偏长的。
为了缓解这个问题,我们把共享框架的构建从Xcode的Build Phase里抽出来了,做成一个独立的CI step。开发者在本地拉代码后先跑一次gradle命令生成framework,然后pod install才能正常编译。这个过程对新人很不友好,第一印象就是“怎么这么零散”。建议在团队Wiki里写清楚整个构建流程,最好再有一键脚本做串联,否则每次新人报到都是一次引导地狱。
5.3 代码规范与团队协作:KMM项目没有想象的那么"跨端友好"
跨平台项目通常意味着一个团队可以跨端开发,但KMM项目实际运行时,你会发现它反而对“各司其职”的要求更高。
Android团队写共享代码,通常会用Android Studio + Kotlin插件,写的时候就顺手考虑了Android端的特性。但共享代码是要同时被iOS端消费的,很多Android团队习以为常的写法,到了Swift端就会变得很别扭。比如把SharedPreferences直接暴露给共享模块(实际上我们没这么干,但有人会这么想),虽然功能上没问题,但iOS端版本就没法复用这个逻辑了,共享的意义就打了折扣。
我们最终定下来的团队规范是:共享代码的提交必须经过双端至少各一个开发者Code Review。不要求两人都精读每一行,但至少要检查API设计是否有平台偏向性。这个规范执行之后,共享代码的质量提升非常明显,很少再出现一方改代码导致另一方莫名其妙编译失败的场景了。
5.4 单元测试:KMM项目里性价比最高的投资
最后聊聊测试。KMM的共享模块能直接在commonMain里写单元测试,然后在Android和iOS两端分别跑。这一套机制比双端各自写业务逻辑测试要省太多事了——你只需要写一遍测试用例。
class AuthServiceTest { @Test fun testLoginValidation() { // given val service = AuthService(mockUserRepo()) // when val result = service.login("", "short") // then assertTrue(result is AuthResult.Failed) } }我们专门为共享模块里的逻辑服务做了很大比例的覆盖率测试,尤其是登录校验、状态机、数据解析这类最容易出问题的逻辑。测试跑在JVM上非常快,iOS端跑native测试稍慢但也完全可以接受。这套测试投入的性价比是我在这个项目里体会最深的一点——以前在Android端写完业务逻辑测试,iOS端还得用OCMock或XCTest再写一遍,不仅工作量翻倍,两边测试还不一定行为一致。现在一份测试双端复用,回归效率提升非常明显。
我们目前的开发迭代节奏是这样的:共享模块代码改动→跑共享模块单测→Android端集成自测→打包framework→iOS端集成自测→双端联调。共享模块因为有独立测试保护,绝大多数逻辑问题都能在最早阶段暴露,真正走到双端联调才发现的bug已经很少了。
回头总结这几个月KMM落地的经验,核心无非是三点:想清楚共享边界,别指望复制粘贴就能双端通吃;把测试前置到共享模块,用自动化守好业务逻辑的底线;构建和依赖管理花时间理顺,别在每次编译时反复折腾。KMM不是万能方案,但如果你恰好是有双端团队、重逻辑、UI原生独立的项目,它的收益可能比想象中更大。