Compose for iOS 的进展,真的能一套代码双端吗

Compose for iOS 的进展,真的能一套代码双端吗

Compose for iOS 的进展,真的能一套代码双端吗


「Compose for iOS 的进展,真的能一套代码双端吗」


Google I/O 2024 上那条"Compose for iOS 进入 Alpha"的官宣,让我手里的咖啡凉了一半。不是激动,是那种"又来了"的疲惫。KMM(Kotlin Multiplatform Mobile)喊了这么多年跨端共享,Jetpack Compose 从 Android 独占到 Desktop、Web 一路扩张,现在终于把触角伸进了苹果的后花园。但问题是,Google 自己真的相信这套叙事吗?还是说这只是一场不得不跟的军备竞赛?


我先把结论撂这儿:以 Compose for iOS 目前 Alpha 的状态,谈"一套代码双端"纯属营销话术。真敢拿它上生产环境的团队,要么技术栈被 Kotlin 绑架得死死的,要么对"跨平台"三个字有某种宗教般的执念。下面慢慢掰扯。


Alpha 版本的真相:能跑 demo,不能碰业务


Compose for iOS 的 Alpha 版本目前托管在 JetBrains 的 GitHub 仓库里,版本号 1.6.10 附近开始有了相对完整的文档。我花了一个周末把官方的那个"Hello World"级示例跑了一遍,又试着把一个内部的小工具模块——大概三千行 Kotlin,主要是列表展示和网络请求——往上迁移。结果相当打击人。


最基础的 Text 组件在 iOS 模拟器上渲染,中文字体回退到了系统默认的 PingFang SC,但字重(font weight)映射是错的。Compose 的 FontWeight.Bold 对应过去,视觉上和 FontWeight.SemiBold 几乎没区别。这不是什么审美挑剔,是我们设计规范里明确区分了标题和副标题的字重层级。我翻了源码,发现问题出在 Skiko(Compose 的底层图形库)对 Core Text 的封装上,某个 weight 映射表直接用了线性插值,而苹果的字重设计是非线性的。这个 issue 在 JetBrains 的 YouTrack 上挂了至少三个月,状态还是 "Open"。


更麻烦的是触摸事件。Compose 的 Modifier.clickable 在 iOS 上偶尔会出现"点下去没反应,抬起来才触发"的延迟。我起初以为是 16ms 的帧同步问题,后来抓了下 Instruments 的 Time Profiler,发现是 Skiko 的 iOS 后端在处理 UITouch 的 touchesBegantouchesEnded 时,往自己的事件队列里塞了一个不必要的防抖延迟。Android 上没这个问题,因为 Android 的 MotionEvent 直接进 Choreographer 的帧回调。这种平台差异不会出现在任何文档里,你只能一行行跟到 C++ 层才能发现。Alpha 版本的意思,翻译过来就是"我们知道有坑,但还没排上优先级"。


"共享 UI" 是个伪命题,KMM 的历史已经证明过了


JetBrains 推动 KMM 这些年,真正在生产环境跑起来的项目,共享的大多是 ViewModel 层以下的逻辑——网络、数据库、业务规则。UI 层?几乎没人敢碰。Netflix 那个被反复引用的 KMM 案例,共享的是日志系统和部分推荐算法,界面还是原生 Android/iOS 分开写。Square 早年更激进,尝试过用 Kotlin/Native 写共享 UI,后来默默回退,Cash App 现在的架构是共享网络层,UI 各端自治。


这个规律不是偶然的。UI 层对平台特性的敏感度远高于业务逻辑。iOS 的导航是 UINavigationController 的栈管理,Android 是 Jetpack Navigation 的深层链接和返回栈,两者的生命周期模型完全不同。Compose 的 NavHost 在 Android 上能玩出花来,到了 iOS 上怎么映射?目前的做法是套一个自己的导航状态机,然后两边各自桥接。这意味着你写的不是"一套代码",而是"一套抽象 + 两套适配层",复杂度没减,耦合还增加了。


