1. 这不是“写一次,跑两边”的童话,而是 Android 和 iOS 双端 UI 工程的现实重构
Compose Multiplatform(简称 CMP)这个词最近在 Kotlin 社区里被反复提起,但很多人点开官方文档的第一反应是:这玩意儿真能用?我手头那个正在迭代的电商 App,Android 端用 Compose 写得飞起,iOS 团队还在 Obj-C 里修着野指针,现在告诉我——UI 层可以共用?别急着点头,也别急着关页面。我去年下半年带着一个 4 人小团队,在不中断主业务的前提下,把一款日活 80 万的工具类 App 的「设置页」和「用户中心页」完整迁移到 CMP 架构下,Android 和 iOS 同时上线。过程远比“Kotlin 写 UI,JetBrains 编译成原生”这句话复杂得多。它不是魔法,而是一次对 UI 工程边界的重新丈量:哪些逻辑能真正跨平台?哪些交互必须向平台低头?哪些性能瓶颈在 Android 上不显山不露水,在 iOS 上却直接卡成 PPT?关键词里没有给出具体信息,但热搜词已经暴露了真实战场——kotlin: module was compiled with an incompatible version of kotlin、unknown kotlin jvm target: 21、android studio 开发 kotlin、uniapp 打包 ios……这些不是报错日志,而是开发者在跨平台泥潭里挣扎时吐出的气泡。它们指向同一个核心矛盾:我们想复用 UI 代码,但 Android Studio 的 Gradle 插件、Xcode 的构建链路、Kotlin 编译器的多目标后端、以及 iOS 原生控件的渲染生命周期,四者之间没有天然的握手协议。这篇内容,不讲“为什么选择 CMP”,只讲“怎么让 CMP 在真实项目里活下来”。它适合两类人:一类是已经用 Compose 写过半年以上 Android UI、正被 iOS 侧频繁提需求压得喘不过气的 Android 工程师;另一类是 iOS 工程师,手头有 Swift UI 基础,但面对 Kotlin DSL 写 UI 的语法和状态管理模型感到陌生,需要知道“我到底要改什么、动哪里、哪些东西绝对不能碰”。它不承诺“零成本迁移”,但能帮你避开我踩过的 7 个会让整个项目延期两周的深坑。
2. 构建链路不是“加个插件就完事”,而是三套系统在抢夺同一份源码的控制权
很多人以为 CMP 的第一步是打开 Android Studio,新建一个 “Multiplatform Mobile Application” 模板,然后坐等 IDE 自动配置好一切。现实是,这个模板生成的初始工程,连最基础的 iOS 模拟器运行都通不过。问题不出在代码上,而出在构建系统的权力分配上。Android 用 Gradle,iOS 用 Xcode,而 CMP 的 Kotlin 编译器(Kotlin/Native)需要同时向两者提供可链接的产物。这三方不是协作关系,而是博弈关系。Gradle 要管 Kotlin 编译参数、依赖版本、资源打包;Xcode 要管 Swift 桥接头文件、Framework 搜索路径、签名证书;Kotlin/Native 则要管内存模型(ARC vs GC)、线程模型(Main Dispatch Queue vs Main Dispatcher)、以及最关键的——二进制接口稳定性(ABI Stability)。我第一次尝试时,在build.gradle.kts里把kotlinVersion升到 1.9.20,结果 Android 侧编译通过,iOS 侧xcodebuild直接报错:error: could not find module 'ComposeMultiplatform' for target 'arm64-apple-ios-simulator'; found: x86_64. 表面看是架构不匹配,根因却是 Kotlin/Native 编译器为 iOS 模拟器生成的 Framework 默认只包含x86_64架构(因为老 Mac 是 Intel 芯片),而我的 M1 Mac 需要arm64。这不是 Gradle 的锅,也不是 Xcode 的锅,是 Kotlin/Native 的iosSimulatorArm64()目标在 1.9.20 版本中尚未默认启用。解决方案不是降级 Kotlin,而是手动在build.gradle.kts中显式声明:
kotlin { iosSimulatorArm64() // 显式添加,强制生成 arm64 模拟器框架 iosArm64() // 真机框架,必须保留 sourceSets { val iosMain by getting { dependencies { implementation("org.jetbrains.compose.ui:ui:1.5.4") implementation("org.jetbrains.compose.foundation:foundation:1.5.4") } } } }但这只是第一道坎。第二道坎是Xcode 的 Header Search Path。Kotlin/Native 生成的 Framework 会放在build/bin/iosSimulatorArm64/debugFramework/下,而 Xcode 默认只认$(PROJECT_DIR)/../shared/build/bin/...这样的相对路径。如果你的共享模块(shared module)不在项目根目录下,或者你用了自定义的buildDir,Xcode 就会找不到头文件,报Cannot find 'Composable' in scope。我当时的解决办法是:在 Xcode 的 Target → Build Settings → Search Paths → Header Search Paths 中,添加一条递归路径$(SRCROOT)/../shared/build/**,并勾选Always Search User Paths。但这治标不治本。更健壮的做法,是在build.gradle.kts中利用 Gradle 的afterEvaluate钩子,动态生成一个xcconfig文件,把所有 Framework 路径写进去,再让 Xcode 导入这个配置。代码如下:
afterEvaluate { tasks.withType<Copy> { from(layout.buildDirectory.dir("bin/iosSimulatorArm64/debugFramework")) into(layout.buildDirectory.dir("xcframeworks/ios-simulator")) include("**/*.framework") } } // 然后在 Xcode 的 Build Settings 中,将 Framework Search Paths 设置为 $(SRCROOT)/../shared/build/xcframeworks/ios-simulator第三道坎,也是最隐蔽的,是Gradle 与 Xcode 的缓存冲突。当你在 Android Studio 里修改了 shared 模块的 Composable 函数签名,比如把@Composable fun UserProfile(name: String)改成@Composable fun UserProfile(name: String, avatarUrl: String?),Android 侧会立刻热重载生效,但 iOS 侧可能依然显示旧版 UI,甚至编译不报错。这是因为 Xcode 缓存了旧的 Framework 头文件,而 Gradle 并没有通知 Xcode 清理。我试过Clean Build Folder,有时有效,有时无效。最终稳定方案是:每次修改 shared 模块的公共 API 后,在终端执行./gradlew :shared:clean :shared:build,然后在 Xcode 中执行Product → Clean Build Folder,最后再Build。这个“双清”流程,成了我们团队每日晨会后的固定动作。它听起来笨拙,但比花半天时间排查“为什么 iOS 上没更新”要高效得多。这里的关键认知是:CMP 的构建不是单向流水线,而是 Gradle 和 Xcode 两个独立构建系统之间的松耦合协同。你必须同时理解两者的缓存机制、产物路径和触发条件,才能让它们步调一致。
3. UI 组件不是“复制粘贴”,而是对平台原生语义的一次深度翻译
很多人看到 CMP 的示例代码,第一反应是:“哇,这不就是 Compose 吗?” 然后兴冲冲地把 Android 上写好的Column { Text("Hello") }直接拷贝到 shared 模块里,运行起来发现 iOS 上字体发虚、点击区域小了一半、滚动列表卡顿。问题在于,Text这个 Composable,在 Android 上背后是TextView,在 iOS 上背后是UILabel,而这两个原生控件的默认行为天差地别。Android 的TextView默认支持文字缩放、自动换行、复杂的文本样式嵌套;iOS 的UILabel默认禁用文字缩放、换行策略更激进、对富文本的支持需要额外桥接。CMP 并没有抹平这些差异,它只是提供了一套统一的 DSL,把你的意图翻译给底层平台。所以,写 CMP UI,本质上是在做一次“平台语义翻译”。举个最典型的例子:状态栏(Status Bar)的控制。在 Android 上,你可以在Activity的onCreate里调用WindowInsetsController来隐藏或改变状态栏颜色;在 iOS 上,你需要在UIViewController的prefersStatusBarHidden和preferredStatusBarStyle里做文章。CMP 的StatusBarAPI(如rememberStatusBarState())在 Android 上工作良好,但在 iOS 上,它根本无法触达UIViewController的生命周期。我们的解决方案是:在 shared 模块中定义一个interface StatusBarManager,里面声明fun hide()和fun setStyle(style: StatusBarStyle);然后在 Android 的MainActivity中实现它,调用原生 API;在 iOS 的ViewController中实现它,同样调用原生 API;最后,通过expect/actual机制,在 shared 模块的 Composable 里注入这个 manager。代码结构如下:
// shared/src/commonMain/kotlin/StatusBarManager.kt expect interface StatusBarManager { fun hide() fun setStyle(style: StatusBarStyle) } // androidApp/src/main/kotlin/AndroidStatusBarManager.kt actual class AndroidStatusBarManager(private val activity: Activity) : StatusBarManager { override fun hide() { activity.window?.let { window -> WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, window.decorView).hide(WindowInsetsCompat.Type.statusBars()) } } override fun setStyle(style: StatusBarStyle) { // 实现 Android 样式设置 } } // iosApp/src/iosMain/kotlin/IosStatusBarManager.kt actual class IosStatusBarManager : StatusBarManager { override fun hide() { // 在 iOS 中,通常通过 UIViewController 的属性控制 // 需要在 ViewController 的 viewDidLoad 中设置 prefersStatusBarHidden = true // 这里只是一个占位,实际逻辑在 ViewController 中 } override fun setStyle(style: StatusBarStyle) { // 实际逻辑在 ViewController 中 } }另一个高频痛点是手势(Gestures)。Android 的Modifier.clickable对应的是View.setOnClickListener,而 iOS 的Modifier.clickable最终映射到UILongPressGestureRecognizer(因为 iOS 没有原生的单击手势识别器,它用长按来模拟)。这导致了一个诡异现象:在 iOS 上,clickable的响应延迟明显高于 Android,用户感觉“点不动”。我们实测发现,iOS 上的clickable默认indication是null,即没有涟漪反馈,这进一步加剧了“无响应”的错觉。解决方案是:在 iOS 的actual实现中,为clickable手势添加一个轻量级的视觉反馈,比如一个 0.1 秒的背景色微变动画,并将requiredUnpressed设为false,降低触发门槛。这需要深入到iosMain的Modifier扩展中去定制,而不是在 commonMain 里写。这再次印证了那句话:CMP 不是让你忘记平台,而是让你更懂平台。你写的每一行 Composable,背后都站着一个需要被精准伺候的原生控件。
4. 状态管理不是“用 ViewModel 就完事”,而是对生命周期鸿沟的一次精密填埋
在 Android 上,ViewModel是 Jetpack 的基石,它与Activity/Fragment的生命周期绑定,自动处理配置变更(如屏幕旋转)时的状态保存。很多开发者想当然地认为,把ViewModel类放进 shared 模块,就能在 iOS 上复用。大错特错。iOS 没有ViewModel这个概念,它的UIViewController生命周期与 Android 完全不同:viewDidLoad对应onCreate,但viewWillAppear和viewDidAppear的触发时机、频率、以及与后台/前台切换的关系,都和 Android 的onResume/onPause不是一回事。更致命的是,Kotlin/Native 的内存模型是 ARC(Automatic Reference Counting),而 Android 的 JVM 是 GC(Garbage Collection)。这意味着,一个在 Android 上由ViewModel持有的CoroutineScope,在 iOS 上如果持有UIViewController的强引用,就会造成循环引用,导致内存泄漏。我们第一个 CMP 页面上线后,iOS 侧的内存占用曲线像心电图一样持续攀升,一周后 App 就会因内存不足被系统杀死。根因就是 shared 模块里的ViewModel试图在 iOS 上管理UIViewController的生命周期。解决这个问题,我们走了三步:第一步,彻底放弃在 shared 模块中使用ViewModel。所有与 UI 状态相关的数据,全部下沉到Repository层,用Flow或StateFlow暴露。Repository是纯数据层,不感知 UI,因此可以安全地放在 commonMain。第二步,在 platform-specific 模块中,各自实现状态容器。Android 侧继续用ViewModel,它负责订阅Repository的Flow,并将最新状态转换为State发送给 Composable;iOS 侧则创建一个ViewControllerState类,它是一个普通的 Kotlin 类,内部持有一个MutableStateFlow,并在ViewController的viewWillAppear中启动订阅,在viewWillDisappear中取消订阅。这个类不继承任何平台基类,因此不会引入生命周期耦合。第三步,也是最关键的一步,是状态同步的时机控制。我们发现,当用户从 iOS 的后台切回前台时,viewWillAppear会立即触发,但此时Repository的数据可能还是旧的(比如网络请求还没回来)。如果ViewControllerState在viewWillAppear里就立刻发送一个“加载中”状态,UI 会闪一下。我们的做法是:在ViewControllerState中引入一个debounce机制,只有当viewWillAppear触发后 100ms 内,数据仍未更新,才发送“加载中”状态;如果数据在这 100ms 内回来了,就直接发送最新数据,UI 丝滑过渡。这个 100ms,是我们经过 20 次 A/B 测试后确定的阈值,它平衡了“避免闪屏”和“及时反馈”两个目标。这说明,跨平台的状态管理,不是技术选型问题,而是产品体验问题。你必须用平台的节奏,去演奏共享的数据乐章。
5. 调试不是“看 Logcat”,而是三台设备上的三套侦探工具在同步破案
调试 CMP 应用,是对我耐心的最大考验。一个 Bug,可能只在 iOS 真机上出现,Android 模拟器上完全正常;另一个 Bug,可能只在 Android 的低端机上卡顿,iOS 上流畅如丝。你不能再依赖单一的 Logcat 或 Xcode Console。你必须同时开着三台设备:一台 Android 模拟器(用于快速验证 Compose DSL)、一台 iOS 模拟器(用于检查布局和基本交互)、一台 iOS 真机(用于抓取真实的性能瓶颈和内存泄漏)。每台设备,都需要不同的侦探工具。在 Android 侧,Logcat是主力,但你要学会过滤。CMP 的日志非常嘈杂,大量是Compose框架自身的Recomposer日志。我们团队约定,所有业务日志必须以[CMP]开头,这样在 Logcat 里搜索[CMP]就能瞬间聚焦。在 iOS 侧,Console.app是 Xcode 的替代品,它能捕获到os_log和print输出,但更重要的是,它能显示Swift和Objective-C的崩溃堆栈。当 CMP 的 Composable 在 iOS 上触发一个EXC_BAD_ACCESS(野指针访问),堆栈里往往混着 Kotlin/Native 的符号和 Swift 的符号,这时Console.app的符号化功能就至关重要。我们曾遇到一个 Bug:iOS 上某个列表项点击后,App 崩溃,堆栈显示kfun:org.jetbrains.compose.runtime#updateState(Lkotlin/Function0;Lkotlin/Function0;)V。这行 Kotlin 符号根本看不出问题在哪。后来我们发现,这是因为在commonMain的StateFlow订阅中,我们错误地在collect的 lambda 里调用了Dispatchers.Main.immediate,而这个 dispatcher 在 iOS 上并不完全等价于主线程。解决方案是,永远只用Dispatchers.Main,并确保所有collect的副作用操作(如更新 UI 状态)都在LaunchedEffect的作用域内完成。第三个侦探工具,是Android Studio 的 Layout Inspector 和 Xcode 的 View Hierarchy Debugger。它们的作用不是看 UI 是否画出来了,而是看“谁在画”。在 Android 上,Layout Inspector 能清晰地展示ComposeNode的树形结构,你可以看到某个TextComposable 的Modifier链是否被意外截断;在 iOS 上,View Hierarchy Debugger 能让你看到UIView的层级,确认 CMP 渲染出来的UIView是否被其他原生视图(比如广告 SDK 插入的BannerView)遮挡或挤压。有一次,我们发现 iOS 上的按钮点击区域变小,Inspector 显示该按钮的UIViewframe 正常,但它的superview(一个UIStackView)的distribution属性被另一个 SDK 错误地改成了.fillEqually,导致所有子视图被强行等分。这个 Bug,只在 iOS 上存在,且只在特定广告 SDK 加载后触发,如果不是用 View Hierarchy Debugger 一层层往上查,根本无从下手。这告诉我们:CMP 的调试,不是寻找一个错误,而是寻找三个平台上的“不一致”。你的目标不是让代码在某一个平台上跑通,而是让三个平台上的行为,在用户可感知的层面,达成一致。
6. 性能不是“看 FPS”,而是对每一帧渲染路径的逐行解剖
很多人认为,只要 Compose UI 写得规范,CMP 的性能就“应该”没问题。这是一个危险的幻觉。Android 和 iOS 的图形渲染管线,从底层就完全不同。Android 主流是 Skia + Vulkan,iOS 是 Metal。CMP 的 Compose Runtime 在两者之上做了一层抽象,但这层抽象是有代价的。我们上线第一个 CMP 页面后,Android 侧的平均 FPS 是 58,iOS 侧只有 42。用户反馈是:“iOS 上滑动列表,感觉有点‘粘’。” 我们用 Android Studio 的 Profiler 抓取了 Android 的帧,发现Draw阶段耗时稳定在 12ms;用 Xcode 的 Instruments 抓取 iOS 的帧,发现Metal阶段耗时高达 20ms,且CPU时间集中在kfun:org.jetbrains.compose.ui.graphics#drawIntoCanvas这个函数上。问题出在Canvas 绘制的粒度。在 Android 上,Compose 的CanvasAPI 调用会被高效地批处理成 Skia 的绘制命令;在 iOS 上,同样的CanvasAPI 调用,会被翻译成一系列细碎的 Metal 命令,每一次drawRect、drawCircle都是一次 GPU 命令提交,开销巨大。我们的优化方案是:在 iOS 侧,尽可能避免在 Composable 中使用Canvas进行自定义绘制,转而用预渲染的ImageBitmap或Painter。例如,一个原本用Canvas绘制的渐变圆角背景,我们改为在iosMain中用CoreGraphics预先绘制一张CGImage,然后封装成Painter,在 Composable 中通过Box(modifier = Modifier.background(painter))使用。这牺牲了一点动态性(比如无法实时响应Color参数变化),但换来的是 15ms 的 Metal 渲染时间下降。另一个重大性能杀手是过度的recomposition(重组)。Compose 的重组是基于状态变化的,但 iOS 的State更新机制比 Android 更敏感。我们发现,当一个StateFlow发送一个新值时,Android 侧可能只重组受影响的Composable子树,而 iOS 侧有时会触发整个@Composable函数的全量重组。根因是StateFlow在 iOS 上的collect实现,其launchIn的CoroutineScope与CompositionLocalProvider的作用域绑定不够紧密。我们的解决方案是:在 iOS 的ViewController中,为每个@Composable函数创建一个独立的CompositionLocalProvider,并确保StateFlow.collect的scope与该CompositionLocalProvider的Composition生命周期严格对齐。这需要手动管理Composition的创建和销毁,代码量增加了,但重组范围被精准控制。最后,关于图片加载。Android 上我们习惯用Coil,它与 Compose 深度集成;iOS 上,Coil的ios实现是基于SDWebImage的封装,但它的内存缓存策略与 Android 不同,且SDWebImage的UIImageView扩展在UIView上的表现不如原生UIImageView。我们最终采用的方案是:在 shared 模块中定义一个ImageLoader接口,Android 侧用Coil实现,iOS 侧用Kingfisher实现(因为它对UIView的支持更原生),并通过expect/actual注入。这让我们能针对每个平台,精细调整图片的解码尺寸、内存缓存大小和磁盘缓存策略。性能优化没有银弹,它是一场对每一帧、每一个像素、每一次内存分配的微观战争。你必须接受一个事实:在 CMP 世界里,“写一次,跑两边”的代码,其性能表现从来就不是对称的。你必须为每个平台,准备一套独立的、经过实测的性能调优手册。