Compose Multiplatform 真的能用吗?踩坑记录
Compose Multiplatform 真的能用吗?踩坑记录
从一次失败的 Demo 开始
去年十月份,手边一个内部工具需要同时支持 Android 和桌面端。团队里就我一个 Android 开发,再招一个 Desktop 的人不现实,Compose Multiplatform 看起来像是救星。JetBrains 官方文档写得漂亮,"100% shared UI code" 的标语挂在首页,GitHub 上的 todoapp 示例跑起来也确实流畅。我花了半天搭环境,把现有 Compose 模块的代码往 commonMain 里一扔,编译报错二十多条。
最离谱的是 rememberSaveable。这个在 Android 里天天用的 API,在 desktop target 上直接 unresolved reference。查了一圈才发现,Compose Multiplatform 1.5.0 的 rememberSaveable 只存在于 androidx.compose.runtime:runtime-saveable 的 Android artifact 里,common 层根本不存在等价物。官方 issue #2525 里有人问过,维护者的回复是 "use expect/actual for now",然后这个 issue 从 2022 年挂到现在,状态还是 open。
这意味着什么?所有依赖状态保存的组件——LazyListState 的滚动位置、TextField 的输入内容、NavHost 的回退栈——在 desktop 上重启进程后全部归零。我试着手写了一个基于 java.util.prefs.Preferences 的 desktop 实现,用 expect/actual 桥接,代码量翻了三倍,而且 Saver 的序列化逻辑在 Android 和 Desktop 两边行为不一致,Bundle 能存的 Parcelable 到了 Preferences 里全得转成 String。这个坑我踩了整整两天,最后把 desktop 端的 "状态恢复" 需求直接砍了,产品说内部工具重启就重启吧。
资源系统的精神分裂
Compose Multiplatform 的资源处理是另一个让我崩溃的点。Android 开发习惯了 R.drawable.xxx,Compose Multiplatform 搞了个 composeResources 目录,理论上 common 层直接 Res.drawable.xxx 就能跨平台。实际呢?
我用的 Kotlin 1.9.20 + Compose Multiplatform 1.5.10。按文档在 commonMain/composeResources/drawable 放了张 PNG,IDE 里 Res 对象死活生成不出来。clean build、invalidate caches、甚至删掉 .gradle 目录全试过了,没用。最后翻到 GitHub issue #3852,发现是资源生成的 Gradle task 和 Kotlin/Native 的编译缓存有 race condition,官方 workaround 是在 build.gradle.kts 里手动加依赖:
kotlin {
sourceSets.commonMain.resources.srcDirs("src/commonMain/composeResources")
// 还得加这个
tasks.withType<<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>> {
dependsOn("generateComposeResClass")
}
}这行 dependsOn 在官方文档里提都没提。更讽刺的是,这个 issue 的评论区里有人贴了同样的 workaround,JetBrains 员工回复 "thanks, we'll look into it",然后三个月没动静。
SVG 支持更是灾难。Android 端 painterResource 直接吃 SVG,desktop 端抛 IllegalArgumentException: Unsupported image format。查源码发现 desktop 的 painterResource 底层用的是 javax.imageio.ImageIO,不支持 SVG。解决方案?自己引入 Apache Batik 写 expect/actual,或者把 SVG 全转成 PNG。我们项目里五十多张 SVG 图标,最后写了个 Gradle task 批量转 PNG,common 层统一用 PNG,文件体积涨了 40%。
字体渲染的 platform delta
跨平台 UI 框架最怕的就是 "看起来一样,实际不一样"。Compose Multiplatform 的字体渲染在 desktop 上给了我当头一棒。
Android 上 Text 组件用 FontWeight.W500 显示 "PingFang SC" 或者 "Roboto",字重变化很明显。同样代码跑在 macOS desktop 上,W400 和 W700 几乎看不出区别,W300 直接糊成一片。我以为是 JetBrains Runtime 的问题,换了 Oracle JDK 17、GraalVM 21,现象一致。
深挖发现是 Skia 在桌面端的字体回退策略和 Android 不同。Compose Multiplatform desktop 用的 Skiko(Skia for Kotlin),字体匹配逻辑不走系统 Font Book,而是 Skia 自己的 SkFontMgr。macOS 上 PingFang SC 的 W500 被映射到了 PingFangSC-Regular,而真正的 PingFangSC-Medium 没被加载。这个问题在 JetBrains 的 YouTrack 上有个 ticket SKIKO-732,状态是 "In Progress",从 2023 年 4 月到现在。
临时解决方案是强制指定 FontFamily 时用绝对路径加载 .ttf 文件,绕过 Skia 的字体解析:
val customFont by lazy {
val fontFile = File("/System/Library/Fonts/PingFang.ttc")
FontFamily(Font(fontFile, weight = FontWeight.W500, style = FontStyle.Normal))
}但这代码在 Windows 上直接爆炸,又是 expect/actual 的命。最后我们 desktop 端的字体规范改成只用 W400 和 W700 两个档,牺牲设计一致性换工程可行性。
输入法的诡异行为
桌面端用户要打字,这是天经地义。但 Compose Multiplatform 的输入法支持让我怀疑 JetBrains 自己有没有拿这个写过生产代码。
macOS 上搜狗输入法输入中文,TextField 的光标位置经常乱跳。具体复现步骤:输入 "ceshi"(测试),按空格上屏,光标应该停在 "测试" 后面,实际停在了 "测" 和 "试" 中间。这个 bug 不是必现,大概 30% 概率,但足够让人抓狂。
我抓了个 InputEvent 的日志,发现 onValueChange 的回调序列在异常情况下是:
// 正常情况
"ceshi" -> "测试" (composition end, 光标 2)
// 异常情况
"ceshi" -> "测" (composition end?) -> "测试" (另一个 onValueChange, 光标 1)看起来是输入法框架把一次完整的 composition 拆成了两次 onValueChange,第二次回调时 TextFieldValue.selection 的计算逻辑有 race。这个问题在 Compose Multiplatform 1.5.10 和 1.6.0-beta01 都能复现,Android 端同样代码完全正常。
Windows 上则是另一个画风。微软拼音输入法在 TextField 里按 Shift 切换中英文,有时候切换了但 VisualTransformation 没刷新,密码框显示的还是圆点而不是明文,或者反过来。这个和 LocalTextInputService 的状态同步有关,我没能定位到根因,最后给所有输入框加了 onFocusChanged 时强制 textInputService?.showSoftwareKeyboard() 的 hack,治标不治本。
导航:从 Jetpack Navigation 到 Voyager 的妥协
Android 项目里 androidx.navigation:navigation-compose 用得很顺手,到了 Compose Multiplatform,这个依赖直接不可用。官方推荐方案是等 JetBrains 出的 org.jetbrains.androidx.navigation:navigation-compose,但这个库 2023 年才发布 2.7.0-alpha01,API 和 AndroidX Navigation 不完全一致,而且 NavType 的自定义序列化在 desktop 上有 bug,传 Parcelable 参数会 crash。
我试过自己桥接,把 Android 的 NavController 用 expect/actual 包一层。但 rememberNavController() 返回的类型在 Android 是 androidx.navigation.NavHostController,在 desktop 不存在这个类,类型系统直接卡死。最后整个团队迁移到了 Voyager,一个第三方导航库。
Voyager 的 API 设计确实为 KMP 考虑了,Screen、Navigator 都是纯 Kotlin 接口,common 层完全可用。但代价是生态割裂——所有 navigation-compose 的现成方案,比如 Hilt Navigation、Deep Link 处理、Nested Navigation 的返回栈管理,全部要重写。我们项目里有个复杂的向导流程,六步表单能前进能回退能跳过,用 Voyager 的 push/pop/replaceAll 重新实现后,状态恢复又成了问题:Voyager 的 ScreenModel 生命周期和 Compose 的 rememberSaveable 不打通,进程死亡后导航栈能恢复(靠 ScreenRegistry 的序列化),但每个 Screen 里的表单数据全丢了。
最后给每个 ScreenModel 手动加了基于 kotlinx.serialization 的持久化层,代码大概长这样:
class FormViewModel : ScreenModel {
private val _state = MutableStateFlow(loadSavedState() ?: FormState())
val state = _state.asStateFlow()
private fun loadSavedState(): FormState? {
val json = Preferences.userRoot().node("app/form").get("state", null)
return json?.let { Json.decodeFromString(it) }
}
override fun onDispose() {
Preferences.userRoot().node("app/form").put("state", Json.encodeToString(_state.value))
}
}onDispose 在进程被系统杀死时不保证调用,所以还得配个定时保存。这套东西 Android 端 SavedStateHandle 几行代码搞定,这里写了两百多行。
性能:desktop 端的 GC 噩梦
Compose Multiplatform desktop 的性能表现,我得分开说。UI 帧率本身没问题,Skia 的渲染效率在线,简单界面能稳 60fps。但内存行为非常诡异。
我们的工具要展示一个实时日志流,每秒几十条新条目,旧的自动淘汰保留最近 1000 条。Android 端用 LazyColumn + rememberLazyListState,同样代码 desktop 端跑半小时,内存涨到 2GB 不回落。JProfiler 一抓,全是 androidx.compose.runtime.SnapshotState 的实例,几十万个。
问题出在 LazyListState 的 item 缓存策略。Android 端 RecyclerView/Compose LazyList 有明确的 view recycling,desktop 端的 Compose 实现似乎对 DisposableEffect 的清理时机更宽松。日志条目每个都有 derivedStateOf 做时间格式化,条目淘汰后 StateObject 没及时释放,Snapshot 系统里的依赖关系越积越多。
尝试的解决方案:把 derivedStateOf 改成普通计算属性,内存涨速减半但不根治;限制 LazyColumn 的 cacheMeasureItemCount,没效果;最后把日志展示改成分页加载,一次只渲染 50 条,手动翻页,产品骂娘但内存稳住了。
另一个性能相关的问题在启动速度。Compose Multiplatform desktop 的 release 包用 compose.desktop.application.buildTypes.release.proguard 做混淆,但 compose.material 里的 icon 资源太多,ProGuard 的 adaptresourcefilecontents 处理不过来,打包阶段要十五分钟。关掉 ProGuard 启动时间从 3 秒变成 8 秒,用户能接受,CI 时间不能接受。最后手动维护了 proguard-rules.pro,把 androidx.compose.material.icons 全 keep,打包时间降到四分钟,但 APK 体积涨了 12MB。
版本对齐的俄罗斯轮盘
Compose Multiplatform 的版本管理是我见过最折磨的 KMP 生态之一。Kotlin、Compose Compiler、AndroidX Compose、JetBrains Compose、Skiko、Kotlin Coroutines,这些版本要互相兼容,错一个就编译失败或者运行时 crash。
2024 年初我尝试升级到 Kotlin 2.0.0,Compose Multiplatform 1.6.0 宣称支持。实际升级后 desktop 端启动即 crash,堆栈指向 org.jetbrains.skiko.SkiaLayer 的 native 初始化。查了半天,发现是 Skiko 0.7.97 和 Kotlin 2.0.0 的 metadata 格式不兼容,但 Gradle 依赖解析没报错,运行时加载 .klib 才炸。JetBrains 后来发了 Skiko 0.7.99 修这个问题,Compose Multiplatform 1.6.1 才带上,中间我回滚了三次。
现在我们的 gradle/libs.versions.toml 里锁死了一组经过血战验证的版本,旁边注释写着 "DO NOT TOUCH UNLESS YOU HAVE A WEEK TO BURN":
kotlin = "1.9.22"
compose = "1.5.11"
compose-plugin = "1.5.11"
agp = "8.2.0"
skiko = "0.7.85" # 手动覆盖,compose 默认的 0.7.90 有字体 bugcompose-plugin 和 compose 版本号一样但 artifact 不同,一个是 Gradle 插件 org.jetbrains.compose,一个是 runtime 库 org.jetbrains.androidx.compose,新手很容易混。更隐蔽的是 org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 这个库,版本要和 compose 严格对齐,1.5.11 的 compose 配 2.7.0 的 lifecycle 会在 iOS target 上编译失败(我们没 iOS 需求,但 common 层编译会扫到)。
那些确实做得好的地方
写了这么多坑,也得公平地说几句。Compose Multiplatform 的 Layout 系统跨平台一致性确实不错,Box、Column、Row、ConstraintLayout 的测量和放置逻辑,Android 和 desktop 行为基本一致,我们没遇到过组件位置偏移的 bug。Modifier 系统也完全共享,自定义 modifier 的代码不用改一行。
动画系统也是亮点。animate*AsState、AnimatedVisibility、Crossfade 在 desktop 上 60fps 稳的,Skia 的硬件加速比 Android 的 HWUI 在某些场景下还流畅。我们有个数据可视化的仪表盘,带十几个同时运行的数值动画,Android 低端机上偶尔掉帧,desktop 上同等复杂度毫无压力。
Gradle 插件的 compose.desktop.application DSL 设计得挺实用,nativeDistributions 配置打包参数,buildTypes.release 控制混淆和签名,比 JavaFX 的 jlink/jpackage 折腾程度低不少。macOS 的 .app bundle、Windows 的 .msi、Linux 的 .deb,都能一键出包,虽然配置细节要调很久,但至少路是通的。
我到底推不推荐
这个问题我分场景答。
如果你团队里有现成的 Android Compose 经验,要快速出个桌面端 MVP,内部工具或者后台系统,Compose Multiplatform 能省时间。但省的时间不是 "写一次跑两处" 的 50%,大概是 30%,而且你得准备好处理 platform-specific 的 bug,心理预期要调整到 "能跑就行,别追求像素级一致"。
如果你要做面向消费者的专业桌面软件,比如设计工具、IDE 插件、音视频编辑器,别用。输入法的坑、字体渲染的 delta、内存管理的粗糙,这些在用户眼里是不可接受的。这种场景 Qt、Electron、或者干脆原生 SwiftUI/WPF 更靠谱。
我个人觉得 Compose Multiplatform 现在的状态像 2019 年的 Flutter desktop——能 demo,能内部用,上生产要赌。JetBrains 的投入力度不小,但 KMP 生态的碎片化不是一家公司短期内能解决的。AndroidX 和 JetBrains Compose 的两条分支会不会合并、什么时候合并,没人知道。我现在的策略是 common 层只放纯 UI 逻辑,所有 platform interaction(文件系统、网络、数据库、系统通知)全用 expect/actual 隔离,这样哪天要换框架,损失可控。
那个内部工具项目最后上线了,Android + macOS/Windows desktop 双端。维护成本比预期高 40%,主要是 desktop 端的 bug 修复和版本升级。团队里后来招了个有 Swing 经验的老哥,他看我们的 Compose desktop 代码直摇头,说 "你们这不如直接写 JavaFX"。我没法反驳。