我见过最诚实的 KMM 实践来自 Shopify,他们 2023 年的技术博客里坦承:共享 UI 的尝试在内部两个实验项目里都以失败告终,最终方案是共享 ViewModel 以下的 70% 代码,UI 保持原生。这 70% 的共享率已经能带来显著收益了,非要追求 100% 的"一套代码双端",往往是技术管理者的 KPI 冲动,不是工程上的理性选择。


Compose for iOS 想打破这个规律,但它的架构决定了它绕不开同样的问题。Compose Runtime 是平台无关的,可一旦落到具体组件——LazyColumn 的滚动惯性、TextField 的输入法交互、ModalBottomSheet 的手势冲突——每一个都是平台深度绑定的。Google 在 Android 上花了五年才把 Compose Material 打磨到勉强可用的程度,现在要在 iOS 上复刻一套,资源投入根本不在一个量级。


Skiko 的性能债务:GPU 线程模型的心病


Compose 跨平台的底层统一依赖 Skiko,也就是 Skia 的 Kotlin 绑定。Skia 本身是 Chrome 和 Flutter 的图形引擎,性能经过验证,但 Skiko 的 iOS 后端有个架构层面的麻烦:它默认跑在后台线程渲染,然后往主线程提交帧,这和 iOS 的 Core Animation 的线程模型有冲突。


具体表现是,当你在一个 Compose 页面里嵌套一个原生的 UIViewController——比如必须用的相机预览或者地图——纹理同步会出现撕裂。我测试的场景很简单:Compose 界面底部有个半透明的底部栏,上面叠着一个原生的 AVCaptureVideoPreviewLayer。滑动页面时,底部栏的圆角和原生视频的交界处会有随机闪烁,大概每 60 帧出现 1-2 次。抓了下 GPU Frame Capture,发现是 Skiko 的纹理提交和 Core Animation 的 presentationLayer 时序没对齐,偶尔会出现一帧的延迟差。


Flutter 早期也有类似问题,但他们的解决方式是重写整个渲染管线,搞出了 Impeller。Compose for iOS 没有这种级别的资源投入,短期内只能打补丁。JetBrains 的工程师在 Slack 频道里提到过考虑迁移到 Metal 的直接渲染,但那是"长期规划",翻译过来就是"还没开始写代码"。


性能上的另一重负担是二进制体积。一个空的 Compose for iOS 应用,Release 包大概 8MB 起步,其中 Skia 的静态链接占了大头。作为对比,纯原生的 SwiftUI 空项目不到 1MB,Flutter 的空项目大概 4MB。对于超大型 App 这个差距可以忽略,但对于中小团队,特别是做工具类、社交类 App 的,8MB 的基线意味着你在 App Store 的排名算法里先输一截——下载转化率对包大小极其敏感,这是被无数 A/B 测试验证过的。


SwiftUI 已经活了五年,窗口期早就过了


Compose for iOS 最尴尬的处境是时间。SwiftUI 2019 年发布,到 2024 年已经经历了五个大版本迭代。iOS 15 上的 SwiftUI 还像个玩具,iOS 17 上已经能覆盖绝大多数业务场景了。苹果自家的应用——Wallet、Health、甚至部分 Settings 页面——都在逐步迁移到 SwiftUI,这意味着框架的优先级和内部测试强度是第三方框架无法比拟的。


更关键的是,SwiftUI 和 Swift 语言的演进是绑定的。@Observable 宏在 Swift 5.9 里的引入,直接解决了早期 ObservableObject 的性能陷阱;@Entry 和新的环境值传递机制,让依赖注入变得干净得多。这些不是语法糖,是架构层面的基础设施。Compose 的 Stateremember 机制在 Android 上很成熟,但到了 iOS 上,它和 Swift 的引用语义、值类型、以及 ARC 的内存模型有天然的摩擦。Kotlin/Native 的内存管理器(MM)虽然一直在改进,但和 Swift 的互调用层(interop)里,循环引用和过早释放的 bug 仍然常见。


