假如你手里有一个Android和iOS双端都要上线的App,团队主力又是Android开发,那么你大概率会遇到同一个困境:界面写两遍咬咬牙可以忍,但业务规则、网络层、缓存逻辑也各写一份,之后每次改需求都要在两个代码库里同步排雷。我最近在整理某跨平台项目X的架构时,又一次体会到早期没把共享边界划清楚的代价。Kotlin Multiplatform Mobile,常说的KMM,正好是冲着这个痛点来的:它不是让你用一套代码渲染两端的UI,而是把网络、数据、领域逻辑这些“肉”放到共享模块里,让Android和iOS各自保留原生界面和交互体验。这篇就当一份实操向的笔记,从选型理由、工程搭法、Android架构落地,一直写到常见坑,希望对正在评估或已经入坑KMM的人有帮助。
1. 为什么选KMM:先理清共享边界再动手
KMM的定位和Flutter、React Native不一样。后两者是把UI渲染层也一起接管,KMM默认只共享Kotlin写的业务代码,UI层仍然用Android原生和iOS原生各自实现。这个差异决定了它的优势和使用边界,也决定了你会在哪些地方获得收益、哪些地方仍然要投入。
1.1 痛点来自两套代码不同步
我见过不少双端项目,网络层和数据层都各自封装,表面上功能一致,实际行为却悄悄分叉。最典型的是费率计算、折扣叠加、时间格式化这类规则型逻辑:Android端某次优化改了四舍五入的规则,iOS端的同事并不知道,两周后数据对不上,排查半天才发现是两套实现语义不一致。
KMM出现之前,解决这种问题只能靠代码评审和文档去“物理同步”。KMM的思路是把这些容易分叉的逻辑全部吸收进共享模块,Android和iOS都直接调用同一份Kotlin编译产物。换句话说,大部分“后端规则”层面的bug,从架构上就消失了。
需要先想清楚的是:KMM适合你,前提是你愿意接受“UI双写”这个事实。它不会让你少写界面代码,只会让你少写业务代码。如果你的核心诉求是“一套代码恨不得连UI都一起跑”,那KMM的共享策略赶不上Flutter激进。如果你们追求原生体验、又不想养两个业务团队,那KMM就很值得投入。
1.2 方案对比和判断维度
拿一个中等复杂度App来对比主流跨平台方案,重点看四个维度:底层渲染、业务逻辑共享、团队技能复用、长期生态风险。
| 方案 | UI渲染方式 | 业务逻辑共享 | 团队技能复用 | 适合场景 |
|---|---|---|---|---|
| KMM | 原生组件各自渲染 | Kotlin共享层 | 要求团队熟悉Kotlin/Android | 已有Android团队并希望保留原生体验 |
| Flutter | 自绘引擎统一渲染 | Dart全写一套 | 需要单独学习Dart | UI强定制、一套代码跨全端 |
| React Native | 原生组件桥接JS | JS全写一套 | 前端技术栈友好 | 动态发布、前端团队主导 |
KMM在业务逻辑共享这一点上和另外两个方案没有本质区别,但它有一个独到的好处:共享层依然用Kotlin,和Android技术栈完全一致。Android开发几乎零成本进入,iOS开发只需要理解Shared Framework的接入方式,不必丢掉自己的SwiftUI/UIKit经验。
判断要不要上KMM,我建议先数一下App里哪部分代码重复度最高。如果是网络请求、数据模型、数据校验、状态机、计算规则,那收益会非常明显。如果App主要是纯展示页、动画、复杂交互,共享层带来的收益就会很低,反而多了一个跨平台编译的复杂度。
1.3 expect / actual 是共享的基石
KMM共享代码的核心机制是expect / actual。在commonMain里声明一个“预期”,然后分别在androidMain和iosMain给出“实际实现”。这像是一个面向平台能力的接口规范:Android端用Android API实现,iOS端用iOS API实现,而业务层只需要面向这个统一接口写逻辑。
// commonMain expect fun currentTimeMillis(): Long // androidMain actual fun currentTimeMillis(): Long = System.currentTimeMillis() // iosMain actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()类似这样,时间、日志、文件路径、加密能力都可以被抽象出来。时间看起来小事,但跨平台项目非常容易在时间单位、时区、格式上出问题。用expect / actual定好契约,后面就会省很多事。
注意一点:并不是所有平台能力都需要抽象。抽象太多,actual实现会膨胀,工程就失去“共享业务”的意义。我的经验是先只抽象真正会被业务调用的能力,比如日志、时间、唯一ID生成、数据库驱动,而不是把所有系统API都包一层。
2. 工程搭建与模块边界控制
KMM工程结构和普通Android工程很不一样,第一次接触的人容易在目录、Gradle、Xcode之间绕晕。但本质其实很简单:一个shared模块,加上两个壳工程(Android App和iOS App)。难点在于怎么让这个shared模块保持干净、稳定,不变成一个大杂烩。
2.1 从模板生成基础结构
目前主流IDE里可以直接找到KMM项目模板,生成后的核心目录如下:
com.example.tool/ ├── shared/ │ ├── src/ │ │ ├── commonMain/kotlin/ │ │ ├── androidMain/kotlin/ │ │ └── iosMain/kotlin/ │ └── build.gradle.kts ├── androidApp/ │ └── src/main/kotlin/ └── iosApp/ └── iosApp.xcodeprojcommonMain放共享代码,androidMain放Android平台实现,iosMain放iOS平台实现。androidApp是一个普通Android工程,依赖shared模块。iosApp通过一个Framework产物接入shared模块。
我建议把shared模块当成一个“独立的SDK”来维护,而不是Android工程的附属模块。它的边界应该是平台无关的业务规则,任何android.开头的import都不应该出现在commonMain里。
2.2 Gradle里配置多平台Target
KMM的Gradle脚本核心是配置kotlin {}块,声明Android target和iOS的三个target。iOS要覆盖真机和模拟器,通常是iosX64、iosArm64、iosSimulatorArm64,并把它们连成同一个iosTarget。
kotlin { androidTarget() iosTargets = listOf( iosX64(), iosArm64(), iosSimulatorArm64() ) iosTargets.forEach { target -> target.binaries.framework { baseName = "Shared" isStatic = true } } sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.ktor.client.core) implementation(libs.kotlinx.serialization.json) } androidMain.dependencies { implementation(libs.ktor.client.okhttp) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } } }上面的libs.xxx是版本目录别名,实际坐标你可以用自己项目里已经定义好的版本,避免版本冲突。选择isStatic = true是我一开始就踩过坑的地方:动态Framework接入iOS会比较麻烦,还要在App里处理embed逻辑,静态Framework简单直接,接入成本低。
2.3 共享模块内部的包结构
就算只有一个shared模块,内部也建议按职责分层,否则半年后你会被自己的代码绕晕。我的分层习惯是:
shared/src/commonMain/kotlin/com/example/tool/ ├── data/ │ ├── local/ │ ├── remote/ │ └── repository/ ├── domain/ │ ├── model/ │ └── usecase/ └── platform/domain只放不依赖任何框架的模型和用例,data负责从网络和数据库取数据,platform放expect/actual和平台相关工具。这样做的原因是让业务规则永远在最内层,不被数据源实现牵着走。
经验:不要在commonMain里出现
android.os.Bundle、Context这类Android专属类型。如果你发现某个文件必须依赖Android类才能编译,那它大概率不应该放在共享层。
3. Android架构在KMM里如何落地
很多Android开发者听到“MVVM”已经条件反射了,但在KMM里,MVVM的边界需要稍微调整一下。Android的ViewModel依赖生命周期,iOS没有对应的东西,所以你不能把Android ViewModel直接搬进commonMain。正确做法是让Android的ViewModel变成“纯视图模型外壳”,真正的业务状态和逻辑放在共享层。
3.1 共享层状态容器:StateHolder
我不会在共享层直接使用androidx.lifecycle.ViewModel,而是定义一个平台无关的类,承担状态容器的工作。这里借用StateFlow做状态分发,Android侧由ViewModel包装,iOS侧通过回调桥接。
// commonMain class RateListStateHolder( private val repository: RateRepository ) { private val _uiState = MutableStateFlow<RateListUiState>(RateListUiState.Loading) val uiState: StateFlow<RateListUiState> = _uiState.asStateFlow() suspend fun load(forceRefresh: Boolean) { repository.observeRates(forceRefresh) .collect { result -> _uiState.value = result.toUiState() } } }RateListUiState是一个普通的数据类,表示加载中、成功、失败三种状态。Android端只需要在ViewModel里持有StateHolder,然后把它提供的StateFlow交给Compose渲染。
3.2 网络层封装:Ktor + 序列化
KMM网络层最常用的是Ktor客户端,因为它在Android和iOS都有对应引擎,接口风格保持一致。在commonMain里配置Ktor,用kotlinx.serialization直接解析JSON,就能让网络层完全共享。
// commonMain val httpClient = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true explicitNulls = false }) } } suspend fun fetchRates(baseCurrency: String): List<Rate> { return httpClient.get("https://api.example.com/rates") { parameter("base", baseCurrency) }.body() }Android端引擎用OkHttp,iOS端引擎用Darwin,底层细节不用在业务代码里关心。这里的关键是配置ignoreUnknownKeys:服务端新增字段不会导致解析失败,这对双端联调很有用。
3.3 本地存储的取舍
本地缓存是双端项目最容易拉胯的地方。如果你的项目只需要Android端缓存,那Room依然是很好的选择,毕竟生态成熟。但如果缓存数据也需要在iOS共享,比如离线模式、草稿箱、行为日志,那就应该考虑SQLDelight这类能用Kotlin写SQL的共享数据库方案。
| 方案 | 共享性 | 类型安全 | 适用情况 |
|---|---|---|---|
| SQLDelight | Android/iOS共享 | 强,编译期校验SQL | 两端都需要访问数据库 |
| Room | 仅Android | 强 | 只在Android侧使用 |
| Realm(Kotlin) | 共享,但迁移策略要额外设计 | 中 | 模型简单、快速开发 |
我当前的做法是:凡是需要跨端共享的缓存数据,都放到SQLDelight;Android本地专属的数据,比如埋点缓存,继续用Room。这不是技术洁癖,而是为了避免一个数据库查询逻辑在两端出现不完全一致的行为。
3.4 Repository与数据源合并
Repository是共享层的数据门户。它的职责是决定数据从网络拿、从数据库拿,还是先读缓存再刷新。下面是一个简化的合并策略:先发缓存数据,再从网络拉新数据更新缓存,这样能兼顾加载速度和新鲜度。
class RateRepository( private val remote: RateRemoteDataSource, private val local: RateLocalDataSource ) { fun observeRates(forceRefresh: Boolean = false): Flow<List<Rate>> { return flow { if (!forceRefresh) { emitAll(local.observeRates()) } val latest = remote.fetchRates() local.upsertRates(latest) emit(latest) }.catch { error -> emitAll(local.observeRates()) } } }catch里回退到本地缓存,保证弱网环境下用户至少能看旧数据。Repository的错误处理是共享层最容易忽略的部分,建议一开始就把“缓存兜底”写进契约,别让上层UI来猜。
4. UI层到底共享多少:从Compose Multiplatform说起
KMM现在确实可以用Compose Multiplatform把UI也共享到iOS,省掉SwiftUI那套。但“能”不等于“应该”。我在模拟项目X里做了对比,最后依然选择共享业务层、分开写UI,下面说说理由。
4.1 Compose Multiplatform的适用场景
Compose Multiplatform在Android上很顺畅,在iOS上也能跑,但和SwiftUI的互操作、系统控件的观感、导航库生态都不如Android成熟。如果你的App是工具类、看板类、表单密集类,界面相对固定,那用它共享UI会显著减少双端UI代码量。如果你的App需要大量系统原生组件、复杂手势、深色模式的自适应细节,那共享UI的维护成本反而可能更高。
我的建议是:如果团队现在iOS开发资源非常紧缺,可以考虑从某几个比较“表单化”的页面开始试点Compose Multiplatform,但不要一上来就全局共享UI。更稳妥的路径是先用KMM共享模型、仓库和状态,UI层保持原生。等项目跑稳了,再去评估UI层是否值得迁移。
4.2 共享UIModel而非共享UI
无论UI是否共享,共享模块里都应该有清晰的面层模型。不要让上层直接操作数据库实体或网络DTO,而是定义一个跨端通用的UiState数据类。
data class RateListUiState( val items: List<RateItemUiModel> = emptyList(), val isLoading: Boolean = false, val isRefreshing: Boolean = false, val errorMessage: String? = null ) data class RateItemUiModel( val currencyCode: String, val currencyName: String, val buyPrice: Double, val sellPrice: Double )Android端把StateFlow收集起来直接渲染,iOS端用回调把状态更新到SwiftUI或UIKit。两边显示的数据结构完全一致,双端UI只是对同一份数据的两种表达。
4.3 iOS侧消费共享状态
Kotlin的StateFlow直接暴露给Swift用并不舒服。一个实用的做法是在共享层提供一个watch接口,内部用协程收集状态,通过回调返回给Swift。
class RateListStateHolder(...) { private var watchJob: Job? = null fun watch(onState: (RateListUiState) -> Unit): Closeable { watchJob?.cancel() watchJob = scope.launch { uiState.collect { onState(it) } } return Closeable { watchJob?.cancel() } } }Swift侧只需要持有返回的Closeable,在页面销毁时调用dispose(),就能避免协程泄漏。这个模式我强烈推荐:不要在iOS端直接尝试用collect操作StateFlow,回调接口更简单、也更安全。
踩坑提示:在共享层发起协程时,要明确定位Scope的生命周期。用全局Scope收集UI状态会把状态泄漏到页面外,导致它永远不取消,内存也不会释放。
5. 依赖注入与模块化的实战思路
依赖注入在服务端和Android单端项目里几乎是标配,但在KMM里需要换个思路。KMM的编译目标多,任何外部依赖都意味着要同时支持Android和iOS两个平台的编译产物。依赖注入框架虽然能提升可测试性,但也会带来额外的编译开销和学习成本。
5.1 手动依赖图优先
对大多数KMM项目,我建议先用手动依赖注入,也就是在shared模块里写一个AppGraph或AppContainer。这样做的好处是依赖关系一目了然,构建速度也快,不引入反射和额外初始化。
class AppContainer { private val httpClient: HttpClient by lazy { createHttpClient() } private val rateRemoteDataSource: RateRemoteDataSource by lazy { RateRemoteDataSource(httpClient) } private val rateLocalDataSource: RateLocalDataSource by lazy { RateLocalDataSource(createDatabaseDriver()) } val rateRepository: RateRepository by lazy { RateRepository(rateRemoteDataSource, rateLocalDataSource) } val rateListStateHolder: RateListStateHolder by lazy { RateListStateHolder(rateRepository) } }createDatabaseDriver()需要数据库驱动,在Android上需要Context,在iOS上需要数据库文件路径,所以它是expect/actual函数。
5.2 平台能力在AppGraph里的接入
Android侧在Application里创建AppContainer,并把Context传给创建驱动的函数。iOS侧在AppDelegate里创建AppContainer,把沙盒路径传进去。这样业务层完全不感知平台差异。
// androidMain actual fun createDatabaseDriver( context: Any? ): SqlDriver { val appContext = context as Context return AndroidSqliteDriver( schema = AppDatabase.Schema, context = appContext, name = "app.db" ) } // iosMain actual fun createDatabaseDriver( context: Any? ): SqlDriver { val path = NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, true ).first() + "/app.db" return NativeSqliteDriver(AppDatabase.Schema, path) }这里Any?其实不太优雅,但对纯共享层来说,能避免依赖平台具体类型。如果团队愿意,也可以专门定义PlatformContext接口,但对我来说,这个场景下够用就好。
5.3 模块拆分的成本账
Android开发习惯用Gradle多模块拆分编译,但在KMM里,多模块意味着每个Kotlin/Native Target都要重新编译这些模块,构建时间会明显上升。所以除非是真正独立、变更频率低的组件,否则我更倾向于在shared模块内部做包拆分,而不是拆成多个Gradle模块。
一个可以接受的折中方案是:把基础工具(比如时间、日志、通用扩展)单独拆一个shared-core模块,其余业务都留在shared模块。这样既能隔离基础变化,又不至于让Kotlin/Native编译任务膨胀到难以接受。
6. 实操记录:从零到可用的双端行情看板
空谈架构容易,落地才是硬道理。我用一个简化的行情看板Demo来演示完整过程,业务很简单:展示多币种汇率,支持下拉刷新,本地缓存上一次数据。这个Demo基本走完了KMM里你会碰到的常见环节。
6.1 需求与文件结构
需求只两条:进入页面先看到缓存数据,然后请求最新汇率并更新;如果没有缓存,则展示加载中,失败时给重试入口。
shared/src/commonMain/kotlin/com/example/tool/ ├── data/ │ ├── remote/RateRemoteDataSource.kt │ ├── local/RateLocalDataSource.kt │ └── repository/RateRepository.kt ├── domain/ │ ├── model/Rate.kt │ └── usecase/ObserveRateListUseCase.kt └── presentation/ ├── RateListUiState.kt └── RateListStateHolder.ktAndroid App里只有一个ViewModel和一个Compose页面;iOS App里有一个SwiftUI页面。两端UI代码量都不大,因为复杂的“何时加载、何时缓存、如何失败”全部在shared里完成了。
6.2 核心代码走读
RateRepository和前面看到的版本类似,但真正让两端共享价值最大的是ObserveRateListUseCase这一层,它负责把仓库数据转换成页面状态。
class ObserveRateListUseCase( private val repository: RateRepository ) { operator fun invoke(): Flow<RateListUiState> { return repository.observeRates() .map { list -> RateListUiState( items = list.map { it.toUiModel() }, isLoading = false, isRefreshing = false ) } .onStart { emit(RateListUiState(isLoading = true)) } } }这个UseCase既不属于Android架构,也不属于iOS架构,它就是纯粹的Kotlin业务逻辑。两边只要调用它,就能得到完全一致的页面状态。
6.3 iOS接入Shared Framework的流程
在iOS工程里接入shared模块,只需要三步:先用Gradle构建Framework,把产物导入iOS工程,再在iOS工程里添加一个脚本构建阶段来保持产物最新。
每一步都简单,但顺序和参数容易错。我的建议是:
- 先用Android Studio或命令行执行
./gradlew :shared:embedAndSignAppleFrameworkForXcode,在Xcode中通过运行脚本阶段触发。 - 在iOS工程里把
Shared.framework加入Frameworks目录。 - 在Swift代码里
import Shared,然后调用MainViewControllerKt或AppContainer暴露的入口。
我最初忽略的是target对应关系:真机、模拟器、衍生Build的不同会生成不同的Framework。如果跑模拟器却用了真机Framework,链接阶段会报一堆Undefined symbols。这里没有捷径,老老实实让脚本根据当前ARCHS构建对应切片。
7. 常见问题与排查经验记录
KMM的消息在社区里已经很多了,但真正动手时,每个人都会在同样的位置卡住几次。我把自己在模拟项目X和另外两个小型项目里遇到的高频问题整理成了一张速查表,也算是实战后的避坑注脚。
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| iOS编译报链接错误 | Framework产物和当前架构不匹配 | 检查脚本是否按ARCHS生成模拟器/真机切片 |
| commonMain里无法调用Android API | 在共享层引入了Android专属类型 | 重构到androidMain,并通过expect/actual暴露接口 |
| iOS侧状态一直不更新 | 协程Scope被提前取消 | 统一用可取消的watch接口,页面退出时dispose |
| Ktor请求在iOS上超时 | 未使用Darwin引擎 | iosMain依赖里加入Ktor Darwin引擎 |
| SQLDelight编译不过 | 表名或字段名的SQL语法问题 | 在IDE里打开生成的数据库文件核对SQL |
| Android/iOS同一功能行为不一致 | 日期格式化或整数除法规则不同 | 统一使用Kotlin的跨平台库,封装成公共工具 |
7.1 编译慢怎么办
Kotlin/Native的编译比JVM慢,这是平台特性决定的。我有三个实用建议:少拆Gradle模块、开启Gradle配置缓存、把iOS Framework的调试构建和发布构建分开。Debug阶段只构建当前模拟器需要的target,不要每次把三个iOS target全部编一遍。
7.2 升级Kotlin版本要谨慎
KMM最怕的是Kotlin版本和Android Gradle Plugin版本不兼容。升级前一定先看官方兼容矩阵,不要在旧模板上直接瞎改版本号。我见过有人把Kotlin从1.9升到2.0,结果SQLDelight和Ktor的配套版本没跟上,一连串编译错误。
7.3 把公共API当作SDK来管理
这是一个从实战里沉淀出的习惯:shared模块对外暴露的类和方法,尽量少、稳定、文档齐全。因为Android端和iOS端都依赖它,一旦某个公共接口签名要改,意味着两个客户端同时发版本。我会用API dump工具把每次变更记录到版本控制里,Review的时候就能看到“这个改动影响了双端”,而不是等集成时才发现。
最后再分享一点个人体会
我自己在实际项目中经历过的最大感受是:KMM不是用来消灭原生代码的,它更像个“规则凝聚器”。把容易分叉的数据、计算、状态逻辑凝聚在Kotlin共享层里,让Android和iOS各自专注自己擅长的UI和交互。这样既保住了原生的质感,又不用忍受两套业务逻辑长期并行维护。
如果你正准备上手KMM,我的建议是从一个很小的业务场景开始试点,不用把整张架构图铺得太大。先把网络层和Repository放进shared,跑通Android和iOS的数据通路,再逐步把数据库、UseCase、状态Container放进来。等你发现双端因为共享逻辑而少加了很多班,再回头谈全面铺开也不迟。这个方法,我从模拟项目X开始试验,到现在一直觉得是最稳妥的进入方式。