KMP+Compose Multiplatform双端跨平台开发实战全流程

发布时间:2026/9/17 20:37:52
KMP+Compose Multiplatform双端跨平台开发实战全流程 1. 先算一笔账KMP Compose 到底帮我省下了什么两套代码、两拨人、两次发版一个按钮颜色改一次要提交两个仓库——这是绝大多数中小团队做移动端时最真实的成本结构。我去年把一个内部工具类应用从Android 原生 iOS 原生改成了 KMPKotlin Multiplatform Compose Multiplatform 的技术栈业务逻辑和 UI 全部收敛到一份 Kotlin 代码里两个端的差异代码从原来的将近 9000 行压到了 1200 行左右。这篇文章就是把这套东西从零跑通的全过程写下来包括哪些坑必须提前知道、哪些配置抄了就能用。先把概念说清楚避免后面绕。KMP 是 Kotlin 官方推出的跨平台能力它的核心不是一套 UI 打天下而是允许你把 Kotlin 代码编译成 Android 的 JVM 字节码、iOS 的原生机器码通过 Kotlin/Native业务逻辑层只写一份。而Compose Multiplatform 是 JetBrains 在 KMP 之上做的一层 UI 框架把原本只在 Android 上用的 Jetpack Compose 扩展到了 iOS、桌面端。两者组合起来才构成逻辑 UI 都共享的完整方案。这个组合适合谁我给三类人画个像。第一类是已经有 Android 原生团队、Kotlin 写得顺手的学习成本几乎为零KMP 对你来说就是把现有代码往 commonMain 里搬。第二类是小团队要同时上双端、UI 复杂度中等的场景比如工具类、内容型、表单密集型 App收益最大。第三类是想逐步迁移而不是推倒重来的存量项目KMP 允许你一次只共享一个模块这点比 Flutter 那种要么全上要么别上的姿势友好得多。反过来说下面这几种情况我会劝你先别急。如果你的 App 重度依赖 iOS 原生控件质感、需要大量 UIKit 混排比如复杂的相机滤镜、深度定制的导航转场Compose 自绘的 UI 和原生控件之间的缝隙会很硌手。如果你的团队一个人都不会 Kotlin那 Kotlin/Native 的编译报错信息会让人怀疑人生。还有一点必须提前说Compose Multiplatform 的 iOS 端没有 Flutter 那种成熟的热重载体验改一行 UI 代码要重新编译 Kotlin 到 Native冷启动编译经常几十秒到两分钟这个反馈循环的心理成本是真实存在的。对比维度KMP ComposeFlutterReact Native共享范围逻辑 UI 均可共享逻辑 UI 全共享逻辑 UI 全共享iOS UI 实现Compose 自绘Skia/Metal自绘Impeller原生控件桥接语言KotlinDartJavaScript/TypeScript与原生互操作极佳可双向调用需要 Platform Channel需要 Bridge/JSIUI 热重载实验性成熟成熟学习成本Android 背景低中中这张表里我最看重的是与原生互操作那一行。KMP 允许你在 Kotlin 里直接import platform.UIKit.UIViewController也能在 Swift 里直接调用 Kotlin 编译出来的类中间没有序列化开销、没有异步桥接。这一点在做共享 80% 原生 20%的混合架构时价值极高你会很自然地形成一种分层策略列表、表单、状态管理全部共享只有那些必须贴着系统走的模块用原生写然后用 KMP 的expect/actual把接口对齐。2. 环境装到什么程度算够工具链清单与版本对齐2.1 别急着建工程先把版本兼容表翻出来这一步我踩过最贵的坑。Kotlin 版本、Compose Multiplatform 版本、Android Gradle Plugin 版本、Gradle 版本、Xcode 版本这五个东西之间是有硬性约束的随便配就会出现编译过了但 iOS 链接失败或者UI 渲染不出来这种毫无提示的故障。我的做法是先去 Kotlin 官方文档的兼容性说明页把 Kotlin 版本和 Compose Multiplatform 版本对应关系确认一遍再往下走。下面是我目前在用的组合写出来只是给你一个参照锚点具体数字请以你查到的官方文档为准不要直接照抄# gradle/libs.versions.toml [versions] kotlin 2.0.21 agp 8.5.2 compose-multiplatform 1.7.0 ktor 2.3.12 coroutines 1.8.1 serialization 1.7.3 lifecycle 2.8.4 [plugins] kotlinMultiplatform { id org.jetbrains.kotlin.multiplatform, version.ref kotlin } androidApplication { id com.android.application, version.ref agp } composeMultiplatform { id org.jetbrains.compose, version.ref compose-multiplatform } composeCompiler { id org.jetbrains.kotlin.plugin.compose, version.ref kotlin } kotlinSerialization { id org.jetbrains.kotlin.plugin.serialization, version.ref kotlin }这里有一个细节值得单独讲从 Kotlin 2.0 开始Compose 编译器插件被合并进了 Kotlin 仓库你必须显式加上org.jetbrains.kotlin.plugin.compose这个插件并且它的版本号跟 Kotlin 主版本保持一致。很多人从 1.9 升级上来时忘了加这一行报错信息是一堆Composable相关的诡异问题看着像代码写错了其实是插件没挂上。2.2 装 Java、Android Studio、Xcode 的顺序和注意点JDK 用 17这是当前 AGP 的稳定选择。我建议直接用 Android Studio 自带的 JetBrains Runtime然后把JAVA_HOME指向它避免机器上同时存在三四个 JDK 相互打架。如果你机器上确实需要多版本共存jenv这类版本管理工具能省不少事否则./gradlew报的 Java 版本错误会让人摸不着头脑。Android Studio要装当前较新的稳定版因为 Compose Multiplatform 的项目模板和插件更新是跟着它走的。装完之后在 SDK Manager 里确认两件事一是 Android SDK Platform 里至少有一个你目标 API 版本的平台二是 SDK Tools 里Android SDK Command-line Tools和Android SDK Build-Tools都勾上了CI 上跑构建时缺这两个会直接失败。Xcode这部分是 Android 开发者最陌生的。从 App Store 装完之后还有两步收尾命令很多人不知道漏掉的话 Gradle 调用 iOS 工具链时会失败# 接受许可协议不执行这条后面 xcodebuild 会直接报错退出 sudo xcodebuild -license accept # 安装额外组件包括模拟器运行时 sudo xcodebuild -runFirstLaunch # 确认选中的是完整版 Xcode 而不是 Command Line Tools xcode-select -p # 期望输出类似 /Applications/Xcode.app/Contents/Developer如果xcode-select -p输出的是/Library/Developer/CommandLineTools说明你只装了命令行工具需要执行sudo xcode-select -s /Applications/Xcode.app/Contents/Developer切过去。这个坑我就中过单独的 Command Line Tools 里没有完整的 iOS SDKKotlin/Native 链接阶段会找不到UIKit相关符号报错信息完全看不出是工具链选错了。还有一个强烈建议装的东西kdoctor。这是 JetBrains 官方出的环境自检工具它会逐项检查你的 JDK、Android SDK、Xcode、CocoaPods、环境变量配置并给出明确的修复建议。装法很简单brew install kdoctor kdoctor跑完之后它会用绿色勾和红色叉告诉你哪一项不达标比你自己一个个猜快太多了。第一次配环境的时候我建议跑三遍刚装完跑一遍建完工程跑一遍iOS 跑不起来时再跑一遍。2.3 关于 Carthage、CocoaPods 要不要装如果你选择用CocoaPods 方式集成 iOS 端后面第 6 节会讲两种方式的区别你需要装 CocoaPods。这里有个建议用gem install cocoapods而不是brew install cocoapods因为 Gradle 里调用的pod命令需要能拿到正确的 Ruby 环境brew 版本在某些 Ruby 版本组合下会出现路径混乱。装完之后pod --version能正常输出版本号就行。如果你选择直接 framework 集成我更推荐新手走这条路那 CocoaPods 完全不用装少一个依赖少一堆麻烦。3. 工程骨架怎么切commonMain、androidMain、iosMain 各放什么3.1 目录结构和 sourceSet 的对应关系KMP 工程最容易让人迷糊的地方就是这段代码到底该写在哪。先看标准骨架长什么样MyKmpApp/ ├── composeApp/ │ ├── build.gradle.kts │ └── src/ │ ├── commonMain/ │ │ ├── kotlin/ # 双端共享的业务逻辑和 UI │ │ └── composeResources/ # 双端共享的图片、字体、字符串 │ ├── androidMain/ │ │ └── kotlin/ # Android 专属实现 │ └── iosMain/ │ └── kotlin/ # iOS 专属实现 ├── iosApp/ │ ├── iosApp.xcodeproj │ ├── iosApp/ │ │ ├── iOSApp.swift │ │ └── ContentView.swift │ └── Configuration/ ├── gradle/libs.versions.toml └── settings.gradle.ktscommonMain里放的是跟平台无关的一切数据模型、序列化、网络层封装、Repository、UseCase、状态管理以及用 Compose 写的所有 UI。判断标准很简单——这段代码如果放到纯 Kotlin 的 JVM 项目里也能编译通过那它就该在 commonMain。一旦你写的东西需要import android.*或者import platform.UIKit.*它就必须往平台目录挪。androidMain放的是 Android 平台的实现Activity 入口、权限申请、SharedPreferences封装、OkHttp 引擎、通知、以及那些必须拿Context才能做的事。iosMain对应地放 iOS 侧的东西把 Compose 的ComposeUIViewController暴露给 Swift、NSUserDefaults封装、Ktor 的 Darwin 引擎、以及需要UIApplication的场景。3.2 共享层里那些需要显式声明的依赖composeApp/build.gradle.kts是整个工程的中枢我把关键的几块拆开说kotlin { androidTarget { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) } } // 三个 iOS target模拟器 x64、真机 arm64、Apple Silicon 模拟器 arm64 listOf( iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { iosTarget - iosTarget.binaries.framework { baseName Shared isStatic true } } sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.components.resources) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) } androidMain.dependencies { implementation(libs.ktor.client.okhttp) implementation(compose.preview) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } } }这段配置里有三个点必须解释清楚不然你会一直在猜。第一为什么 iOS 要声明三个 target。iosArm64对应真机iosSimulatorArm64对应 Apple Silicon 芯片的 Mac 上跑模拟器iosX64对应老款 Intel Mac 上跑模拟器。三个都声明代码才能在你的机器和同事的机器、以及 CI 上都能编出来。如果你的团队已经全部换成 M 系列芯片了iosX64理论上可以省掉但保留它的成本很低我建议先留着。第二isStatic true是什么意思。这决定 Kotlin 编译出来的 framework 是静态库还是动态库。静态库在链接期被塞进主二进制App 启动时少一次动态库加载启动速度会略好一些而且不会有framework 没被嵌入导致真机崩溃这一类问题。代价是最终二进制体积稍大。对于绝大多数 App我推荐静态库。如果后面你遇到两个 framework 里有重复符号的冲突也可能需要切换这个值来绕开。第三Ktor 引擎为什么分开写。Ktor 的网络客户端是分层设计的ktor-client-core是公共 API具体发起请求的引擎是平台相关的。Android 用 OkHttp 性能好、生态成熟iOS 用系统底层的NSURLSession封装也就是 Darwin 引擎最省事不用额外带一堆 C 库。这个设计其实很值得借鉴跨平台框架的正确姿势就是把接口在 common、实现在平台这条原则贯彻到底。3.3 一份代码该放哪的速查表你要写的东西放哪原因数据模型、DTOcommonMain纯 Kotlin无平台依赖网络请求封装commonMain引擎在平台接口共享引擎分离页面 UI、主题、导航commonMainCompose Multiplatform 已经跨端数据库实体与 DAOcommonMain驱动在平台SQLDelight/Room KMP 都是这个模式权限申请androidMain iosMainAPI 完全不同分享、支付、推送androidMain iosMain需要各平台 SDKApp 入口androidMainActivity iosAppSwift启动流程平台专属图片字体资源commonMain/composeResourcesCompose 资源系统统一管理4. expect/actual 的正确用法与被写烂的用法4.1 它到底解决什么问题expect/actual是 KMP 里最核心也最容易被滥用的语言特性。它的语义是在 commonMain 里用expect声明存在这么一个东西然后在每个平台的 sourceSet 里用actual给出具体实现。编译器会在编译期强制检查每个平台都提供了实现漏掉一个就报错。最基础的用法是这样// commonMain/Platform.kt expect fun platformName(): String expect fun platformVersion(): String // androidMain/Platform.android.kt import android.os.Build actual fun platformName(): String Android actual fun platformVersion(): String Build.VERSION.RELEASE // iosMain/Platform.ios.kt import platform.UIKit.UIDevice actual fun platformName(): String UIDevice.currentDevice.systemName() actual fun platformVersion(): String UIDevice.currentDevice.systemVersion看起来很清爽但这里有几个硬性规则必须记住全都是编译期报错的来源expect声明不能有函数体actual的函数签名、可见性、返回类型必须与expect完全一致actual不能比expect更开放比如 expect 是 internalactual 就不能是 publicexpect上不允许写默认参数——这一条特别容易踩很多人习惯写fun doSomething(timeout: Long 1000)在 common 里一写就编译不过只能改成重载或者把默认值交给平台实现处理。4.2 别把它当成万能胶接口 注入往往更合适我见过不少工程把expect/actual用到了一种什么都往上面套的程度结果 commonMain 里散落着几十个 expect 函数每个平台都要维护一份 actual改一个参数要动三个文件。这是典型的误用。判断标准是这样的如果这个能力的契约是固定的、只有实现方式不同用expect/actual如果这个能力本身是可选的服务、有多种实现、需要测试替换用接口 平台注入更合适。举个例子。日志上报这种能力我不建议用expect/actual而是这样设计// commonMain/Logger.kt interface Logger { fun d(tag: String, msg: String) fun e(tag: String, msg: String, throwable: Throwable? null) } // 一个跨平台默认实现两个端都能用 class ConsoleLogger : Logger { override fun d(tag: String, msg: String) println([$tag] $msg) override fun e(tag: String, msg: String, throwable: Throwable?) { println([$tag] $msg) throwable?.printStackTrace() } } // 全局容器由各平台在启动时注册 object AppGraph { var logger: Logger ConsoleLogger() var prefs: KeyValueStore InMemoryStore() lateinit var appContext: Any }然后在androidMain里注入一个写 Logcat 的实现在iosMain里注入一个写os_log的实现。这样做的好处有三个commonMain 里的代码只依赖Logger接口单测时可以直接塞一个假的平台实现可以有任意多个不受 expect/actual 一对一关系的限制未来加桌面端时只需要再写一个实现类而不用再补一遍 actual。需要提醒的是上面这种全局可变容器在正式项目里要小心线程安全问题更规范的做法是用一个初始化函数在 App 启动时注入而不是暴露 public var。我写在这里是为了让代码短一点看清思路。4.3 一个真实例子网络客户端的平台差异化回到前面的 Ktor 场景我把expect/actual和接口结合起来的写法是这样// commonMain/HttpClientFactory.kt import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig expect fun createHttpClient(config: HttpClientConfig*.() - Unit {}): HttpClient // commonMain/ApiClient.kt class ApiClient(private val client: HttpClient) { suspend fun fetchHome(): HomeData client.get(https://api.example.com/home).body() } // androidMain/HttpClientFactory.android.kt import io.ktor.client.engine.okhttp.OkHttp actual fun createHttpClient(config: HttpClientConfig*.() - Unit): HttpClient HttpClient(OkHttp) { config() } // iosMain/HttpClientFactory.ios.kt import io.ktor.client.engine.darwin.Darwin actual fun createHttpClient(config: HttpClientConfig*.() - Unit): HttpClient HttpClient(Darwin) { config() }注意expect里我没有写默认参数前面说过不允许而actual里写了默认值——这是允许的因为默认值只在调用点决定而调用点都在 common 里会去查expect的签名。这类细节在编译报错时看着莫名其妙理解了规则就一次过了。还有一个实操提醒第一次编译 iOS target 时Kotlin/Native 会去下载 konan 工具链和依赖体积有好几百兆。如果你的网络环境一般第一次构建可能要等很久甚至超时失败。我的经验是先在命令行跑一次./gradlew :composeApp:compileKotlinIosSimulatorArm64让它把工具链下完再去 Android Studio 里点构建这样失败重试的反馈更快。5. Compose Multiplatform 的 UI 在 iOS 上究竟怎么跑起来的5.1 渲染路径从 Skia 到 Metal很多人第一次听到Compose 能在 iOS 上跑会觉得不可思议觉得是不是套了一层 WebView 或者做了某种模拟。不是的。Compose Multiplatform 的 iOS 端走的是自绘路线Compose 的布局树被渲染到 Skia 画布上再通过 Metal 输出到屏幕。这意味着你在 iOS 上看到的按钮、列表、文字本质上都是画出来的而不是UIButton、UITableView这些原生控件。这个事实带来两个非常实际的后果必须提前知道。好处是双端 UI 像素级一致Android 和 iOS 上写一套 Compose 代码跑出来的界面视觉上几乎完全一样不用再为双端控件默认样式差异头疼。代价是你会失去一部分 iOS 原生手感滚动回弹的曲线、列表惯性、UIRefreshControl那种特定的下拉质感、文本选中的放大镜效果这些都需要你自己实现或者接受不太一样。这不是 bug是路线选择的必然结果。如果你需要跟原生控件混排Compose Multiplatform 提供了两个互操作组件Android 端用AndroidView嵌原生 ViewiOS 端用UIKitView嵌UIView。我实测下来简单控件比如地图、WebView、播放器混排是可靠的但复杂的嵌套滚动场景要谨慎滚动事件的传递会有边界情况性能也会有明显下降。5.2 从 SwiftUI 到 Compose 的入口代码iOS 端的接入代码其实很少核心就是三步。第一步在iosMain里把 Compose 的 UI 包装成一个UIViewController// iosMain/MainViewController.kt import androidx.compose.ui.window.ComposeUIViewController import platform.UIKit.UIViewController fun MainViewController(): UIViewController ComposeUIViewController { App() }第二步在 Swift 侧写一个UIViewControllerRepresentable桥接// iosApp/iosApp/ComposeView.swift import SwiftUI import Shared struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) - UIViewController { // 注意这里的调用方式Kotlin 的顶层函数会被编译到 文件名Kt 这个类里 MainViewControllerKt.MainViewController() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} }第三步在 SwiftUI 的 App 入口里用它// iosApp/iosApp/iOSApp.swift import SwiftUI main struct iOSApp: App { var body: some Scene { WindowGroup { ComposeView() .ignoresSafeArea(.keyboard) // 键盘避让交给 Compose 自己处理 } } }第三个片段里的.ignoresSafeArea(.keyboard)是我踩过的一个坑。如果不加这一句iOS 的软键盘弹出来时SwiftUI 会试图自己调整整个 View 的位置而 Compose 内部也在做键盘避让计算两边一起动结果就是输入框被顶飞或者抖动。让 SwiftUI 放过键盘区域把这件事完全交给 Compose 处理是目前比较稳的做法。5.3 资源管理和那些视觉上的坑Compose Multiplatform 从 1.6 开始提供了统一的资源系统图片、字体、字符串都可以放在commonMain/composeResources/下面然后在代码里用生成的Res对象访问// 目录结构commonMain/composeResources/drawable/ic_logo.png // commonMain/composeResources/values/strings.xml import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import mykmpapp.composeapp.generated.resources.Res import mykmpapp.composeapp.generated.resources.ic_logo import mykmpapp.composeapp.generated.resources.app_name Composable fun SplashScreen() { Column { Image(painter painterResource(Res.drawable.ic_logo), contentDescription null) Text(text stringResource(Res.string.app_name)) } }这里有个经常被忽略的坑生成的Res类所在的包名是由 Gradle 配置推导出来的如果你的 module 名或者 group 名改了import 路径会跟着变代码里会突然出现一片红色。遇到这种情况不要慌去 build 目录里搜一下生成的Res.kt文件看清楚实际包名再改 import。另外几个视觉差异我列一下方便你避坑现象原因处理方式顶部内容被刘海遮挡Compose 默认不处理 iOS 安全区外层加Modifier.windowInsetsPadding(WindowInsets.safeDrawing)文字换行位置和 Android 不同两端字体度量差异关键文案设maxLines或改用两端都打包的内置字体深色模式下状态栏文字看不清状态栏样式由系统控制iOS 侧在Info.plist里配UIUserInterfaceStyle或用preferredStatusBarStyle列表滚动回弹感不对自绘不跟随系统配置属于路线限制接受或改用原生列表这些点看起来零碎但在真机上验收阶段会集中爆发提前半小时规划好过花两天改。6. iOS 侧的桥接从 Gradle 产物到 Xcode 能跑6.1 两种集成方式的取舍把 Kotlin 代码集成进 Xcode 工程主流有两条路。第一条是直接 framework 集成也是我现在用的方式。Gradle 编译出.frameworkXcode 在构建时通过一个 Run Script 阶段调用 Gradle 生成并嵌入它。优点是链路短、依赖少、不引入 CocoaPods缺点是 Xcode 和 Gradle 的版本耦合更紧配置写错时排查要更细心。第二条是 CocoaPods 集成。在 Gradle 里加kotlin(native.cocoapods)插件声明podspec然后 Xcode 侧照常pod install。优点是跟现有用 CocoaPods 管理的第三方库统一了缺点是每次改 Kotlin 代码都要重新 pod install构建链路变长出问题时排查层次更多。如果你的 iOS 工程本来就在用 CocoaPods 管理一堆依赖选它如果是新工程我建议直接 framework。6.2 直接 framework 方式的完整配置在composeApp/build.gradle.kts里除了前面的binaries.framework声明通常还要指定 iOS 部署版本kotlin { listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target - target.binaries.framework { baseName Shared isStatic true freeCompilerArgs listOf(-XbinarybundleIdcom.example.mykmpapp.shared) } } }然后打开 Xcode 工程做三件事。第一步加一个 Run Script 构建阶段位置必须在 Compile Sources 之前。脚本内容cd $SRCROOT/.. ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode这个embedAndSignAppleFrameworkForXcode任务是 Compose Multiplatform 模板内置的它会读取 Xcode 传过来的环境变量比如目标架构、配置名自动编译出对应架构的 framework 并嵌入到 App 的 Frameworks 目录顺手把签名也处理了。不要自己去写./gradlew :composeApp:linkReleaseFrameworkIosArm64这种硬编任务名因为模拟器构建和真机构建需要的架构不一样硬编会在切换设备时失败。第二步配置 Framework Search Paths。在 Build Settings 里把这个路径加上注意 $(SRCROOT) 的相对层级要跟你的目录结构对上$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)第三步关掉 User Script Sandboxing。这是 Xcode 15 引入的一个安全特性默认开启会导致 Run Script 阶段调用的 Gradle 脚本没有权限写文件报一堆Sandbox: bash(...) deny(1) file-write-create。在 Build Settings 里搜ENABLE_USER_SCRIPT_SANDBOXING设成NO。这个坑非常常见而且报错信息完全没提沙箱和Gradle的关系第一次遇到会怀疑自己的脚本写错了。6.3 真机调试和模拟器调试的差异模拟器跑通之后上真机通常还会遇到几个新问题我按出现频率排一下。签名配置。真机需要有效的开发者证书和 Provisioning Profile。在 Xcode 的 Signing Capabilities 里选好 Team勾上 Automatically manage signing 让 Xcode 自己处理。如果是多人协作建议用.xcconfig文件把 Team ID 抽出来避免每个人的本地配置提交到仓库里互相覆盖。提交到仓库的.pbxproj文件里如果混进了别人的 Team ID会让队友构建失败这个坑团队里几乎必踩。架构不匹配。真机是 arm64Apple Silicon 的模拟器也是 arm64但两者的 SDK 不同链接的 system framework 也不一样。如果出现了building for iOS Simulator, but linking in object file built for iOS说明某个预编译的二进制架构选错了。用直接 framework 方式时这种情况较少因为embedAndSignAppleFrameworkForXcode会根据当前 SDK 挑正确的 target。首次启动白屏。有几种可能一是 Compose 的内容还没渲染完成可以先用一个纯 SwiftUI 的 Text 替换ComposeView()验证桥接层是否正常二是 Kotlin 侧在初始化时抛了异常但 Kotlin/Native 的异常在 Swift 里默认不会打印堆栈需要在iosMain里挂一个未捕获异常处理器来打日志三是主线程问题Compose 的状态更新必须在主线程如果某个网络回调里直接改了 stateiOS 上会出各种怪异表现详见下一节。7. 踩坑排查实录几个真实故障的完整链路7.1 Undefined symbols for architecture arm64 怎么定位这是 iOS 集成阶段出现频率最高的报错。看到一个长长的符号列表很多人第一反应是怀疑 Kotlin 代码写错了实际上原因通常在链接层面。我整理的排查顺序是这样的先看符号名字长什么样。如果符号里有kfun:前缀说明是 Kotlin 侧的函数没被链接进来——大概率是某个iosMain的实现文件没被 sourceSet 识别到检查目录结构和 package 声明是否对应。如果符号是_OBJC_CLASS_$_XXX这种形式说明是系统框架没链接需要在 Build Phases 的 Link Binary With Libraries 里加上对应的.framework比如用了UIKit就确保它在列表里。然后确认 framework 是不是真的被嵌入到了 App 包里。构建完之后去 DerivedData 里找.app文件右键 → 显示包内容看Frameworks目录下有没有你的Shared.framework。如果用了isStatic true静态库会被直接链进主二进制这个目录下看不到它是正常的——这也是静态库的一个隐性好处不会因为 framework 嵌入失败而在真机上崩溃。最后检查 Build Settings 里的Other Linker Flags。用直接 framework 方式时通常需要加-framework Shared如果不加Swift 代码能编译通过因为头文件找得到但链接会失败报的就是这个符号未定义。7.2 iOS 上的线程模型和 Compose 的状态更新Kotlin 2.0 之后默认启用了新的内存管理器早期那种对象冻结freezing的限制基本消失了跨线程传对象方便了很多。但有一个约束依然存在而且非常容易踩Compose 的状态更新必须在主线程执行。在 Android 上你可能习惯了在协程里直接改 state 也没事因为 Android 的 UI 线程检查相对宽松一些。到了 iOS从后台线程改 Compose state 会直接抛出异常或者出现莫名其妙的崩溃。解决办法是保证 ViewModel 里的协程都在主线程调度器上启动// commonMain/HomeViewModel.kt import kotlinx.coroutines.* class HomeViewModel(private val api: ApiClient) { private val scope CoroutineScope(SupervisorJob() Dispatchers.Main) private val _state MutableStateFlow(HomeUiState()) val state: StateFlowHomeUiState _state.asStateFlow() fun load() { scope.launch { _state.value _state.value.copy(loading true) runCatching { api.fetchHome() } .onSuccess { data - _state.value HomeUiState(data data) } .onFailure { e - _state.value HomeUiState(error e.message) } } } }这里用Dispatchers.Main而不是Dispatchers.Default然后withContext(Main)是因为一次性把整个作用域绑在主线程上省得在每个状态更新点都想一遍上下文出错概率更低。代价是fetchHome()这个网络请求也跑在主线程上发起——不过像 Ktor 这类客户端内部会把实际 IO 切到自己的线程池主线程只是发起调用不会阻塞所以这个写法是安全的。如果你的某个操作是纯 CPU 密集型的一定要在里面用withContext(Dispatchers.Default)切出去。还有一个小细节iOS 上的Dispatchers.Default背后的工作线程数是有限的跟 CPU 核心数相关不像 JVM 上那么宽裕。如果你在 common 代码里启了一个很大的Dispatchers.Default协程池做并发生成任务双端表现会差异很大。我的做法是把并发度用一个常量显式写死不要依赖默认值。7.3 那些不报错但表现不对的问题有些问题比编译错误更难搞因为它们不报错只是感觉不对。网络请求在 iOS 上失败但没有任何信息。先排查两件事一是你的接口是不是明文 HTTPiOS 默认只允许 HTTPS需要在Info.plist里配置NSAppTransportSecurity白名单。二是Info.plist里有没有声明网络用途说明。这两项在 Android 上完全不存在所以很多人第一次遇到会以为是 Ktor 配置问题。Release 包崩溃而 Debug 包正常。这是典型的代码混淆问题。Android 侧如果开了 R8kotlinx.serialization自动生成的 serializer 会被当成无用代码删掉运行时反序列化直接抛异常。解决方法是在 proguard 规则里保留序列化器的相关类-keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.** -keepclassmembers class kotlinx.serialization.json.** { *** Companion; } -keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); } -keep,includedescriptorclasses class com.example.mykmpapp.**$$serializer { *; } -keepclassmembers class com.example.mykmpapp.** { *** Companion; }iOS 侧没有 R8 那套东西但它有自己的优化问题Kotlin/Native 编译器会把没被引用的代码剔除掉dead code elimination。如果你的某个类只被反射使用、Swift 侧又没直接引用可能就被裁掉了。这种情况一般出现在跟 Swift 的接口边界上确保 Swift 侧真的调用过每个需要的 Kotlin 导出方法或者用ObjCName之类的注解显式声明导出。编译越来越慢。Kotlin/Native 的编译速度是所有人都要面对的问题。我做过几个优化效果比较明显把org.gradle.jvmargs调大到合适值-Xmx4g起步机器内存够的话 8g开启 Gradle 构建缓存和配置缓存把不常改的模块拆出去避免改一行 UI 就触发全量 Native 编译。另外本地开发时不要三个 iOS target 全编用./gradlew :composeApp:compileKotlinIosSimulatorArm64只编你当前要跑的架构能省一半以上时间。8. 打包发布双端各自的临门一脚8.1 Android 侧的版本管理和混淆KMP 工程在 Android 侧和普通 Android 工程没本质区别但有一个细节值得注意版本号应该只在 Gradle 里定义一处然后同时被 Android 和 iOS 读取。我通常在libs.versions.toml或者一个专门的Version.kt里声明Android 的defaultConfig引用它iOS 侧在构建时通过 Gradle 生成一个 Swift 能读到的常量。// composeApp/build.gradle.kts android { defaultConfig { applicationId com.example.mykmpapp minSdk 24 targetSdk 35 versionCode 100 versionName 1.0.0 } buildTypes { release { isMinifyEnabled true isShrinkResources true proguardFiles( getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro ) } } }minSdk选 24 是个常见取舍再往下降会有一些 API 兼容负担往上升又会丢掉一部分老设备。如果你的 App 面向的是普通消费者24 到 26 之间基本够用。8.2 iOS 侧的归档与导出iOS 打包必须在 macOS 上做没有第二条路。GUI 流程是 Xcode 里Product → Archive然后在 Organizer 里选Distribute App。命令行流程更适合接 CI# 1. 归档生成 .xcarchive xcodebuild archive \ -project iosApp/iosApp.xcodeproj \ -scheme iosApp \ -configuration Release \ -archivePath build/iosApp.xcarchive \ -destination generic/platformiOS # 2. 导出 IPA需要一份 ExportOptions.plist 描述导出配置 xcodebuild -exportArchive \ -archivePath build/iosApp.xcarchive \ -exportPath build/ipa \ -exportOptionsPlist iosApp/ExportOptions.plistExportOptions.plist是最容易配错的文件里面要写清楚导出方式app-store、ad-hoc、development、enterprise、签名方式、Team ID、以及是否上传符号表。我建议先用 Xcode GUI 导出一次它会生成一份ExportOptions.plist给你参考拿过来改成模板再手工维护比从零写靠谱得多。有几个坑我列一下。Archive 之前要确认 Run Script 阶段在 Release 配置下也能正常工作本地 Debug 能跑不代表 Release 能归档因为 Release 会做更多的代码优化和剥离。版本号不一致会导致上传被拒iOS 侧的CFBundleShortVersionString和CFBundleVersion要跟提交到应用商店的信息对齐。符号表建议保留Kotlin/Native 的崩溃堆栈需要.dSYM才能符号化如果线上出了崩溃却拿不到符号排查会非常痛苦。8.3 我在长期使用中的几点体会关于这套技术栈我想说的最后几点是经验层面的。第一不要一开始就追求 100% 共享。我见过有人为了跨平台纯粹性硬要用 Compose 实现 iOS 的所有交互细节最后在一个下拉刷新动画上耗了一周。正确的做法是先共享数据层和状态层UI 从简单页面开始遇到真正需要原生质感的地方就老老实实写原生用UIKitView嵌进来。共享率 70% 到 80% 是个很健康的位置硬追 95% 的边际成本会陡增。第二把 commonMain 的边界当成架构纪律来守。commonMain里出现import android.*或import platform.*是编译不过的这本身就是一道很好的约束。但有些不那么明显的越界会悄悄发生比如某个类虽然编译得过去但它依赖了 Android 的某个默认行为比如默认的Dispatchers.Main实现细节。我建议给 commonMain 的代码写单元测试在 JVM 环境里跑能跑通的才算是真正平台无关的代码。第三版本升级要克制。Kotlin、Compose Multiplatform、AGP 这三者的版本联动很紧升级 Kotlin 主版本经常意味着要同步升 Compose 和 AGP还可能牵动 Xcode 的最低版本要求。我现在的节奏是新项目跟着最新稳定版走存量项目只在有明确需要比如某个 bug 在新版本修复了时才升一次只升一个组件升完先把双端都跑一遍回归再继续。第四给 iOS 调试留足时间预算。项目排期时Android 侧和 iOS 侧的工作量不能按 1:1 算。Android 侧的坑你大概率见过iOS 侧的坑签名、沙箱、符号化、键盘避让对大多数 Android 出身的开发者来说是全新的。我做第一个 KMP 项目时iOS 集成和调试的时间大概是 Android 侧的 2.5 倍第二次做已经降到 1.3 倍左右这个学习曲线要提前跟项目干系人说清楚。最后分享一个我一直在用的小习惯在iosApp的 Xcode 工程里单独留一个纯 SwiftUI 的 Debug 页面用来快速验证桥接层是否正常。当 Compose 页面白屏时把它切回纯 SwiftUI 页面如果 SwiftUI 正常而 Compose 白屏问题就在 Kotlin 侧如果连 SwiftUI 都不正常问题在 Xcode 配置或签名上。这个二分法帮我省掉了很多在错误方向上瞎猜的时间。