做移动端跨端方案选型,绝大多数团队的第一反应都是 Flutter。我不否认 Flutter 的优秀,但从 2023 年开始,我所在团队走了一条不太一样的路:KMP-CMP,也就是 Kotlin Multiplatform 加 Compose Multiplatform,用一套 Kotlin 代码同时覆盖 Android 和 iOS,并且在 App 内部用 SPA 的单容器架构来组织全部页面。这套路线目前已经稳定支撑了三个线上版本,双端 UI 共享率接近九成,我想把这段工程实践完整拆出来,给正在做技术选型或者对 KMP-CMP 跃跃欲试的同行一个真实参考。
先说结果,再说适合谁。KMP-CMP 的核心价值不是“完全替代 Flutter”,而是在 Kotlin 生态里提供一条 UI 层也能共享的工程路线:业务逻辑、数据层、页面 UI 全部写一份,双端运行。它适合已经有 Android 技术积累、又被 iOS 交付成本压得喘不过气来的团队;也适合那些对原生能力依赖较强、App 里有大量蓝牙、地图、硬件交互的业务场景。如果你还没接触过 Kotlin 或 Compose,这篇文章里的代码示例会帮你避开我踩过的那些坑。
1. 路线选型:为什么是 KMP-CMP 而不是 Flutter
1.1 KMP 和 CMP 到底是什么关系
很多人把 KMP 和 CMP 混在一起说,其实它们是两个层面。KMP 是 Kotlin Multiplatform 的缩写,解决的是“业务逻辑跨端共享”的问题,一套用 Kotlin 写的网络层、数据仓库、领域模型,可以编译成 Android 的字节码和 iOS 的 Framework,在各自平台被原生代码调用。CMP 是 Compose Multiplatform 的缩写,是 JetBrains 在 Jetpack Compose 基础上做的跨端 UI 实现,把 Android 的声明式 UI 范式搬到了 iOS 上。KMP 负责“脑子”,CMP 负责“脸”,两者合起来才叫“全页面跨端 UI”。
这个组合的关键在于:它和 Flutter 共享同一个目标,就是“一份代码,双端运行”,但底层逻辑完全是另一套。Flutter 用的是 Dart 语言加自绘渲染引擎,整个控件树都是自己画的,等于在系统之上又造了一个完整操作系统;KMP-CMP 则站在 Kotlin 和平台原生控件肩膀上,通过 Compose 编译器把 UI 描述翻译成平台可执行的绘制指令,所以和原生代码的互操作天然更顺畅。
我要再强调一点:CMP 并不是 KMP 的唯一 UI 方案。KMP 完全可以只共享逻辑层,UI 层还是用 SwiftUI 加 Jetpack Compose 分别写。这种方案很多团队在用,但它没有解决“UI 双份维护”的痛点。我们选择 CMP,就是为了把最后一个双份成本也干掉。
1.2 与 Flutter 的核心差异:生态还是护城河
选 Flutter 还是 KMP-CMP,本质上是在选生态。Flutter 的生态里,Dart 是唯一语言,包管理器是 pub,UI 组件全部来自 Flutter 体系;KMP-CMP 的生态里,Kotlin 是 JVM 语言家族成员,背后有 JetBrains 和 Google 双重背书,UI 组件是 Jetpack Compose 的跨端延伸。这意味着什么?意味着你团队里的 Android 开发者转型 KMP-CMP 几乎零成本,Android 上的 Compose 经验直接迁移到 iOS;而 Flutter 要求所有成员重新学一门语言加一个新 UI 框架,这条学习曲线不是几个月能抹平的。
我做一个团队常看的对比表格:
| 对比维度 | Flutter | KMP-CMP |
|---|---|---|
| 开发语言 | Dart,需要团队重新学习 | Kotlin,Android 团队直接上手 |
| UI 渲染 | 自绘引擎(Skia/Impeller),脱离平台控件 | 平台原生渲染,贴近系统体验 |
| 原生互操作 | 通过 MethodChannel/Pigeon 桥接 | 直接调用原生 API,expect/actual 模式 |
| 热重载体验 | 优秀,状态保留稳定 | 尚可,Compose 预览在 iOS 端不如 Android 顺手 |
| 动态化/热更新 | 受限,需要额外方案 | 与原生一致,走发布流程 |
| 团队技术积累迁移成本 | 高,几乎从头开始 | 低,Android 知识直接复用 |
| 跨端 UI 共享度 | 100% | 可接近 100%(部分平台差异需 expect/actual) |
我没有说 Flutter 不好,它生态成熟、社区活跃、招聘市场上人也多。但对我们这种 Android 基因很强的团队来说,KMP-CMP 的学习成本几乎是 Flutter 的三分之一。花同样的时间,我们已经上线了业务,隔壁组还在调 Dart 的异步模型。
1.3 适合走这条路的团队画像和业务特征
如果你正在做技术选型,先别急着抄我们的方案。KMP-CMP 目前有几个前提条件,缺一条我都不建议硬上。
第一,团队里至少有 2 个能写 Kotlin 的人。CMP 虽然是跨端,但最终调用的还是原生 API,Android 和 iOS 的平台差异依然存在,你得有人能看懂两端日志。第二,业务场景需要对原生能力有较强依赖,比如低功耗蓝牙、硬件 SDK、地图服务。这类场景 Flutter 也能做,但每接一个原生 SDK 都要写一次 MethodChannel 桥接,代码量大不说,排查问题还费劲。第三,领导层愿意接受渐进式迁移,而不是指望推翻重来。我们当时就是从 Android App 里先抽出数据层接 KMP,再逐步把页面迁到 CMP,整个过程没有大规模重写。
至于纯 UI 展示型产品、快速验证原型的场景,我反而建议 Flutter 更省事,因为它的生态里现成的轮子实在太多了,从 UI 库到状态管理一应俱全,开箱即用。
2. 移动端 SPA 架构设计:单容器承载全部页面
2.1 移动端为什么要做 SPA 化
SPA 是 Single Page Application 的缩写,原本是 Web 前端的概念,指整个应用只有一个 HTML 页面,页面切换靠前端路由完成。我把它搬到了移动端:App 内部只有一个原生容器页,所有业务页面都是容器内的 Compose 组件,页面跳转完全由路由表控制,不再新建原生 Activity 或者 ViewController。
为什么这么干?核心原因是双端生命周期统一。传统多 Activity 架构在 Android 上很自然,back 栈、任务栈都是系统管好的;到了 iOS 就完全不一样,View Controller 的压栈出栈语义和 Android 对不上,跨端共享代码时,导航逻辑每端写一遍,互相之间还没法复用。用 SPA 单容器架构之后,路由表变成一份纯 Kotlin 数据,页面切换、参数传递、返回手势全部统一处理,双端行为一致率大幅提升。
另一个好处是内存控制。移动端 SPA 的单容器本质是用一个原生页承载 Compose 的组件树,页面退出时组件从组合中移除,对应的资源直接释放。相比传统多页面栈,页面销毁更彻底,不容易出现页面栈越积越深导致的内存膨胀问题。这和热词里大家关心的“移动端性能优化”正好是同一类话题。
2.2 路由方案选型:Voyager、Decompose 还是自研
移动端 SPA 最核心的是路由。我们调研过三个方案。
第一个是 JetBrains 推荐的 Voyager,它提供 Screen、Navigator、TabNavigation 等抽象,API 设计非常贴近 Compose 的声明式风格,写起来最舒服。但它的缺陷是页面参数传递是类型安全的序列化方式,跨模块通信需要提前定义好路由协议,我们业务里有大量动态下发页面配置的场景,处理起来有点绕。
第二个是 Badoo 开源的 Decompose,它把状态管理和导航拆成独立组件,支持复杂的嵌套导航、跨平台生命周期、back 手势处理。功能很强,但学习成本不低,抽象层级多,新同学上手比较慢。
第三个就是我们最终的选择:在 Voyager 基础上包了一层自研路由表。核心思路是把页面路径注册成字符串常量,每个页面组件对应一个路由节点,参数用 Kotlin 的 data class 封装。这样做的好处是路由寻址可以和后端下发的页面链接打通,运营配置的 H5 跳转原生页面也能直接命中路由节点。
自研路由的简化模型长这样:
data class RouteNode( val path: String, val screen: @Composable (RouteArgs) -> Unit, val needLogin: Boolean = false ) class SPAApp : Application() { val routeTable = listOf( RouteNode("/home", { HomeScreen(it) }), RouteNode("/profile", { ProfileScreen(it) }, needLogin = true), RouteNode("/order/detail", { OrderDetailScreen(it) }, needLogin = true) ) }页面跳转时,只需要根据 path 查表得到对应 Composable,再传入解析好的 RouteArgs 即可。整个方案轻盈、可控,后续加动态化配置也容易。
2.3 shared 模块的分层与平台差异隔离
SPA 架构确定后,紧接着就是代码分层。我们用标准的三层结构:domain 层放纯 Kotlin 的领域模型和业务规则,data 层放网络、数据库、偏好存储等数据源,presentation 层放 ViewModel 加 Compose UI。
分层里最要命的是平台差异隔离,这个 Google 和 JetBrains 已经给了标准方案,就是 expect/actual 机制。expect 声明一个公共接口,actual 在 Android 和 iOS 各自实现。举个例子,App 里需要读取设备型号,域名层的接口只暴露一个函数:
// commonMain expect fun getDeviceModel(): String// androidMain actual fun getDeviceModel(): String { return Build.MODEL ?: "unknown" }// iosMain actual fun getDeviceModel(): String { return UIDevice.currentDevice.model }业务代码完全不用关心它在两端是怎么实现的。我强烈建议团队里新写的公共 API 一开始就按 expect/actual 设计,不要等项目跑起来再补,后期拆平台差异的代价远大于前期设计成本。
3. 工程落地与核心实现
3.1 初始化 KMP-CMP 工程的正确姿势
工程初始化这一步,十个人有九个会踩版本坑,我直接说结论。Kotlin Multiplatform 的插件版本、Compose Multiplatform 插件版本、AGP 版本、Gradle 版本是强绑定关系,不是越新越好。我有一个自己的版本匹配原则:Kotlin 用稳定版压一年,CMP 用跟随 Kotlin 主版本发布的稳定版,AGP 用 Android Studio 推荐的最低版本。
以我当前项目为例,核心版本号是:
// 顶层 build.gradle.kts plugins { kotlin("multiplatform") version "2.0.21" id("org.jetbrains.compose") version "1.7.3" id("com.android.application") version "8.7.3" }创建工程时别手动搭 Gradle 结构,直接用 JetBrains 官方的 KMP Wizard 生成,它会根据你选的配置自动匹配一套能跑的版本组合。生成之后再逐步升级,出问题也容易定位。
模块结构建议这样组织:
shared/ src/ commonMain/kotlin/ // 公共代码:路由、页面、业务逻辑 androidMain/kotlin/ // Android 平台实现 iosMain/kotlin/ // iOS 平台实现 build.gradle.ktsCommon 模块的 build.gradle.kts 里,记得把所有平台 target 配全:
kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } listOf( iosArm64(), iosSimulatorArm64() ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "Shared" isStatic = true } } sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(libs.ktor.client.core) implementation(libs.kotlinx.coroutines.core) } androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } } }这里提一个很多人会忽略的点:iosArm64()和iosSimulatorArm64()都要配上,前者是真机,后者是 Apple Silicon 模拟器。如果漏了模拟器 target,你在 Mac 上跑 iOS 模拟器时就会发现 framework 根本编不出来。
3.2 全页面跨端 UI 的 Compose 组件实践
CMP 的 UI 代码写起来和 Jetpack Compose 几乎一样,但跨端之后会出现一些细微差异。我从实践中总结出三条最实用的经验。
第一条,Material3 组件在 iOS 上不要无脑用。比如TopAppBar在 Android 上默认自带返回箭头和状态栏占位,到了 iOS 上状态栏高度策略不同,标题栏会偏高或者和刘海重叠。我们的做法是自定义一个CommonTopBar,用 expect/actual 处理 top inset,Android 返回 24.dp,iOS 返回安全区高度加状态栏高度。这样双端观感才能一致。
第二条,列表性能是 UI 跨端的生命线。CMP 的LazyColumn性能已经不错,但要注意 item 必须设置key,否则列表滑动时重组成本会指数级上升。另外,图片加载用 Coil 3 的跨端版本,它在 KMP-CMP 下可以共用一套图片加载代码,还能天然支持网络图片缓存和占位图。
第三条,字体渲染差异。iOS 的字体渲染比 Android 细,同样的 16.sp 在 iOS 上看起来比 Android 小。这不是 Bug,是平台的系统字体差异。我们会在公共主题层统一调节,iOS 上全局字重加粗一点点,观感就拉回来了。
一个典型的页面组件大概长这样:
@Composable fun ProfileScreen(vm: ProfileViewModel = viewModel()) { val state by vm.uiState.collectAsState() CommonTopBar(title = "个人中心") LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(state.orderList, key = { it.id }) { order -> OrderCard(order = order) } } }3.3 数据层与网络请求封装
移动端 SPA 的数据层是整个架构的地基。我们选了 Ktor Client 作为跨端网络库,它是 JetBrains 出品,和 KMP-CMP 同源,配合度最好。Android 端引擎用 OkHttp,iOS 端用 Darwin 引擎,两端行为差异极小。
这里要说一个热词里很多人关心的点:“spa项目开发之jwt验证码实现”。移动端 SPA 和 Web SPA 一样要做鉴权,我们用 JWT 加验证码的组合。验证码图片在登录页用 Compose 自绘加随机干扰线实现,Token 采用双 Token 策略:Access Token 短时效,Refresh Token 长时效,网络层拦截 401 后自动刷新重放请求。Ktor 的拦截器机制写起来非常顺手:
class AuthHandler(private val tokenStore: TokenStore) { fun handleRequest(): HttpRequestBuilder.() -> Unit = { tokenStore.getAccessToken()?.let { token -> header(HttpHeaders.Authorization, "Bearer $token") } } fun handleResponse(): HttpResponseValidator = object : HttpResponseValidator { override fun validateResponse(response: HttpResponse) { if (response.status.value == 401) { // 统一刷新 Token,刷新失败则踢回登录页 } } } }TokenStore 用 expect/actual 实现:Android 端用 DataStore 存储,iOS 端用 NSUserDefaults,业务层直接调用统一接口,不用关心底层存储差异。
3.4 与原生能力互操作:蓝牙、地图、推送这类场景怎么处理
KMP-CMP 最大的杀手锏就是原生互操作。拿低功耗蓝牙举例子,热词里有人在问“flutter 低功耗蓝牙 iOS 有问题嘛”。Flutter 在这块确实坑不少,因为它的 Dart 层到原生蓝牙框架之间隔了一层桥,每次调用都要走二进制消息通道,出问题后要同时排查 Dart、桥接层和原生三层代码。KMP-CMP 是直接调用原生蓝牙 API,Android 端在 androidMain 里写 BluetoothGatt 操作,iOS 端在 iosMain 里写 CoreBluetooth 操作,中间没有任何桥接层,排查问题时一条链路直接通到底。
我们的蓝牙模块设计是这样的:commonMain 定义BLEManager接口,提供扫描、连接、读写特征值的抽象;androidMain 用系统蓝牙 API 实现,iosMain 用 CoreBluetooth 实现。业务页面只关心 commonMain 的接口状态,比如蓝牙连接状态用 StateFlow 暴露给 UI 层:
// commonMain interface BLEManager { val connectionState: StateFlow<ConnectionState> fun scan() fun connect(deviceId: String) fun write(data: ByteArray) }同类问题还有地图。我们接入过天地图,也用过高德地图的 Flutter 插件,但地图这种涉及大量手势交互和原生绘制的场景,Flutter 插件的维护成本一直在涨。KMP-CMP 下地图可以直接封装进 shared 模块,Android 端用高德原生的 Fragment 嵌入 Compose AndroidView,iOS 端用 MKMapView 嵌入 Compose UIKitView,两端各写一份包装实现,但业务调用方式和坐标系换算逻辑全部共用一套 Kotlin 代码。
有一些非常常识的坑:iOS 蓝牙权限必须在 Info.plist 里声明NSBluetoothAlwaysUsageDescription,定位权限要声明NSLocationWhenInUseUsageDescription,这类权限声明不能用 expect/actual 统一,它属于平台配置,忘了加的话运行时会直接崩溃。
4. 性能优化与稳定性治理
4.1 启动链路与首屏优化
移动端 SPA 的性能优化重点和原生 App 不一样。由于只有一个容器页,冷启动时整个 Compose 组件树都要在容器里构建,首屏渲染路径比其他架构更长,所以“启动优化”其实就是“首屏组件树瘦身”。
我们的做法有四个。第一,路由表懒加载,不把全部页面组件一次性注册到导航器里,只有一个 Map 存路径到构造函数的映射,真正导航到该页面时才创建。第二,首屏页面用remember { }加 ViewModel 懒加载,网络请求在页面组合完成后再触发,不阻塞 Compose 初始化。第三,SPA 容器页的原生背景色和启动页保持一致,让用户在跳进容器页的瞬间感受不到白屏闪烁。第四,所有页面的图片资源默认用 Compose 的尺寸限定符做多倍图适配,减少首屏解码压力。
iOS 上还有一个额外的优化点:CMP 生成的 framework 如果是动态库,启动时 Linker 会花时间加载符号表。我们直接改用静态库配置,启动时间能缩短 10% 到 15%,代价是包体积会小幅上涨,但在业务可控的范围内。
4.2 列表性能与图片内存治理
热词里反复出现的“flutter内存优化”和“移动端性能优化”在 KMP-CMP 下同样适用,但排查路径完全不一样。CMP 的内存模型和 Android 原生一致,可以用 Android Studio 的 Profiler 直接看堆内存,不像 Flutter 还要先转 Dart 层再做实例分析,省了不少功夫。
列表性能上,我们遇到过最典型的问题是LazyColumn在 iOS 上快速滑动时掉帧。定位后发现不是 CMP 渲染问题,而是图片库默认的磁盘缓存策略在 iOS 上太保守,滑动时频繁做解码。换用 Coil 3 并手动调大内存缓存比例后,问题直接消失。
图片内存还有一种病态场景:网络图片的宽高信息和实际展示尺寸不一致,导致图片加载后先按原尺寸解码再压缩,内存瞬间飙升。我们在网络层统一加了Resize转换器,加载前就拿到目标尺寸,解码时就按目标尺寸解码。以下是一个简化写法:
AsyncImage( model = ImageRequest.Builder(LocalPlatformContext.current) .data(imageUrl) .size(400, 400) .crossfade(true) .build(), contentDescription = null )4.3 协程多线程:KMP-CMP 的并行处理方案
Flutter 的多线程靠 Isolate,写起来要专门设计通信协议;KMP-CMP 直接用 Kotlin 协程,API 在各个平台是一致的。这里有个很容易出错的地方:协程的Dispatchers.IO在不同平台上的实际实现不同。Android 上是基于 OkHttp 的线程池封装,iOS 上是 coroutines 在 Darwin 下用 Grand Central Dispatch 实现。写业务代码时,永远不要在 commonMain 里硬编码线程模型,而是定义一个公共的调度器:
// commonMain expect val appDispatcher: CoroutineDispatcherAndroid 实现用Dispatchers.IO,iOS 实现用Dispatchers.Default。这样保证 commonMain 里的withContext(appDispatcher)在两端都不会阻塞 UI 线程。
我们线上遇到过一次诡异的崩溃:在 iOS 上往一个MutableStateFlow里发数据时,后台线程没经过主线程调度,直接触发 UI 重组,导致并发修改崩溃。排查后确认是某个工具类里用了GlobalScope.launch,没有指定主线程。后来全局扫了一遍,所有协程入口统一用MainScope创建,杜绝了这类并发问题。
5. 常见问题与排查技巧实录
5.1 构建期:Gradle、AGP、Kotlin 版本强绑定
构建期最大的坑是版本矩阵。KMP 和 CMP 插件对 Kotlin 编译器版本非常敏感,经常出现“前一天还能编译,升级一个插件后连不上”的情况。症状通常是e: Supertypes of the following classes cannot be resolved这类报错,第一次遇到会误以为是业务代码写错了。
我的排查路径是固定的:先看 Gradle 警告里的 Plugin 版本检查输出,再比对 Kotlin 版本和 CMP 版本的生命周期对应关系。JetBrains 在 GitHub Releases 页面有版本兼容矩阵,我每次升级前先核对一遍再动手。
另外遇到过和热词里那条“you are applying flutter‘s main gradle plugin imperatively using the apply”类似的报错。KMP 工程里如果同时混了 Android App 模块和 KMP 模块,很容易出现插件重复应用或者应用顺序混乱。解决办法就是在根项目的 settings.gradle.kts 里用pluginManagement统一声明插件仓库和版本,所有模块只通过alias引用插件,坚决不用apply plugin命令式写法。
5.2 iOS 构建:CocoaPods、Xcode 和 Framework 配置
KMP 生成的 iOS Framework 在集成到 Xcode 工程时有两种方式:CocoaPods 和 Swift Package Manager。我们用 CocoaPods 更稳,因为它对动态库和静态库的支持更成熟,和 CMP 的集成文档也是首先覆盖这条路。
一个常见的坑:Xcode 真机编译要用iosArm64的 framework,模拟器编译要用iosSimulatorArm64,如果只生成了一种,会在构建时报building for iOS Simulator, but linking in object file built for iOS。写脚本在编译前动态选择 framework 时,要注意区分目标 SDK:
if [ "$PLATFORM_NAME" = "iphonesimulator" ]; then SHARED_FRAMEWORK_DIR="$(PWD)/shared/build/xcode-frameworks/$(CONFIGURATION)/iosSimulatorArm64" else SHARED_FRAMEWORK_DIR="$(PWD)/shared/build/xcode-frameworks/$(CONFIGURATION)/iosArm64" fi这问题在热词里被反复问到是有原因的。如果你在本地跑脚本没问题,一上 CI 就报错,八成就是忘了区分模拟器和真机的 SDK 路径。
5.3 鸿蒙适配:KMP-CMP 的下一站
热词里已经出现“kmp 鸿蒙适配”,这是真实存在的趋势。HarmonyOS NEXT 启动后,很多 Android 团队在评估 KMP 能不能延伸到鸿蒙。目前社区有 OpenHarmony 的 Kotlin Multiplatform 适配项目,Kotlin/Native 目标里也加入了ohosArm64相关支持。但我要提醒,目前 CMP 对鸿蒙的 UI 自适应还处于早期阶段,Compose 编译器在鸿蒙上的渲染后端还没有完全稳定,生产环境大规模使用仍需观望。
我的建议是:如果鸿蒙是你未来半年的核心交付目标,可以先在数据层用 KMP 做跨端,等 CMP 在鸿蒙上的适配成熟后再迁 UI。渐进式的好处在这种时刻就体现出来了,不至于被单一路线绑死。
5.4 性能定位工具:一套组合拳打天下
跨端开发的排查工具链是最容易让人头秃的。Flutter 有自己的一套 DevTools,但跨端桥接问题定位起来依然费劲。KMP-CMP 开发时的性能定位可以用三套工具组合。
Android 端没什么好说的,Android Studio 自带的 Profiler 直接看 CPU、内存、网络,和原生开发体验完全一致。iOS 端用 Instruments 的 Time Profiler 和 Allocations 模板,能直接看到 Kotlin 协程调度到 GCD 队列上的执行情况。跨端公共代码的内存问题用 Kotlin/Native 的 Memory Profiler,它能看到 commonMain 里的对象分配,是排查泄漏的利器。
6. 团队落地建议与演进路径
6.1 渐进式迁移的三个阶段
如果有人问我要不要全员 All-in KMP-CMP,我给出的答案永远是分三步走。
第一阶段,只共享数据层。把现有 Android App 的网络请求、数据仓库、登录状态迁到 shared 模块,iOS 端暂时继续用原生代码,但数据模型从 shared 里引用。这个阶段收益低但风险极低,团队能先跑通 KMP 的日常开发流程。
第二阶段,共享业务逻辑加部分 UI。选两三个无复杂原生依赖的页面迁到 CMP,比如个人中心、设置页、订单列表。这个阶段的主要目标是验证 CMP 的双端 UI 一致性,和团队对声明式 UI 的熟练度。
第三阶段,全页面跨端 UI 加 SPA 化。等团队对 CMP 有足够信心后,再逐步把原生页面替换成 Compose 页面,最终收敛到单容器 SPA 架构。这个阶段要同步引入路由规范、组件库规范和原生能力封装规范,不然并行开发时容易写出“一台代码两套风格”的怪胎。
6.2 什么情况下建议继续用 Flutter
我在前面说了很多 KMP-CMP 的好处,但必须负责任地把“不适用场景”也讲清楚。如果你的 App 以内容展示为主,页面里没有多少原生硬件交互,团队没有 Kotlin 背景,招人市场上 Flutter 开发者更容易招到,那 KMP-CMP 的性价比就远远不如 Flutter。
另一个典型场景是强动态化需求。Flutter 的产物是自绘渲染,理论上可以在云端加载后本地运行,但要真正把这个能力做出来,复杂度极高,两边其实都不适合,最终都要靠原生层面的热更新体系。这种情况下选哪边都一样,关键看谁的学习成本低。
6.3 团队人员能力模型如何调整
KMP-CMP 对团队成员的能力要求比 Flutter 更“叠 buff”。Android 开发要懂 iOS 的生命周期差异,误以为 Kotlin 一把梭就不用管平台细节,实际 expect/actual 写了多少,就说明你对平台差异理解有多深。iOS 开发要能接受用 Kotlin 写 UI,虽然 CMP 在 iOS 上能用的系统控件和手势 API 在逐步补齐,但和 SwiftUI 的原生体验还是有差距。
我们团队的做法是重新定义岗位:不再分 Android 开发者和 iOS 开发者,统一叫“跨端业务开发”,但每个小组必须保留一个“平台工程师”,负责维护 androidMain 和 iosMain 里的平台实现。这样既不牺牲专业深度,又能让业务代码的共享率持续提高。
我个人非常看好这条路线接下来的走势。KMP 已经是 Google 在 Android 官方文档里推荐的跨平台逻辑共享方案,CMP 的 iOS 稳定版发布后,JetBrains 的迭代速度快得惊人。如果你所在团队正在纠结跨端选型,不妨先拿出一个最小功能模块,用 KMP-CMP 做个 2 到 4 周的可行性验证,再用数据说话。我当时就是靠一个登录页加个人中心,说服了整个团队从 Flutter 转向我们今天在用的这套架构。