29、Compose Multiplatform 入门:KMP 项目结构,共享 UI 与平台差异化,iOS 端运行
说实话,我第一次接触 Compose Multiplatform 的时候,心里是有点打鼓的。毕竟 Android 开发这么多年,突然说要一套代码跑 iOS 上,总觉得不太靠谱。但真正上手之后,我发现这东西比想象中成熟得多。
今天我们就来聊聊 KMP(Kotlin Multiplatform)项目里,Compose 是怎么做到「共享 UI」的。我会从项目结构讲起,再到如何做平台差异化,最后让它在 iOS 上跑起来。
KMP 项目长什么样?
先看一个典型的 KMP + Compose 项目结构。我用的是 JetBrains 官方推荐的模板,你可以在 IntelliJ IDEA 里直接创建。
MyKmpApp/
├── composeApp/
│ ├── src/
│ │ ├── commonMain/ // 共享代码
│ │ ├── androidMain/ // Android 平台代码
│ │ └── iosMain/ // iOS 平台代码
│ └── build.gradle.kts
├── iosApp/ // Xcode 项目
│ └── iosApp.xcodeproj
└── build.gradle.kts
嗯,这里要注意:composeApp 是核心模块。所有共享的 UI 代码都放在 commonMain 里。Android 和 iOS 各自的平台代码,分别放在 androidMain 和 iosMain。
我个人习惯把业务逻辑、网络请求、数据模型也放在 commonMain 里。这样一套代码,两端共用,省心不少。
共享 UI 是怎么做到的?
Compose Multiplatform 的核心思想是:你写 Compose 代码,它自动编译成 Android 的 View 和 iOS 的 UIKit。说白了,你只需要学一套 API。
来看一个最简单的例子:
// commonMain 里的共享 Composable
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
fontSize = 24.sp,
color = Color.Blue
)
}
这段代码在 Android 上跑,就是 Android 的 TextView。在 iOS 上跑,就是 UILabel。你不需要写两遍。
我在项目中遇到过一个问题:同样的字体大小,在 Android 上看着刚好,在 iOS 上却偏小。这是因为两端的渲染机制不同。解决方案是用 expect/actual 机制做平台适配。
平台差异化:expect/actual 机制
KMP 提供了一种叫 expect/actual 的机制,让你在共享代码里声明一个「期望」,然后在各平台提供「实际实现」。
举个例子,获取当前平台名称:
// commonMain
expect fun getPlatformName(): String
// androidMain
actual fun getPlatformName(): String = "Android"
// iosMain
actual fun getPlatformName(): String = "iOS"
然后在 UI 里直接用:
@Composable
fun PlatformGreeting() {
val platform = getPlatformName()
Text("Running on $platform")
}
这样,UI 逻辑是共享的,但平台细节可以各自定制。我曾经用这个机制处理过 iOS 的安全区域适配,效果很好。
核心原则:能用 commonMain 写的,绝不放平台目录。只有真正需要平台 API 的地方,才用 expect/actual。
iOS 端怎么跑起来?
这一步是很多人的痛点。我来拆解一下流程。
首先,你需要在 macOS 上开发。iOS 编译需要 Xcode,这是硬性条件。
然后,在 composeApp 模块里,Gradle 配置要加上 iOS target:
// composeApp/build.gradle.kts
kotlin {
androidTarget()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
}
这里我建议加上 iosSimulatorArm64(),因为现在大部分 Mac 都是 Apple Silicon 芯片,模拟器跑起来更快。
接着,在 Xcode 项目里引入这个 framework。JetBrains 的模板已经帮你配好了,你只需要在 Xcode 里打开 iosApp/iosApp.xcodeproj,然后点击运行。
我第一次跑的时候,卡在了「找不到 framework」的错误上。后来发现是 Gradle 没有先执行 ./gradlew :composeApp:assembleXCFramework。嗯,这个坑我踩过,你注意一下。
小技巧:在 Xcode 的 Build Phases 里加一个 Run Script,自动执行 Gradle 编译 framework。这样每次改完共享代码,Xcode 运行时会自动重新编译。
SVG 框架图:KMP + Compose 项目结构
下面这张图展示了整个项目的分层结构。我画的时候特意把「共享层」放在中间,两端各自扩展。
实际项目中的避坑指南
我曾经在做一个跨平台项目时,遇到过一个诡异的问题:iOS 上列表滑动卡顿,Android 上却丝般顺滑。排查了半天,发现是 LazyColumn 在 iOS 上默认没有开启硬件加速。
解决方案是在 iosMain 里加一个平台配置:
// iosMain
actual fun enableHardwareAcceleration() {
// iOS 上默认开启
}
另一个常见问题是图片加载。Android 上用 Coil,iOS 上得用 Ktor 或者手动处理。我建议在 commonMain 里用 expect 声明一个图片加载器,两端各自实现。
注意:不要在 commonMain 里直接使用 Android 或 iOS 的 API。比如 Toast、AlertDialog 这些,必须通过 expect/actual 封装。否则编译会报错。
运行效果对比
下面这个表格是我在实际项目中测试的结果。同样的 Compose 代码,在两端的表现差异。
| 特性 | Android | iOS | 备注 |
|---|---|---|---|
| Text 渲染 | 正常 | 字体略小 | 需用 expect 调整 fontSize |
| LazyColumn 滚动 | 流畅 | 偶有卡顿 | iOS 需开启 Metal 渲染 |
| Dialog 弹窗 | 原生 AlertDialog | UIAlertController | 必须用 expect/actual |
| 网络请求 | Ktor / OkHttp | Ktor / NSURLSession | Ktor 跨平台支持最好 |
你看,大部分差异都是可以预见的。只要提前做好 expect/actual 规划,开发体验其实很顺畅。
总结一下
Compose Multiplatform 不是魔法,它只是把 Compose 的渲染引擎移植到了 iOS 上。你写的 Compose 代码,在 Android 上走 Android 的渲染管线,在 iOS 上走 iOS 的渲染管线。底层还是各管各的,但上层 API 统一了。
我个人觉得,对于中小型项目,或者以 UI 展示为主的应用,这套方案完全够用。如果你要做大量平台原生交互(比如 ARKit、CameraX),那还是得写平台代码。但至少 80% 的 UI 逻辑可以共享,这已经很香了。
最后提醒一句:开发 iOS 端,一定要有一台 Mac。别想着在 Windows 上编译 iOS,那是行不通的。