Compose Multiplatform 真的能用吗?踩坑记录
Compose Multiplatform 真的能用吗?踩坑记录
被官方 Demo 骗进来的
去年十月份,我在一个 side project 里想同时搞 Android 和 Desktop 两个客户端。当时 JetBrains 刚在 KotlinConf 上吹完 Compose Multiplatform 1.5.10,说什么"Write once, run everywhere",宣传片里那个 Weather App 丝滑得跟原生似的。我心说这不就是我要的东西吗,KMP 搞业务逻辑,Compose 搞 UI,一套代码全平台,美滋滋。
结果这一脚踩进去,前前后后折腾了四个月,到现在 Desktop 端还半死不活地挂在 GitHub 上。这篇文章不是那种"五分钟上手"的教程,就是纯踩坑记录,给后面想试的人提个醒。
版本号地狱:1.5.10 到 1.6.0 的 breaking change
刚开始用的是 1.5.10,跟着官方 Getting Started 配完 Gradle,hello world 确实跑起来了。但当我试图把之前 Android 项目里写好的一个自定义 LazyColumn 迁移过来时,直接编译报错:
e: java.lang.IllegalStateException: CompositionLocal LocalOverscrollConfiguration not present翻了半天 issue tracker,发现这是 1.5.x 在 Desktop 上的已知问题,有人建议用 CompositionLocalProvider 手动提供一个假的 OverscrollConfiguration。我照做了,代码长成这样:
CompositionLocalProvider(
LocalOverscrollConfiguration provides null
) {
LazyColumn { ... }
}这玩意儿在 Android 上没问题,Desktop 上能编译了,但滚动条的行为变得特别诡异——鼠标滚轮滚动时,惯性效果完全不对,跟抹了胶水似的。更坑的是,1.6.0-beta01 突然把这个 API 改了,LocalOverscrollConfiguration 整个被移除,换成了 LocalOverscrollFactory,我又得把那一坨临时方案删掉重写。
这种 breaking change 在 Compose Multiplatform 里太常见了。Android 上的 Jetpack Compose 有 Google 压着,API 稳定性相对好一些,但 Multiplatform 分支感觉像是 JetBrains 的实验田,每个 minor 版本都能给你惊喜。我现在项目里锁死在 1.6.1,打死不敢升级了,虽然明知有几个 bug 在 1.6.2 里修了,但谁知道会引入什么新坑。
Desktop 端的渲染:Skiko 的锅还是我的锅?
Compose Desktop 底层用的是 Skiko(Skia for Kotlin),理论上渲染性能应该不差。但我实际测下来,同样一个带 50 张网络图片的列表页面,Android 真机上 60fps 稳如老狗,Desktop(M1 MacBook Pro)居然会掉帧到 40fps 左右。
用 Android Studio 的 Profiler 没法直接看 Desktop,我搞了个土办法,在 LaunchedEffect 里每帧打时间戳:
val frameClock = remember { mutableListOf<Long>() }
LaunchedEffect(Unit) {
while (true) {
withFrameNanos { nanos ->
frameClock.add(nanos)
if (frameClock.size > 120) {
val fps = 1e9 / ((frameClock.last() - frameClock.first()) / 120.0)
println("FPS: $fps")
frameClock.clear()
}
}
}
}测出来发现,瓶颈不在布局,在图片解码。Android 有 Glide、Coil 这些成熟的图片库,硬件加速解码走得好好的。Desktop 上 Compose 自带的 AsyncImage 用的是 Skia 的解码,大图进来直接主线程阻塞。我试过用 Ktor 自己下载再手动切线程解码成 Bitmap,但 Bitmap 在 Compose Desktop 里的类型和 Android 完全不兼容——Android 是 android.graphics.Bitmap,Desktop 是 org.jetbrains.skia.Bitmap,虽然都叫 Bitmap,但 API 差得十万八千里。
最后我的 workaround 是在 Desktop 端引入 Java 的 BufferedImage 做中转,先 Ktor 下载 ByteArray,再 ImageIO.read() 转成 BufferedImage,再手动拷像素数据到 org.jetbrains.skia.Bitmap。这串代码写出来我自己都想笑,说好的"共享 UI 代码"呢?图片加载这一坨完全是平台相关的,而且性能还烂。
Coil 后来出了 3.0.0-alpha 支持 Multiplatform,我试了一下,Desktop 端确实能跑了,但内存占用直接飙到 800MB+,同样的页面 Android 端才 200MB 出头。看了下源码,Coil 在 Desktop 上用的是 skia 的 Image.makeFromEncoded,没有图片尺寸限制,也没有复用池,大图全量解码进内存。这根本没法用在生产环境。
输入法的幽灵:中文输入在 macOS 上的灾难
这个项目有搜索功能,需要输入中文。Android 上没问题,Desktop 上在 macOS 测试时,中文输入法的行为让我怀疑人生。
具体现象是:用搜狗输入法或者系统拼音,输入过程中,Compose 的 TextField 会把拼音字母也 commit 进文本值。比如我想打"测试",输入拼音的过程中,TextField 的 onValueChange 回调会收到 "ceshi测试",前面的 "ceshi" 是拼音字母,后面"测试"是最终汉字,整个混在一起。
我一开始以为是 Compose 的 bug,去 YouTrack 搜,发现 issue CMP-1234(具体编号我忘了,类似这个)早就有人报了,状态是 Open,两年没修。临时方案是在 TextField 外面包一层,监听底层按键事件,判断输入法是否处于 composing 状态,如果是就拦截 onValueChange。
但 Compose Desktop 的按键事件又和 Swing 那套搅在一起,KeyEvent 的类型是 java.awt.event.KeyEvent,不是 Compose 自己的 androidx.compose.ui.input.key.KeyEvent(虽然包名一样,但实现完全不同)。我得这样写:
val textFieldValue = remember { mutableStateOf(TextFieldValue()) }
BasicTextField(
value = textFieldValue.value,
onValueChange = { newValue ->
// 恶心人的判断:如果输入法正在 composing,忽略这次回调
if (!isComposing) {
textFieldValue.value = newValue
}
},
modifier = Modifier.onPreviewKeyEvent { event ->
// event 是 java.awt.event.KeyEvent
isComposing = event.isComposing // 这方法还不是公开的,反射拿的
false
}
)isComposing 在 java.awt.event.InputMethodEvent 里有,但 Compose 的 onPreviewKeyEvent 给的是 KeyEvent,不是 InputMethodEvent。最后我是通过 AWT 的全局事件监听器绕过去的,整个实现脏得一批,而且和 Compose 的声明式 UI 理念完全背道而驰。
Windows 上据说没这个问题,Linux 没测。但 macOS 用户占比不低吧?这种基础功能两年不修,真的说不过去。
导航:官方没有,社区方案各玩各的
Android 上的 Navigation Compose 是标配了,深链接、返回栈管理、参数传递,一套挺成熟的。到了 Multiplatform,官方直接没有。JetBrains 的推荐是"你自己抽象一个接口,各平台分别实现"。
我试了社区里最火的两个方案:PreCompose 和 Voyager。
PreCompose 的作者很活跃,API 设计得尽量贴近 Navigation Compose,但 1.5.x 到 1.6.x 的迁移过程中,它的版本兼容性出了问题。我配完依赖编译不过,去翻 release note,发现它某个版本开始要求 Compose 的特定版本,但文档没写清楚。最后是通过看源码里的 gradle/libs.versions.toml 才猜出来该用哪个组合。
Voyager 的 API 风格我不太喜欢,它用 Screen 接口那一套,每个页面是一个 class,带参数传递时得手动序列化。但它在 Desktop 上的内存泄漏问题让我直接弃坑——反复打开关闭页面后,通过 VisualVM 看堆内存,ScreenModel 的实例没被释放,疑似和它的依赖注入容器 Koin 集成有关。我没深挖,因为那时候已经被 Compose Multiplatform 的各种问题搞烦了,不想再给第三方库当 debugger。
最后我的方案是土法:自己写了一个 expect/actual 的 Navigator 接口,Android 端实际实现用 Navigation Compose 包装一下,Desktop 端用最简单的 mutableStateOf<Class<*>> 做页面切换,参数通过全局的 rememberSaveable 模拟。这代码写出来我都不好意思给人看,但它至少不 leak,也不会因为版本升级突然挂掉。
资源系统:R.java 的乡愁
Android 开发者习惯了 R.drawable.xxx、R.string.xxx,编译时检查,自动补全爽歪歪。Compose Multiplatform 搞了一套新的资源系统,1.6.0 之前是 compose.components.resources,之后改成了 org.jetbrains.compose.resources,API 全变。
我项目里图标比较多,大概 80 个 vector drawable。Android 上直接 painterResource(R.drawable.ic_xxx),Desktop 上同样的代码,资源路径解析失败,打出来是 null。debug 发现 painterResource 在 Desktop 上用的是类加载器找资源,路径规则是 drawable/ic_xxx.xml,但我的资源文件实际编译后路径带了包名前缀,而且大小写处理也有坑——文件名 ic_xxx.xml,代码里写 ic_Xxx 在某些平台能过,某些平台过不了,完全没有编译期检查。
最后我是写了个代码生成器,在 Gradle build 时扫描 commonMain/resources,生成一个 Res object,把路径硬编码成字符串常量。这相当于自己造了一个残废版 R.java,但至少能提前发现拼写错误。
字体资源更坑。我用的一个自定义字体,.ttf 文件放 commonMain/resources/font/ 下,Android 端 Font(R.font.xxx) 没问题,Desktop 端 Font(resource = "font/xxx.ttf") 加载后,中文字体 fallback 到系统默认,完全没生效。查了半天,发现 Desktop 端的 Font 构造器对 .ttf 的支持有 bug,换 .otf 就好了。这谁他妈能想到?issue tracker 里有人报,jetBrains 回复说"已知问题,请用 workaround"。
平台差异:那些文档里不会告诉你的
官方文档喜欢说"共享你的 UI 代码",但真写起来,到处都是 expect/actual 的缝。
比如状态栏颜色。Android 上 WindowInsets 那一套,Compose 支持得不错。Desktop 上?窗口标题栏是 Swing 的 JFrame 管,Compose 根本碰不到。我想做沉浸式状态栏,Windows 上得调 JNI 用 DWM API,macOS 上得更改 NSWindow 的 titlebarAppearsTransparent,这些和 Compose 完全无关,你得分别写 native 代码。
再比如返回手势。Android 有 predictive back gesture,Compose 的 BackHandler 能处理。Desktop 上用户按 Escape 或者 Alt+Left,这算是"返回"吗?我的设计是 Escape 关闭弹窗,Alt+Left 才是页面返回,但 Compose 的 KeyEvent 在 Desktop 上 key 和 awtKeyCode 两个字段容易搞混,我一开始判断 key == Key.Escape 在部分键盘布局上失效,后来改成判断 awtKeyCode == KeyEvent.VK_ESCAPE 才稳定。
还有更离谱的:鼠标右键菜单。Desktop 上 Modifier.contextMenu 是实验性 API,1.6.1 里还是 @OptIn(ExperimentalComposeUiApi::class),而且自定义菜单项的 API 和 Android 的 PopupMenu 完全不同。我做了个文本编辑功能,想加"复制/粘贴",Android 上长按自动出,Desktop 上得自己监听 MouseEvent.isPopupTrigger(这玩意在 Windows 和 macOS 上触发时机还不一样),然后手动算坐标弹 DropdownMenu。
这些细节堆起来,"共享 UI 代码"的比例远没有宣传的那么美好。我粗略统计过,我这个项目的 UI 代码大概 60% 是 commonMain 里的共享代码,剩下 40% 是 androidMain 和 desktopMain 里的平台特化,再加上各种 expect/actual 的胶水层。如果当初直接 Android 用 Jetpack Compose、Desktop 用 Compose Desktop 分开写,可能总工作量差不多,还不用被 Multiplatform 的版本兼容性折磨。
构建速度:Gradle 的复仇
KMP 的 Gradle 构建本来就慢,Compose Multiplatform 再插一脚,更慢。我项目不大,大概 2 万行 Kotlin 代码,clean build 要 3 分半。Android 单平台项目同样规模,clean build 大概 50 秒。
最恶心的是 IDE 同步。Android Studio 对 KMP 的支持一直磕磕绊绊,Compose Multiplatform 的 Gradle plugin 还会在某些版本里和 AGP 冲突。我遇到过 compose.desktop.application 这个 plugin 和 com.android.application 同时应用时,Gradle sync 报 ClassNotFoundException: org.jetbrains.compose.ComposePlugin,原因是某个 transitive dependency 版本被 AGP 的 dependency resolution 给覆盖了。解决办法是在 buildscript 里强制锁版本,但锁完又和 Kotlin Gradle Plugin 的版本对不上,得一个个试组合。
现在我的 gradle.properties 里塞满了各种 workaround:
kotlin.mpp.enableCInteropCommonization=true
kotlin.mpp.androidGradlePluginCompatibility.nowarn=true
org.jetbrains.compose.experimental.jscanvas.enabled=false
compose.kotlin.compiler.plugin.version=1.5.8最后这个 compose.kotlin.compiler.plugin.version 是某个版本里必须手动指定的,因为 plugin 默认带的 compiler plugin 版本和项目里 Kotlin 版本不匹配。这种配置我花了两个晚上才试出来,官方文档提都没提,是在一个俄罗斯开发者写的俄语博客(Google 翻译看的)里找到的。
我还坚持用的理由
写到这里好像全是骂,但说实话我还没完全放弃。有几个场景 Compose Multiplatform 确实省了事:
一个是主题系统。我定义了一套 ColorScheme、Typography、Shapes,三端(我还试了 Web,但 Web 端坑更多,另说)共用,换品牌色改一个地方就行。Android 上的 Material3 适配和 Desktop 上的窗口边框颜色,底层实现不同,但上层 API 统一了,这部分代码如果分开写确实会重复。
另一个是动画。AnimatedVisibility、animateContentSize 这些 API,在 Desktop 上行为基本一致,我做了个展开收起的卡片组件,代码完全没改就跑了。这比当年用 Swing 或者 JavaFX 写动画,体验好太多。
还有预览功能。@Preview 在 Desktop 上能直接跑,不用开模拟器,调试单个组件时反馈很快。虽然有时候预览里的主题和实际运行不一致(预览用的 PreviewAnnotationInDesktop 有自己的默认配置),但比 Android Studio 的 Compose Preview 启动速度快。
现在的真实状态
我的项目目前是这样:Android 端已经上线内部测试,体验还行,毕竟底层还是 Android 原生那套。Desktop 端能跑,但有几个已知 bug 没修——macOS 中文输入法的 workaround 导致光标位置偶尔跳,大图列表滚动掉帧,右键菜单在 4K 显示器上缩放比例不对。这些问题要么等 JetBrains 修(不知道哪年),要么我自己写 native 代码(不想写)。
如果让我给现在的 Compose Multiplatform 打分,纯技术角度大概 6/10,能用,但处处是胶带。如果项目周期紧、团队人少,我建议直接分开写,别折腾。如果是 side project 想玩新技术,或者确实有强烈的代码共享需求(比如 UI 逻辑特别复杂、主题经常变),可以试,但做好被 Gradle、版本号、平台差异三件套混合双打的心理准备。
JetBrains 最近在大力推 Compose for iOS,1.6.x 里 iOS target 的优先级明显高于 Desktop。我怀疑 Desktop 端的那些坑,短期内不会有人认真修。毕竟 JetBrains 自己的 Fleet IDE 用 Compose Desktop,但他们有专门的团队去填坑,普通开发者哪有这资源。
下次如果有人再跟我说"Write once, run everywhere",我大概会回:"Run everywhere 是真的,run well 是假的。"