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 各自的平台代码,分别放在 androidMainiosMain

我个人习惯把业务逻辑、网络请求、数据模型也放在 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 项目结构

下面这张图展示了整个项目的分层结构。我画的时候特意把「共享层」放在中间,两端各自扩展。

KMP + Compose Multiplatform 项目结构 commonMain(共享层) Compose UI 组件 业务逻辑 / ViewModel 网络请求 / 数据模型 expect 声明 androidMain actual 实现 Android 特定 API 系统权限 / 传感器 Android Manifest iosMain actual 实现 iOS 特定 API 安全区域 / 手势 Xcode Framework 共享层通过 expect/actual 机制调用平台代码

实际项目中的避坑指南

我曾经在做一个跨平台项目时,遇到过一个诡异的问题:iOS 上列表滑动卡顿,Android 上却丝般顺滑。排查了半天,发现是 LazyColumn 在 iOS 上默认没有开启硬件加速。

解决方案是在 iosMain 里加一个平台配置:

// iosMain
actual fun enableHardwareAcceleration() {
    // iOS 上默认开启
}

另一个常见问题是图片加载。Android 上用 Coil,iOS 上得用 Ktor 或者手动处理。我建议在 commonMain 里用 expect 声明一个图片加载器,两端各自实现。

注意:不要在 commonMain 里直接使用 Android 或 iOS 的 API。比如 ToastAlertDialog 这些,必须通过 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,那是行不通的。

公众号:蓝海资料掘金营,微信deep3321