我看过一个 JetBrains 工程师在 KotlinConf 上的演讲,演示 Compose for iOS 和 SwiftUI 的混编。现场 demo 的效果是:Kotlin 写的 Compose 页面可以嵌入到 SwiftUI 的 NavigationStack 里,数据通过一个 @StateObject 桥接。看起来很美好,直到你仔细看代码——桥接层需要手动处理 KotlinBase 的引用计数,任何 remember 里的对象如果要传给 Swift 侧,必须包一层 ObservableObject 的适配器。这不是"无缝集成",这是"能跑就行"的胶水代码,维护成本极高。


对于已经在用 SwiftUI 的团队,Compose for iOS 的吸引力几乎为零。对于还没上 SwiftUI、还在用 UIKit 的老项目,迁移成本是同样的痛苦,为什么要选一个没有生态保障的第三方方案?这是 Compose for iOS 面临的最根本的市场困境。


Google 的动机:防御性布局,不是进攻


一个值得追问的问题是:Google 为什么要推 Compose for iOS?JetBrains 作为 Kotlin 的娘家,有语言生态扩张的动机,这很好理解。但 Google 自己的核心利益在 Android,iOS 上的 Compose 并不能直接带来 Play Store 或广告收入。


我的判断是,这是防御性布局。Flutter 的跨端叙事虽然在国内被诟病,但在全球范围内确实抢走了不少原本属于原生 Android 的新项目。Google 内部对 Flutter 和 Compose 的关系一直暧昧——Flutter 团队属于另一个汇报线,Compose 是 Android 团队的亲儿子。推出 Compose for iOS,某种程度上是为了给"Kotlin 跨端"这个故事补全最后一块拼图,防止开发者因为"想跨 iOS"而被 Flutter 或 React Native 拉走。


但这个动机本身就暴露了问题:如果 Compose for iOS 真的战略优先级很高,它不会只是一个 JetBrains 主导的、Google 站台的项目。对比一下 Flutter 的投入——专门的 IDE 插件、独立的渲染引擎、完整的 DevTools 生态——Compose for iOS 得到的资源只是零头。Google I/O 上的官宣更像是一种"我们也支持"的姿态,而不是"我们要大干一场"的承诺。


这种半心半意在技术决策上有直接体现。Compose 的 Material 3 组件库在 Android 上更新很勤快,但 iOS 适配版本(Material3 for Compose Multiplatform)的进度明显滞后。我对比过两个平台的 TopAppBar:Android 版本已经支持折叠动画和 content padding 的细粒度控制,iOS 版本还停留在基础的高度适配,滚动行为是直接 disable 的。官方文档的措辞很微妙——"iOS 上的行为可能与 Android 略有不同"——翻译过来就是"还没做"。


"一套代码"的代价:你共享的是什么,失去的是什么


跨平台框架的营销话术里,"一套代码"是最具蛊惑性的。但稍微拆解一下,共享的粒度可以有完全不同的含义。


最粗粒度的共享是"同一套设计语言",比如 Flutter 的 Material/Cupertino 双主题。但这不是真正的代码共享,只是视觉风格的模仿,底层实现还是两套。


中等粒度是"共享业务逻辑",KMM 的主流用法。网络层用 Ktor,数据库用 SQLDelight,序列化用 kotlinx.serialization,这些确实能跨平台。但 UI 层各写各的,所谓的"一套代码"只覆盖了 50%-70% 的代码量。


最细粒度才是"共享 UI 实现",Compose for iOS 和 Flutter 试图做到的。但这要求框架层抹平所有平台差异,而平台差异恰恰是最难抽象的部分——不是不能做,是做了之后要么丧失平台原生体验,要么抽象层复杂到难以维护。


Flutter 的选择是牺牲原生体验,统一自绘。它的 Cupertino 组件库被 iOS 开发者吐槽"像但不是"很多年了,滚动曲线的手感、键盘弹起的动画、状态栏的适配,细节上一堆毛病。但 Flutter 至少做到了真正的统一渲染,一致性有保障。


Compose for iOS 走了一条更危险的路:它试图在保持 Android 原生体验的同时,去适配 iOS。结果是两头不讨好。Android 侧它确实比 Flutter 更"原生",因为最终走的还是 Android 自己的渲染管线;但 iOS 侧它既不如 SwiftUI 原生,又不如 Flutter 统一,卡在中间最难受的位置。


