Compose Multiplatform 真的能用吗?踩坑记录

Compose Multiplatform 真的能用吗?踩坑记录

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 上用的是 skiaImage.makeFromEncoded,没有图片尺寸限制,也没有复用池,大图全量解码进内存。这根本没法用在生产环境。


输入法的幽灵:中文输入在 macOS 上的灾难


这个项目有搜索功能,需要输入中文。Android 上没问题,Desktop 上在 macOS 测试时,中文输入法的行为让我怀疑人生。


具体现象是:用搜狗输入法或者系统拼音,输入过程中,Compose 的 TextField 会把拼音字母也 commit 进文本值。比如我想打"测试",输入拼音的过程中,TextFieldonValueChange 回调会收到 "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
    }
)

isComposingjava.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/actualNavigator 接口,Android 端实际实现用 Navigation Compose 包装一下,Desktop 端用最简单的 mutableStateOf<Class<*>> 做页面切换,参数通过全局的 rememberSaveable 模拟。这代码写出来我都不好意思给人看,但它至少不 leak,也不会因为版本升级突然挂掉。


资源系统:R.java 的乡愁


Android 开发者习惯了 R.drawable.xxxR.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 上得更改 NSWindowtitlebarAppearsTransparent,这些和 Compose 完全无关,你得分别写 native 代码。


再比如返回手势。Android 有 predictive back gesture,Compose 的 BackHandler 能处理。Desktop 上用户按 Escape 或者 Alt+Left,这算是"返回"吗?我的设计是 Escape 关闭弹窗,Alt+Left 才是页面返回,但 Compose 的 KeyEvent 在 Desktop 上 keyawtKeyCode 两个字段容易搞混,我一开始判断 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% 是 androidMaindesktopMain 里的平台特化,再加上各种 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 确实省了事:


一个是主题系统。我定义了一套 ColorSchemeTypographyShapes,三端(我还试了 Web,但 Web 端坑更多,另说)共用,换品牌色改一个地方就行。Android 上的 Material3 适配和 Desktop 上的窗口边框颜色,底层实现不同,但上层 API 统一了,这部分代码如果分开写确实会重复。


另一个是动画。AnimatedVisibilityanimateContentSize 这些 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 是假的。"

开源图表库选型:MPAndroidChart 之外还有什么 2026-05-20
国内安卓厂商的推送联盟,到底救活了没有 2026-05-20

评论区