☰
KMM跨平台实践:Kotlin共享业务逻辑,Android与iOS架构落地指南
2026/10/21 21:19:56 网站建设 项目流程

假如你手里有一个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全写一套需要单独学习DartUI强定制、一套代码跨全端
React Native原生组件桥接JSJS全写一套前端技术栈友好动态发布、前端团队主导

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.xcodeproj

commonMain放共享代码,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的共享数据库方案。

方案共享性类型安全适用情况
SQLDelightAndroid/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.kt

Android 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工程里添加一个脚本构建阶段来保持产物最新。

每一步都简单,但顺序和参数容易错。我的建议是:

  1. 先用Android Studio或命令行执行./gradlew :shared:embedAndSignAppleFrameworkForXcode,在Xcode中通过运行脚本阶段触发。
  2. 在iOS工程里把Shared.framework加入Frameworks目录。
  3. 在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开始试验,到现在一直觉得是最稳妥的进入方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询