我具体说一个场景:输入法。Android 的 WindowInsets 和 iOS 的 keyboardNotification 是完全不同的机制。Compose 在 Android 上有 imePadding() 这种方便的 Modifier,到了 iOS 上,同样的代码行为是未定义的——文档里说"部分支持",实际测试下来,键盘弹起时页面被推上去的高度是错的,多出的空隙刚好是安全区(safe area)的高度。这意味着你要写平台特定的代码来处理键盘,而这段代码在 Android 上是多余的。所谓的"共享 UI",最后变成了一堆 #ifdef 式的条件编译,只不过套上了 Kotlin 的 expect/actual 语法糖。


团队决策的考量:不是技术选型,是组织选型


聊到这里,可能有人会问:那什么情况下 Compose for iOS 是合理的选择?


我能想到的场景非常狭窄。第一种是团队已经重度投入 KMM,共享了大量业务逻辑,且人员结构以 Kotlin 开发者为主,几乎没有 iOS 原生开发能力。这种情况下,用 Compose for iOS 写界面是一种"被迫的低成本方案",虽然体验打折,但比招 Swift 开发者或外包更现实。不过这种团队结构本身就有问题——做 iOS 应用却没有 iOS 开发能力,产品决策上是否成立要打个大问号。


第二种是内部工具或 B 端应用,对平台原生体验不敏感,快速迭代优先。比如一个给运营人员用的数据后台,同时需要 Android 和 iOS 版本,用户量小、功能简单。这种场景下,Compose for iOS 确实能省人力。但同样的场景,Flutter 或 React Native 的成熟度更高,生态更丰富,为什么要选一个 Alpha 版本?


除此之外,我想不出第三个理由。对于正经的 C 端应用,特别是依赖平台特性的——推送、支付、相机、地图、健康数据——Compose for iOS 的桥接成本会迅速吞噬掉"共享代码"带来的收益。而且随着 iOS 版本迭代,这种桥接是持续的技术债务。苹果每年 WWDC 都会引入新的系统能力,SwiftUI 第一时间跟进,Flutter 通常滞后 3-6 个月,Compose for iOS?目前连基础组件都没补齐,新特性的支持根本不在讨论范围内。


还有一个容易被忽视的点是调试体验。Compose 在 Android 上有 Layout Inspector、Animation Preview、Compose Compiler 的详细报错信息,这些工具链是五年积累的结果。在 iOS 上,你现在能用的基本只有 Xcode 的常规调试,Compose 特有的工具一个都没有。一个 Recomposition 循环过多的性能问题,在 Android 上可以用 Layout Inspector 直接看到重组次数,在 iOS 上只能靠打 log 猜。这种开发效率的落差,在大型项目里会被放大到不可接受。


对比 Flutter:不是谁更好,是路径依赖


很多人会把 Compose for iOS 和 Flutter 拿来比较,问"哪个跨平台方案更值得投"。我觉得这种比较本身就有误导性,因为两者的前提条件完全不同。


Flutter 是一个从零开始的完整框架,有自己的渲染引擎、自己的工具链、自己的生态。它的代价是重,基线体积大、启动慢、和平台原生代码的互操作麻烦。但它的收益也是真的:一致性有保障,Google 的投入是战略级的,社区活跃度和第三方库数量远超 Compose Multiplatform。


Compose for iOS 是依附于 Kotlin 生态的延伸,它的优势在于和现有 Kotlin 代码的无缝衔接——如果你已经在用 KMM 共享了网络层和数据层,UI 层继续用 Compose 确实比引入 Dart 更自然。但这种优势只在特定的技术栈前提下成立,不是一个普遍适用的选择。


我个人不太认同那种"Flutter 已经死了,Compose Multiplatform 是未来"的论调。这种说法在 Kotlin 社区里偶尔能听到,更多是语言忠诚度的宣泄,不是技术判断。Flutter 的 2024 年依然很活跃,Impeller 的推进、Wasm 支持的落地、以及 Fuchsia 之外的持续投入,都说明 Google 没有放弃它。Compose 和 Flutter 在 Google 内部是竞争关系,不是替代关系,这个格局短期内不会变。


