这次我们来看一个非常典型的跨平台工程问题:星球突击队 Kotlin Multiplatform 移植。
直接把话说在前头,这个项目不是指某一个特定的开源仓库,而是“把一套 Kotlin 代码通过 KMP 架构移植到 Android、iOS、桌面端等多平台”的完整工程方案。无论你是从原生 Android 项目往 iOS 扩展,还是想把一套游戏逻辑、业务模块同时跑在多个端上,Kotlin Multiplatform(简称 KMP)现在都是值得优先考虑的技术路线。
KMP 的核心卖点不是“一套代码走天下”这种夸张口号,而是共享业务逻辑、保留原生体验、按需复用 UI。它最务实的使用方式是把网络层、数据存储、状态管理、工具类这些和平台无关的逻辑全部抽到共享模块,UI 层仍然用原生方案去写,或者用 Compose Multiplatform 做跨平台 UI。
本文从应用层出发,讲清楚 KMP 移植的完整路径:环境准备 -> 工程评估 -> 模块划分 -> 依赖配置 -> 代码改造 -> 功能测试 -> 接口设计 -> 常见问题排查。适合正在做 Android/iOS 双端项目的团队,也适合打算把已有 Kotlin 工程往多平台方向迁的开发者。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | Kotlin Multiplatform 跨平台应用移植 |
| 目标平台 | Android、iOS、桌面端(JVM/Windows/macOS/Linux) |
| 核心技术 | Kotlin Multiplatform、expect/actual 机制、Gradle 多模块工程 |
| 共享范围 | 业务逻辑、数据层、网络层、状态管理、工具库 |
| UI 方案 | 原生 UI 或 Compose Multiplatform |
| 核心难点 | 平台差异隔离、依赖替换、多平台依赖树管理、打包产物配置 |
| 入门门槛 | 需要熟悉 Kotlin 和 Gradle,理解各平台构建体系 |
| 适用项目 | 中大型客户端项目、游戏逻辑层、工具类 SDK、跨端业务组件 |
从工程实践看,KMP 移植最值钱的地方是帮团队把“三端三份逻辑”压缩成“一份共享逻辑 + 三份轻量适配层”。代价是前期需要投入学习成本和构建链路调试时间。
2. 适用场景与使用边界
2.1 适合什么人
KMP 移植特别适合下面几类场景:
- 已有 Android 原生工程,想低成本扩展到 iOS 端。
- 团队熟悉 Kotlin,不想引入 Flutter/React Native 那套全新的 UI 生态。
- 需要共享网络请求、本地存储、账号体系、埋点上报、加密解密等底层逻辑。
- 在做游戏或应用的基础组件层,希望一套逻辑同时跑在 Android、iOS、桌面端。
- 打算用 Compose Multiplatform 把 UI 层也统一起来,但希望按模块渐进式迁移。
2.2 能解决什么问题
最直观的收益是减少重复开发。比如登录流程、数据解析、消息推送、数据库访问这些逻辑,原来 Android 写一遍、iOS 用 Swift 再写一遍,逻辑稍微不一致就会出现两端行为不同的问题。KMP 共享之后,算法逻辑、状态机、校验规则这些只有一份。
另一个收益是测试成本下降。共享模块可以直接在 JVM 上跑单元测试,不需要每次都启动模拟器。这对纯 Kotlin 逻辑的覆盖效率提升非常明显。
2.3 不适合什么情况
- 对 UI 高度定制、大量依赖系统控件的应用,KMP 共享 UI 层的性价比会下降。
- 团队没有 Kotlin 经验,一上来就搞 KMP,学习成本会叠加在业务开发上。
- 核心性能瓶颈在平台特有 API 上的项目,比如大量依赖 CoreML、ARKit、Camera2 的项目,共享逻辑占比小,移植收益有限。
- 处于快速原型期的项目,KMP 的工程复杂度不值得提前引入。
2.4 版权、隐私与合规边界
做 KMP 移植时要特别注意:
- 移植第三方 SDK 或开源库时,确认许可证是否允许跨平台重新编译和分发。
- 涉及用户数据的共享模块,两个平台都要走各自的数据保护合规流程,不能因为逻辑共享就忽略平台隐私政策。
- 游戏或应用的素材、美术资源、音频文件,上传到共享模块后要确认授权范围是否覆盖所有目标平台。
- 不要把平台限制的接口通过 expect/actual 技术绕过审核,所有移植都要遵守目标平台的应用市场规则。
3. 环境准备与前置条件
3.1 开发工具链
| 工具 | 用途 | 说明 |
|---|---|---|
| JDK | 编译 Kotlin/JVM 代码 | 推荐使用 JDK 11 或更高版本 |
| Android Studio | Android 端开发与构建 | 需要安装 Kotlin Multiplatform 插件 |
| Xcode | iOS 端编译与调试 | 仅 macOS 环境需要 |
| Kotlin 插件 | Gradle 构建支持 | 版本以官方最新稳定版为准 |
| Android SDK | Android 平台 API | 通过 SDK Manager 安装 |
| CocoaPods | iOS 端依赖管理 | 可选,取决于是否使用 Pod 集成 |
3.2 硬件要求
KMP 本身对硬件没有特殊硬性要求,普通开发机能跑 Android Studio 就够了。但如果要同时编译 iOS 端,必须使用 macOS。Windows 和 Linux 上可以做共享逻辑的开发,但 iOS target 只能交给 macOS 构建机。
如果是做 CI 持续集成,建议准备 macOS 的构建机器,配置至少 16GB 内存和 100GB 以上磁盘空间。Android 和 iOS 的构建产物加起来体积不小。
3.3 开发环境的检查项
动手之前先确认下面这些项:
java -version # java version "17.0.x" 比较好,低版本可能和最新 Gradle 插件不兼容 gradle -v # 或者看项目里的 gradle wrapper 版本 adb version # Android 调试桥正常如果是 macOS 上做 iOS 编译,还需要确认:
xcodebuild -version pod --version3.4 网络与依赖仓库
KMP 工程会用到 Maven Central 和 Google 的 Maven 仓库,国内网络环境下建议配置镜像加速。在~/.gradle/init.gradle或者项目settings.gradle.kts中配置仓库时,优先选择可达的镜像地址。
4. 移植前的工程评估与架构设计
移植不是把代码文件直接复制到共享模块就完事,最忌讳上来就改 Gradle 配置。先做评估和架构设计,后面会省很多事。
4.1 盘点现有代码
按下面几个维度把现有代码过一遍:
| 代码类别 | 示例 | 移植策略 |
|---|---|---|
| 纯逻辑代码 | 数学计算、字符串处理、校验规则 | 直接放入共享模块 |
| 平台无关的数据模型 | DTO、实体类、枚举 | 直接放入共享模块 |
| 依赖 Android API 的代码 | Context、SharedPreferences、Toast | 包一层接口,用 expect/actual 适配 |
| 依赖 iOS API 的代码 | UserDefaults、Keychain、NSURLSession | 包一层接口,用 expect/actual 适配 |
| 第三方 SDK | Firebase、友盟、微信登录 | 保留在各端,通过接口抽象隔离 |
这一步的目标是把工程分成三层:共享逻辑层、平台适配层、平台应用层。
4.2 确定共享边界
不是所有代码都值得共享。我的建议是优先共享:
- 网络请求和响应解析
- 数据持久化(数据库、Key-Value 存储)
- 登录态管理和 Token 刷新
- 业务状态机和规则引擎
- 埋点数据组装
- 日志输出
- 通用工具类(日期、加密、文件路径处理)
保留在平台层的代码:
- 复杂 UI 组件和页面导航
- 系统能力调用(相机、定位、传感器)
- 推送注册和通知处理
- 支付和账号授权
- 平台特有的动画和渲染
4.3 设计模块结构
建议按 Feature 和 Core 拆模块,而不是把所有共享逻辑都塞进一个大shared模块。模块拆太粗会导致编译增量变慢、多人协作冲突,拆太细又会增加维护成本。
推荐结构:
project-root/ ├── shared/ │ ├── core/ // 基础工具、网络、存储抽象 │ ├── auth/ // 登录注册模块 │ ├── profile/ // 用户信息模块 │ └── game/ // 星球突击队核心玩法逻辑 ├── androidApp/ ├── iosApp/ └── build.gradle.kts4.4 明确 UI 处理方案
如果当前项目已经有完整原生 UI,第一版 KMP 移植不要碰 UI。先把逻辑层共享,UI 继续用原生写,等逻辑稳定了,再评估是否引入 Compose Multiplatform。
如果你的团队是从零开始,项目没有历史包袱,可以考虑直接用 Compose Multiplatform 做一套 UI 覆盖 Android、iOS、桌面端。但要对兼容状态和性能表现做充分测试,尤其是 iOS 端。
5. 项目改造与依赖配置
这一节是实际操作核心。所有配置代码都按常规 Kotlin Multiplatform 工程结构给出,具体包名、模块名要按你的实际项目替换。
5.1 根目录 settings.gradle.kts
pluginManagement { repositories { mavenCentral() google() gradlePluginPortal() } } dependencyResolutionManagement { repositories { mavenCentral() google() } } rootProject.name = "PlanetStriker" include(":shared:core") include(":shared:auth") include(":shared:profile") include(":shared:game") include(":androidApp")5.2 根目录 build.gradle.kts
plugins { kotlin("multiplatform") version "2.0.0" apply false kotlin("android") version "2.0.0" apply false kotlin("plugin.serialization") version "2.0.0" apply false id("com.android.application") version "8.5.0" apply false id("com.android.library") version "8.5.0" apply false }注意版本号要和你本地的 Android Studio、JDK 版本匹配。写死版本不是最优选择,建议用较新的稳定版本。
5.3 共享模块 shared/build.gradle.kts
这是一个典型的多平台模块配置:
plugins { kotlin("multiplatform") kotlin("plugin.serialization") id("com.android.library") } kotlin { androidTarget { compilations.all { kotlinOptions { jvmTarget = "17" } } } listOf( iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "Shared" isStatic = true } } sourceSets { val commonMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") implementation("io.ktor:ktor-client-core:2.3.12") implementation("io.ktor:ktor-client-content-negotiation:2.3.12") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12") } } val commonTest by getting { dependencies { implementation(kotlin("test")) } } val androidMain by getting { dependencies { implementation("io.ktor:ktor-client-okhttp:2.3.12") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5") } } val iosMain by getting { dependencies { implementation("io.ktor:ktor-client-darwin:2.3.12") } } } } android { namespace = "com.example.planetstriker.shared" compileSdk = 35 defaultConfig { minSdk = 24 } }5.4 Android 宿主应用配置
Android 应用模块直接依赖共享模块:
// androidApp/build.gradle.kts dependencies { implementation(project(":shared:game")) implementation(project(":shared:auth")) // 其他 Android 依赖 }5.5 iOS 端集成方式
iOS 端有两种集成方案:
方案一:直接生成 Framework,拖入 Xcode 工程。
方案二:通过 CocoaPods 集成。在shared模块中启用 CocoaPods:
kotlin { cocoapods { summary = "Shared module for PlanetStriker" homepage = "https://example.com" version = "1.0.0" framework { baseName = "Shared" } ios.deploymentTarget = "12.0" } }然后在 iOS 工程的Podfile中添加:
target 'iosApp' do use_frameworks! pod 'Shared', :path => '../shared' end构建命令:
cd shared ./gradlew podspec pod install --project-directory=../iosApp6. 功能测试与效果验证
KMP 移植完成后,不能只验证能不能编译通过,要建立一套完整的验证流程。
6.1 共享逻辑单元测试
共享模块的代码可以直接在 JVM 上跑测试,这是 KMP 比较舒服的一点。
在shared/core/src/commonTest/kotlin/下新建测试文件:
import kotlin.test.Test import kotlin.test.assertEquals class TokenValidatorTest { @Test fun testTokenExpired() { val token = AuthToken( value = "abc123", expiresAt = 1000L, now = { 2000L } ) assertEquals(true, token.isExpired()) } }运行测试:
./gradlew :shared:core:testDebugUnitTest6.2 Android 端功能验证
在 Android 模拟器或真机上验证下面的核心链路:
| 验证项 | 操作步骤 | 预期结果 |
|---|---|---|
| 登录流程 | 输入账号密码,点击登录 | 请求发出、Token 写入本地存储、登录状态更新 |
| 数据缓存 | 断网后重新进入页面 | 共享模块缓存数据可读取 |
| 玩法逻辑 | 执行一局游戏或任务 | 状态机流转正确,比分计算正确 |
| 网络切换 | 从 WiFi 切到 4G/5G | 请求不崩溃,能自动重试或提示 |
6.3 iOS 端功能验证
同一条业务链路在 iOS 端也要完整跑一遍,重点检查:
- 共享 Framework 是否被正确链接。
- 网络库 Ktor Darwin 引擎是否正常发请求。
- UserDefaults 通过 expect/actual 封装后读写是否正常。
- 同一个 Token 在 Android 和 iOS 端互相验证是否通过。
6.4 双端一致性验证
这是最关键的验证:同一个操作在两个端上的业务结果必须一致。
具体做法是准备一份测试用例清单,包含登录、登出、数据刷新、异常处理等场景,在两端执行并对比结果。可以做成离线的数据对比测试:
// 生成测试数据 val input = TestDataFactory.createBattleRecord() val androidResult = processBattleRecord(input) // iOS 端导出相同数据,跑同一种处理逻辑后对比输出一致才说明共享逻辑链路的移植没有偏差。
7. 接口设计与数据层移植
7.1 用 expect/actual 封装平台差异
KMP 不可能把所有平台 API 都抹平,遇到平台差异要靠expect/actual做适配。
在公共模块里声明一个通用的 Token 存储接口:
// shared/core/src/commonMain/kotlin/com/example/core/storage/TokenStorage.kt expect class TokenStorage { fun saveToken(token: String) fun getToken(): String? fun clearToken() }Android 端实现:
// shared/core/src/androidMain/kotlin/com/example/core/storage/TokenStorage.android.kt import android.content.Context actual class TokenStorage(private val context: Context) { private val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) actual fun saveToken(token: String) { prefs.edit().putString("auth_token", token).apply() } actual fun getToken(): String? = prefs.getString("auth_token", null) actual fun clearToken() { prefs.edit().remove("auth_token").apply() } }iOS 端实现:
// shared/core/src/iosMain/kotlin/com/example/core/storage/TokenStorage.ios.kt import platform.Foundation.NSUserDefaults actual class TokenStorage { private val defaults = NSUserDefaults.standardUserDefaults actual fun saveToken(token: String) { defaults.setObject(token, forKey = "auth_token") } actual fun getToken(): String? = defaults.stringForKey("auth_token") actual fun clearToken() { defaults.removeObjectForKey("auth_token") } }这种方式把平台差异收敛到最薄的一层,业务代码不需要知道底层是 SharedPreferences 还是 NSUserDefaults。
7.2 网络层设计
网络层推荐用 Ktor Client,天然支持多平台。核心思路是在 commonMain 中定义 API 接口和 DTO,各平台只需要选择适合自己的引擎。
// shared/core/src/commonMain/kotlin/com/example/core/network/ApiClient.kt import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json class ApiClient(private val baseUrl: String) { private val client = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true isLenient = true }) } } suspend fun login(username: String, password: String): LoginResponse { return client.post("$baseUrl/api/login") { setBody(LoginRequest(username, password)) }.body() } suspend fun fetchBattleData(battleId: String): BattleData { return client.get("$baseUrl/api/battle/$battleId").body() } }Android 端使用 OkHttp 引擎,iOS 端使用 Darwin 引擎,代码逻辑完全一致,只有 Gradle 依赖不同。
7.3 数据库与本地存储
如果业务需要本地数据库,建议使用 SQLDelight 或 Room 的 KMP 支持。SQLDelight 的优势是 SQL 语句在所有平台统一执行,生成类型安全的查询接口。
-- shared/core/src/commonMain/sqldelight/com/example/core/db/Player.sq CREATE TABLE player ( id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, level INTEGER NOT NULL, score INTEGER NOT NULL ); selectAll: SELECT * FROM player; insertPlayer: INSERT OR REPLACE INTO player(id, name, level, score) VALUES (?, ?, ?, ?); updateScore: UPDATE player SET score = ? WHERE id = ?;注意 SQLDelight 的版本和 Kotlin 版本要匹配,否则会出现编译期错误。
7.4 数据模型与序列化
用 kotlinx.serialization 定义跨平台数据模型:
// shared/game/src/commonMain/kotlin/com/example/game/model/BattleRecord.kt @Serializable data class BattleRecord( val battleId: String, val playerId: String, val enemyType: String, val score: Int, val durationSeconds: Int, val victory: Boolean )数据模型的字段命名要保持稳定,避免双端各自反序列化时出现不一致。
8. 常见问题与排查方法
KMP 移植中遇到的坑,大多数集中在依赖配置、链接错误、构建版本不一致和本地存储差异上。整理一份排查清单:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 同步 Gradle 报错 | 仓库源不可达或插件版本不匹配 | 查看gradle.log,检查仓库镜像 | 配置国内镜像,升级或降低插件版本 |
| iOS Framework 生成失败 | Xcode 版本与 Kotlin 版本不兼容 | 运行./gradlew :shared:linkDebugFrameworkIosSimulatorArm64查看详细日志 | 检查 Kotlin 版本和 Xcode 版本匹配表 |
| Kotlin 编译时找不到 expect 的 actual 实现 | actual 声明放错了 source set | 检查androidMain、iosMain目录结构 | 把 actual 实现放到对应平台 source set |
| Android 端 NoClassDefFoundError | 共享模块没有被应用模块依赖 | 查看androidApp/build.gradle.kts依赖配置 | 添加 project 依赖 |
| iOS 端 Undefined symbols | Framework 未正确链接 | 查看 Xcode 的 Link Binary With Libraries | 手动添加 Shared.framework |
| 数据库文件路径不一致 | 各平台默认数据库目录不同 | 打印实际路径 | 在 expect/actual 中显式指定数据库目录 |
| 网络请求在 iOS 上不发送 | 缺少 NSAppTransportSecurity 配置 | 查看 Xcode Info.plist | 添加 ATS 例外或改用 HTTPS |
| 序列化报错 | JSON 字段和 Kotlin 属性不一致 | 打印原始响应 | 在 DTO 上使用@SerialName对齐字段名 |
| 协程调度异常 | 在 Main 线程做耗时操作 | 查看日志中的 Dispatchers 使用 | 正确切换到 Dispatchers.IO |
| URLSession 请求慢 | 多线程并发导致 | 查看网络时序 | 使用 HttpClient 的线程池配置优化 |
8.1 依赖安装失败
KMP 工程涉及 Gradle 插件、Kotlin 插件、Android Gradle Plugin、原生工具链等多层依赖,最容易踩坑的是版本兼容。
处理思路是:以官方最新稳定 KMP 版本为基准,反查 Android Gradle Plugin 和 Kotlin 版本的兼容说明。不要所有依赖都用 latest,也不要混用大版本。
8.2 模型文件或资源文件缺失
共享模块中如果引入了资源文件,需要检查对应 source set 的目录是否正确。放在commonMain/resources下只能用于逻辑初始化,真正的平台资源(图片、音频)仍然建议放在各端自己的资源目录。
8.3 显存或内存不足
如果移植的是游戏相关逻辑,大量场景对象驻留内存时,需要在共享模块里做好对象生命周期管理,及时释放资源引用。各端的虚拟机内存策略不同,不用平台统一标准去衡量,要以两端独立测试为准。
8.4 端口冲突或本地服务访问失败
开发阶段如果共享模块内嵌了本地文件服务或调试端口,注意两个平台端口不能冲突。统一把端口配置放到公共常量里。
8.5 构建产物不稳定
如果出现“改了代码但运行没有变化”的问题,多半是增量编译缓存没有命中。清理构建缓存:
./gradlew clean rm -rf .gradle rm -rf buildmacOS 上还可以清理 Xcode 的 DerivedData 再去重新编译。
9. 最佳实践与使用建议
9.1 分阶段迁移
不要尝试“一夜之间全部迁到 KMP”。建议按下面的节奏走:
- 把无依赖的纯工具类迁到共享模块。
- 引入网络层和数据模型。
- 实现登录、数据缓存等核心链路。
- 再逐步收敛业务逻辑。
- 最后评估 UI 层是否引入 Compose Multiplatform。
每一阶段都要设置“可回退点”,确认没有严重问题再进入下一阶段。
9.2 模块拆分宁细勿粗
虽然模块多了会增加配置量,但从长期维护看,按功能域拆分共享模块会让编译增量更清晰,也让每个人改动的范围更可控。建议至少拆出:
- core(网络、存储、日志、通用工具)
- auth(登录注册)
- user(用户信息)
- battle(战斗玩法逻辑)
- rank(排行榜)
9.3 保留最小可运行配置
建议维护一个“最小 KMP 模板工程”,包含最基础的 Android + iOS 双端构建链路。每次升级 Kotlin 版本、Gradle 版本或 Ktor 版本前,先在这个模板里验证,再对正式项目操作。
9.4 批量化处理编译任务
如果共享模块多,每次验证都要全量编译很耗时。可以只编译改动的模块:
./gradlew :shared:core:compileKotlinAndroid ./gradlew :shared:core:linkDebugFrameworkIosSimulatorArm64CI 中也可以拆成多个 job,分别编译 Android 和 iOS。
9.5 接口设计规范
共享模块的接口要尽量保持平台无关,不要直接暴露 expect 类。对外统一暴露接口加数据模型,内部实现细节不暴露到业务层。
9.6 日志与状态管理
KMP 移植后,两端日志格式要尽量统一。可以自己封装一个基于端口的日志模块:
expect fun logDebug(tag: String, message: String) actual fun logDebug(tag: String, message: String) { // Android 使用 Log.d } actual fun logDebug(tag: String, message: String) { // iOS 使用 NSLog }这样排查问题时,两端日志可以放到同一个聚合系统中分析。
9.7 合规使用提醒
共享模块可能被两个平台同时使用,任何涉及第三方代码的引入都要做许可证检查。如果项目要商用,优先使用 Apache 2.0、MIT 等宽松许可证的库。涉及用户数据、定位信息、通讯录等敏感权限时,两端都要遵循平台隐私规范,不能因为共享逻辑就绕过系统授权。
10. 总结与下一步
Kotlin Multiplatform 移植解决的核心问题是“一份业务逻辑,多端保持一致”。从实践角度看,最值得先做的并不是把整个项目翻成 KMP,而是先把网络层、数据层、状态管理这类和 UI 无关的代码抽取出来,放到共享模块中跑通双端构建链路。
最容易踩的坑有三个:一是 Gradle 多模块依赖配置不兼容,二是 expect/actual 声明放错 source set,三是 iOS 端 Framework 链接失败。这三个问题都会在构建阶段暴露,解决思路分别是先跑通最小模板、严格按目录放置平台代码、检查 Xcode 的链接配置。
下一步要做的验证也很明确:先跑起来最小共享模块,把登录链路拆出来共享一遍,对比 Android 和 iOS 两端的行为差异。只要这一步能稳定落地,后续业务模块迁移就只是工程量的问题。
建议把本文里的最小模板和测试步骤保存下来,等真正做 KMP 移植时按这个顺序走一遍,能省掉不少排查时间。