KMM跨平台实践:Kotlin共享业务逻辑,Android与iOS架构落地指南
假如你手里有一个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.xcodeprojcommonMain放共享代码androidMain放Android平台实现iosMain放iOS平台实现。androidApp是一个普通Android工程依赖shared模块。iosApp通过一个Framework产物接入shared模块。我建议把shared模块当成一个“独立的SDK”来维护而不是Android工程的附属模块。它的边界应该是平台无关的业务规则任何android.开头的import都不应该出现在commonMain里。2.2 Gradle里配置多平台TargetKMM的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 MutableStateFlowRateListUiState(RateListUiState.Loading) val uiState: StateFlowRateListUiState _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): ListRate { return httpClient.get(https://api.example.com/rates) { parameter(base, baseCurrency) }.body() }Android端引擎用OkHttpiOS端引擎用Darwin底层细节不用在业务代码里关心。这里的关键是配置ignoreUnknownKeys服务端新增字段不会导致解析失败这对双端联调很有用。3.3 本地存储的取舍本地缓存是双端项目最容易拉胯的地方。如果你的项目只需要Android端缓存那Room依然是很好的选择毕竟生态成熟。但如果缓存数据也需要在iOS共享比如离线模式、草稿箱、行为日志那就应该考虑SQLDelight这类能用Kotlin写SQL的共享数据库方案。方案共享性类型安全适用情况SQLDelightAndroid/iOS共享强编译期校验SQL两端都需要访问数据库Room仅Android强只在Android侧使用Realm(Kotlin)共享但迁移策略要额外设计中模型简单、快速开发我当前的做法是凡是需要跨端共享的缓存数据都放到SQLDelightAndroid本地专属的数据比如埋点缓存继续用Room。这不是技术洁癖而是为了避免一个数据库查询逻辑在两端出现不完全一致的行为。3.4 Repository与数据源合并Repository是共享层的数据门户。它的职责是决定数据从网络拿、从数据库拿还是先读缓存再刷新。下面是一个简化的合并策略先发缓存数据再从网络拉新数据更新缓存这样能兼顾加载速度和新鲜度。class RateRepository( private val remote: RateRemoteDataSource, private val local: RateLocalDataSource ) { fun observeRates(forceRefresh: Boolean false): FlowListRate { 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: ListRateItemUiModel 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(): FlowRateListUiState { 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接口页面退出时disposeKtor请求在iOS上超时未使用Darwin引擎iosMain依赖里加入Ktor Darwin引擎SQLDelight编译不过表名或字段名的SQL语法问题在IDE里打开生成的数据库文件核对SQLAndroid/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开始试验到现在一直觉得是最稳妥的进入方式。