一个具体的反例:为什么我不看好近两年的落地


去年有个机会,一个创业团队的朋友咨询我技术选型。他们做社交产品,两端的体验都很重要,团队 6 个人,4 个 Android 背景,2 个能写点 iOS。他们倾向于 KMM + Compose for iOS,理由是"一套代码省人力"。


我当时的建议是:如果一定要跨平台,用 Flutter;如果能接受分开写,Android 用 Compose,iOS 用 SwiftUI。他们最后选了 Flutter,半年后告诉我,虽然 Dart 的学习曲线有点陡,但至少没有遇到"这个组件在 iOS 上行为不对"的底层问题,社区里的解决方案也足够多。


这个案例如果换成 Compose for iOS,我几乎能预见到结局:前两个月进展顺利,共享的 Kotlin 业务逻辑跑得很顺;第三个月开始碰 UI 细节,字体、动画、手势逐个爆雷;第四个月发现某个第三方 SDK 的 iOS 版本没有 Kotlin 绑定,要自己写桥接;第五个月评估回退成本,发现已经陷得太深。这不是臆测,是 KMM 早期采用者的典型路径,Compose for iOS 只是把 UI 层的坑也加进来了。


JetBrains 的官方文档里有个"Known Issues"页面,列了二十多项限制,从内存泄漏到性能退化到平台特定行为。Alpha 版本有已知问题很正常,但问题的性质很重要——是"这个 API 还没实现",还是"这个设计在 iOS 上根本行不通"。不幸的是,Compose for iOS 的已知问题里,后者占比不低。


那么,什么时候可能成真?


我不是说 Compose for iOS 永远没机会。技术演进的速度很难预测,也许两年后 JetBrains 解决了 Skiko 的线程模型,Google 加大了投入,社区涌现了大量成熟的桥接库,那时候的生产力等式会完全不同。


但"两年后"和"现在"是两个概念。以目前的 Alpha 状态,任何声称"我们已经用 Compose for iOS 上线了双端应用"的案例,要么是超级简单的 demo 级应用,要么在用户体验上做了大量妥协。我不否认有团队能做到,但那是特定条件下的特例,不是可复制的最佳实践。


更现实的演进路径可能是:Compose for iOS 长期停留在"能跑,但不推荐生产"的状态,成为 Kotlin 生态的一个补充选项,而不是主流方案。它的真正价值在于完善 Kotlin Multiplatform 的故事完整性——"我们也能跨 iOS UI",至于好不好用,是另一回事。这种定位有点像 Kotlin/Native 本身:存在了很多年,能编译到 iOS,但真正大规模替代 Swift/Objective-C 的场景几乎没有。


最后


回到标题的问题:Compose for iOS 真的能一套代码双端吗?


技术上,能跑通 demo,能写简单界面,能共享一部分代码。但"一套代码"的完整叙事,在当前版本下是营销语言,不是工程现实。平台差异太深,框架成熟度太低,生态太薄,投入方的优先级太模糊。


如果你现在要做技术决策,我的建议很直接:除非你的团队已经被 Kotlin 深度绑定、且能接受显著的体验折损,否则不要选 Compose for iOS。Flutter 更成熟,原生双端更可靠,SwiftUI 对于纯 iOS 团队是显而易见的更好选择。跨平台的诱惑很大,但"写一次跑到处"的黄金承诺,历史上没有一次真正兑现过,Compose for iOS 也不会是例外。


当然,如果你就是喜欢 Kotlin,喜欢 Compose 的声明式 API,愿意当 early adopter 去填坑,那没问题,技术选型本来就有个人偏好成分。只是别骗自己这是"为了省人力",大概率你会发现,省下来的代码量,又在调试和适配里加倍还回去了。


JetBrains 的路线图里,Compose for iOS 的 Beta 计划在今年晚些时候。到时候我会再测一遍,希望被打脸——被打脸意味着生态在进步,对所有人都是好事。但以目前的节奏,我持怀疑态度。

Compose 的副作用 API:LaunchedEffect、DisposableEffect、SideEffect 到底什么时候用 2026-06-18

评论区