尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Kotlin Multiplatform实战:Android游戏迁移到iOS的完整方案

Kotlin Multiplatform实战:Android游戏迁移到iOS的完整方案 “星球突击队”这个项目之前是典型的 Android 单端工程逻辑层和界面层全部耦合在 APK 里。当团队提出要覆盖 iOS、桌面端甚至后续要尝试 Web 端时最直接的问题就来了同一套玩法逻辑、关卡数据、排行榜同步难道要为每个平台各写一遍网上关于 Kotlin Multiplatform 的资料不少但大多停留在 Hello World 或简单的工具库示例。真正把一个有状态、有业务流程、有跨平台存储需求的项目拆开、迁移、跑通中间的坑其实比想象中多。这篇文章就以“星球突击队”的移植过程为线索从环境搭建、共享模块设计、expect/actual 机制到 Android 与 iOS 双端集成完整梳理一套可以直接套用的 KMP 移植方案。如果你正准备把一个现成的 Android 项目迁移到 Kotlin Multiplatform或者你只是在评估“KMP 到底能不能支撑真实业务”这篇文章会给你一个比较完整的参考答案。1. 先把概念对齐Kotlin Multiplatform 到底在做什么1.1 什么是 Kotlin MultiplatformKotlin Multiplatform简称 KMP是 JetBrains 推出的一种跨平台技术方案。它并不是把 UI 一次性跨端渲染而是把“业务逻辑”抽出来在多个平台之间共享UI 部分仍然由各平台原生实现。放到“星球突击队”这个场景里就是共享层shared负责游戏状态管理、关卡进度、玩家积分、存档读写、网络请求等。Android 层使用 Jetpack Compose 或传统 View 体系读取共享层数据并渲染界面。iOS 层使用 SwiftUI 或 UIKit通过 Kotlin/Native 生成的 framework 调用共享层逻辑。桌面端可选使用 Compose Multiplatform 复用 UI。这样的好处并不是“一套代码到处跑”而是“一套逻辑多处复用”。对于游戏类项目规则、状态、算法是最容易沉淀的部分先把它们抽出来收益最直接。1.2 移植前后有什么变化移植前项目结构大概是这样的StarRaid/ ├── app/src/main/java/com/example/starraid/ │ ├── ui/ // Activity、Fragment、Compose │ ├── model/ // 数据模型 │ ├── repository/ // 仓库层 │ ├── network/ // 网络请求 │ └── utils/ // 工具类移植后我们希望把中间的数据与逻辑部分挪进 shared 模块StarRaid/ ├── shared/ │ └── src/ │ ├── commonMain/ // 共享代码 │ ├── androidMain/ // Android 平台实现 │ └── iosMain/ // iOS 平台实现 ├── androidApp/ // Android 壳工程 └── iosApp/ // iOS 壳工程从这张结构对比里能看出来KMP 移植的核心工作不是重写代码而是“识别哪些代码可以共享哪些代码必须留在平台层”。1.3 哪些代码适合放进 commonMain“星球突击队”里适合共享的典型内容游戏核心状态当前关卡、生命值、得分、弹药数量。玩法规则关卡判定、碰撞检测逻辑、奖励计算。数据模型玩家信息、关卡配置、排行榜条目。存储接口本地存档、设置项。网络接口排行榜上报、登录 Token 刷新。不适合共享的内容具体 UI 控件Android 的 View / Compose、iOS 的 SwiftUI。平台传感器震动反馈需要分别适配。系统级能力推送、定位、内存警告等。简单判断标准如果这段代码不依赖 android.* 或 UIKit并且描述的是业务本身那就有迁移价值。2. 环境准备在开始动手前要装好哪些东西KMP 开发环境比普通 Android 开发环境多了一些组成部分。下面按“最小可用”来列。2.1 工具清单工具用途说明JDKGradle 与 Kotlin 编译推荐使用项目现有的 JDK 版本KMP 对 JDK 没有额外特殊要求Android StudioAndroid 端开发与调试建议保持在较新版本Kotlin 插件通常随 IDE 更新XcodeiOS 端编译与调试只有需要构建 iOS 目标时才必须Kotlin 插件支持 KMP 工程识别Android Studio 内置命令行构建时由 Gradle 插件提供CocoaPods可选iOS 集成共享 framework也可以使用直接生成 framework 的方式避开 pod 流程这里要注意具体版本号更新很快不要盲目照搬别人的版本。工程是团队协作的话建议统一 JDK、Gradle、Kotlin 插件版本避免本机能跑、别人拉下来就编译失败的情况。2.2 验证环境是否就绪在新建 KMP 工程之前可以先确认几个关键命令行工具存在java -version kotlin -version gradle -version如果你在 Android Studio 中开发还可以打开 SDK Manager确认已安装所需的 Android SDK 平台版本。KMP 本身对“新”的要求不是绝对的但依赖版本差距过大会带来很多不容易排查的问题因此这里优先推荐一个原则使用与你的 Gradle、Kotlin 版本兼容的 KMP 插件版本不要混用新旧大版本。对于 iOS 构建可以在终端检查xcodebuild -version如果没有安装 Xcode仍然可以先开发 Android 端iOS 相关源码可以保留但暂不参与编译。不过更好的做法是在工程配置阶段就把 iOS 目标加上否则之后再补会比较麻烦。2.3 版本策略建议Kotlin Multiplatform 是一个生态联动性很强的技术栈。Kotlin 版本升级后相应 Android Gradle 插件、Compose Multiplatform 插件、Ktor、kotlinx.serialization 等库都需要一起兼容。因此项目刚起步时选择一个稳定的 Kotlin 版本以它为中心确定其他依赖版本。不要频繁升级 Kotlin 版本除非你确实需要新特性。记录原有 Android 工程的依赖集合逐个检查是否有 KMP 版本的替代方案。以“星球突击队”为例我们首先需要保证现有 Android 工程可以正常编译打包然后再引入 shared 模块而不是直接大改。这样的渐进式迁移出问题时容易定位。3. 核心配置拆解一个最小的 KMP 共享模块在动手迁业务代码之前先搭一个能跑的 shared 模块。下面的配置是 KMP 工程的骨架后面所有共享代码都会放在这个模块里。3.1 工程级配置先看根目录的settings.gradle.ktspluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { google() mavenCentral() } } rootProject.name StarRaid include(:shared) include(:androidApp)这里把shared和androidApp两个模块显式包含进来。iOS 工程通常不通过 Gradle 管理而是通过 Xcode 工程引用 shared 生成的 framework。3.2 根目录依赖声明在根目录的build.gradle.kts中声明插件版本但不直接应用plugins { id(org.jetbrains.kotlin.multiplatform) version 2.0.0 apply false id(com.android.library) version 8.5.0 apply false id(org.jetbrains.kotlin.android) version 2.0.0 apply false id(org.jetbrains.kotlin.plugin.serialization) version 2.0.0 apply false }注意这里的版本号只是示例具体版本需要根据你当前的开发环境来定。如果你原本的 Android 工程使用 Kotlin 1.9.x直接升级到 2.x 可能会引发其他依赖的兼容问题。最稳妥的做法是先新建一个分支只升级必要插件跑通后再继续。3.3 shared 模块的 KMP 配置shared/build.gradle.kts是核心文件它定义了共享模块支持哪些平台目标import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id(org.jetbrains.kotlin.multiplatform) id(com.android.library) id(org.jetbrains.kotlin.plugin.serialization) } kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } 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) } } val androidMain by getting { dependencies { implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4) } } val iosMain by getting { } } } android { namespace com.starraid.shared compileSdk 34 defaultConfig { minSdk 24 } compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } }这里有几个关键点需要展开讲。第一androidTarget表示共享代码可以编译成 Android Library。iOS 端的iosX64、iosArm64、iosSimulatorArm64分别对应模拟器、真机、Apple Silicon 模拟器三种架构。三个都写上是标准做法。第二isStatic true表示生成静态 framework。静态 framework 在集成到 iOS 工程时更简单不需要在 App 启动时手动加载动态库。第三commonMain是所有平台共享代码的源集目录androidMain和iosMain分别存放平台相关实现。第四android块是 Android Library 的常规配置。namespace是共享包名compileSdk、minSdk要根据现有项目调整。3.4 理解 expect / actual 机制KMP 里最容易迷惑新人的就是expect和actual。用一句话解释expect在公共代码里声明“我要什么”actual在各个平台代码里分别实现“我给什么”。比如我们希望知道当前运行平台的名称// 文件shared/src/commonMain/kotlin/com/starraid/shared/Platform.kt package com.starraid.shared expect fun platformName(): String然后在 Android 端实现// 文件shared/src/androidMain/kotlin/com/starraid/shared/Platform.android.kt package com.starraid.shared actual fun platformName(): String { return Android }iOS 端实现// 文件shared/src/iosMain/kotlin/com/starraid/shared/Platform.ios.kt package com.starraid.shared actual fun platformName(): String { return iOS }注意两点expect声明所在的包名必须与actual实现所在的包名一致。编译器要求每个声明的expect在所有已启用平台目标上都有对应actual否则编译报错。在实际项目中expect / actual主要用于以下场景获取当前平台信息。实现平台相关的数据存储。获取系统权限状态。使用平台特有的加密 API。“星球突击队”的存档功能就是一个典型例子。我们可以在commonMain定义接口用expect / actual在 Android 使用 SharedPreferences在 iOS 使用 NSUserDefaults。4. 星球突击队移植实战从单端到共享4.1 确定移植范围“星球突击队”是一个以太空背景为核心的街机射击类小游戏。我们把它拆成五个核心领域领域内容是否共享游戏状态关卡、生命值、得分、敌机列表是玩法规则发射子弹、碰撞检测、敌机移动是存档玩家最高分、解锁关卡是排行榜接口上报分数、拉取排行是UI开始界面、战斗界面、结算界面否UI 部分不放进共享层。这样团队里负责 iOS 的同事可以继续使用 SwiftUI负责 Android 的同事可以继续使用 Compose共享层只负责“数据从哪里来、状态怎么变”。4.2 定义共享层的数据模型在开始移植业务逻辑之前先把数据模型定义出来。这些模型会同时用于 Android、iOS 和网络接口。// 文件shared/src/commonMain/kotlin/com/starraid/shared/model/GameModels.kt package com.starraid.shared.model import kotlinx.serialization.Serializable Serializable data class PlayerState( val score: Int 0, val lives: Int 3, val level: Int 1, val isGameOver: Boolean false ) Serializable data class EnemyShip( val id: Int, val positionX: Float, val positionY: Float, val speed: Float, val hp: Int ) Serializable data class RankItem( val playerName: String, val score: Int, val timestamp: Long )这里使用了kotlinx.serialization它让数据模型在需要序列化时不需要额外写解析代码。对于跨平台场景这个库很实用因为网络请求或本地存档都要求对象能方便地转换为 JSON。4.3 设计游戏状态管理游戏状态管理采用 ViewModel 模式但这里的 ViewModel 是共享层的不依赖 Android 的androidx.lifecycle.ViewModel。我们用纯 Kotlin 类加StateFlow来实现。// 文件shared/src/commonMain/kotlin/com/starraid/shared/GameViewModel.kt package com.starraid.shared import com.starraid.shared.model.PlayerState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class GameViewModel( private val storage: SaveStorage ) { private val _playerState MutableStateFlow( storage.load() ?: PlayerState() ) val playerState: StateFlowPlayerState _playerState.asStateFlow() fun startNewGame() { _playerState.value PlayerState() storage.save(_playerState.value) } fun addScore(points: Int) { _playerState.value _playerState.value.copy( score _playerState.value.score points ) storage.save(_playerState.value) } fun loseLife() { val current _playerState.value val newLives current.lives - 1 _playerState.value if (newLives 0) { current.copy(lives 0, isGameOver true) } else { current.copy(lives newLives) } storage.save(_playerState.value) } fun nextLevel() { _playerState.value _playerState.value.copy( level _playerState.value.level 1 ) storage.save(_playerState.value) } }这个类的特点是不直接操作任何 UI。只通过StateFlow对外暴露状态。状态变化时自动存档。Android 和 iOS 通过订阅StateFlow获取最新玩家数据。UI 层可以这样订阅状态Android在 Compose 中使用collectAsState()。iOS通过 Kotlin/Native 生成的 framework 暴露的StateFlow结合 Swift 的AsyncStream或Observable包装。4.4 定义存档接口SaveStorage需要跨平台实现。我们先在 commonMain 里定义接口// 文件shared/src/commonMain/kotlin/com/starraid/shared/SaveStorage.kt package com.starraid.shared import com.starraid.shared.model.PlayerState interface SaveStorage { fun save(state: PlayerState) fun load(): PlayerState? }然后分别实现。Android 端用 SharedPreferences// 文件shared/src/androidMain/kotlin/com/starraid/shared/SaveStorage.android.kt package com.starraid.shared import android.content.Context import com.starraid.shared.model.PlayerState import kotlinx.serialization.json.Json class AndroidSaveStorage( context: Context ) : SaveStorage { private val prefs context.getSharedPreferences(star_raid_save, Context.MODE_PRIVATE) private val json Json { ignoreUnknownKeys true } override fun save(state: PlayerState) { prefs.edit() .putString(player_state, json.encodeToString(PlayerState.serializer(), state)) .apply() } override fun load(): PlayerState? { val raw prefs.getString(player_state, null) ?: return null return json.decodeFromString(PlayerState.serializer(), raw) } }iOS 端用 NSUserDefaults// 文件shared/src/iosMain/kotlin/com/starraid/shared/SaveStorage.ios.kt package com.starraid.shared import com.starraid.shared.model.PlayerState import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.serialization.json.Json import platform.Foundation.NSUserDefaults class IosSaveStorage( private val defaults: NSUserDefaults NSUserDefaults.standardUserDefaults ) : SaveStorage { private val json Json { ignoreUnknownKeys true } override fun save(state: PlayerState) { val encoded json.encodeToString(PlayerState.serializer(), state) defaults.setObject(encoded, forKey star_raid_save) } override fun load(): PlayerState? { val raw defaults.stringForKey(star_raid_save) ?: return null return json.decodeFromString(PlayerState.serializer(), raw) } }这里的思路是接口是共享的实现是平台私有的。未来如果要做桌面端只需要再写一个 JVM 或 Native 的实现即可。4.5 模拟一局游戏的核心逻辑为了让示例更完整下面写一个简单的“发射子弹”和“敌机碰撞”逻辑。这部分不依赖任何平台 API完全在 commonMain 里。// 文件shared/src/commonMain/kotlin/com/starraid/shared/GameEngine.kt package com.starraid.shared import com.starraid.shared.model.EnemyShip import kotlin.math.abs class GameEngine { private val enemies mutableListOfEnemyShip() fun spawnEnemy(id: Int, startX: Float, startY: Float, speed: Float, hp: Int) { enemies.add( EnemyShip( id id, positionX startX, positionY startY, speed speed, hp hp ) ) } fun moveEnemies() { for (i in enemies.indices) { val enemy enemies[i] enemies[i] enemy.copy( positionY enemy.positionY enemy.speed ) } } /** * 检测子弹是否命中敌机 * param bulletX 子弹 X 坐标 * param bulletY 子弹 Y 坐标 * param hitRadius 命中判定半径 * return 命中敌方返回 true否则返回 false */ fun checkBulletHit(bulletX: Float, bulletY: Float, hitRadius: Float): Boolean { val iterator enemies.iterator() while (iterator.hasNext()) { val enemy iterator.next() val dx abs(bulletX - enemy.positionX) val dy abs(bulletY - enemy.positionY) if (dx hitRadius dy hitRadius) { iterator.remove() return true } } return false } fun remainingEnemies(): Int enemies.size }这个GameEngine代表了一部分游戏玩法逻辑。它不关心界面不关心如何在 Android 上绘制敌机也不关心 iOS 的 SpriteKit 或 Metal。它只负责抽象的游戏世界规律。这就是 KMP 移植的最大收益当这些规则被抽出来共享后Android 和 iOS 的 UI 工程师可以各自专注于绘制和交互而不需要重复实现关卡与碰撞规则。4.6 Android 端集成共享层Android 端的集成方式很简单在androidApp模块中依赖 shared 模块。在androidApp/build.gradle.kts中plugins { id(com.android.application) id(org.jetbrains.kotlin.android) id(org.jetbrains.kotlin.plugin.compose) } android { namespace com.starraid.android compileSdk 34 defaultConfig { applicationId com.starraid.android minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } buildFeatures { compose true } } dependencies { implementation(project(:shared)) implementation(androidx.compose.ui:ui:1.7.0) implementation(androidx.compose.material3:material3:1.3.0) implementation(androidx.lifecycle:lifecycle-runtime-ktx:2.8.4) implementation(androidx.activity:activity-compose:1.9.0) }然后在 Compose 界面中订阅共享状态// 文件androidApp/src/main/kotlin/com/starraid/android/MainActivity.kt package com.starraid.android import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.starraid.shared.GameViewModel import com.starraid.shared.AndroidSaveStorage class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val storage AndroidSaveStorage(applicationContext) val viewModel GameViewModel(storage) setContent { val state by viewModel.playerState.collectAsState() Column( modifier Modifier .fillMaxSize() .padding(24.dp) ) { Text(得分${state.score}) Text(生命${state.lives}) Text(关卡${state.level}) Button(onClick { viewModel.addScore(10) }) { Text(击中敌机 10) } Button(onClick { viewModel.loseLife() }) { Text(受伤) } } } } }这里的collectAsState()来自 Compose把共享层的StateFlow映射成 Compose 状态。界面点击按钮时实际上是调用了共享层的GameViewModel状态更新后又会自动驱动 UI 刷新。4.7 iOS 端集成共享层iOS 端集成 KMP 的方式有两种直接使用 Gradle 生成 framework然后在 Xcode 工程中引用。使用 CocoaPods 集成。这里推荐直接用 framework因为“星球突击队”的 iOS 工程相对独立不希望引入额外依赖管理工具。在 shared 模块中执行./gradlew :shared:linkDebugFrameworkIosSimulatorArm64这个命令会生成一个Shared.framework路径通常在shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework把Shared.framework拖入 Xcode 工程然后在Build Settings的Framework Search Paths中填入对应目录。Swift 调用示例// 文件iosApp/StarRaid/ContentView.swift import SwiftUI import Shared MainActor final class GameStore: ObservableObject { Published var score: Int 0 Published var lives: Int 0 Published var level: Int 0 private let viewModel: GameViewModel private var observation: TaskVoid, Never? init() { let storage IosSaveStorage() self.viewModel GameViewModel(storage: storage) observeState() } private func observeState() { observation Task { for await state in viewModel.playerState { score Int(truncating: state.score) lives Int(truncating: state.lives) level Int(truncating: state.level) } } } func onHitEnemy() { viewModel.addScore(points: 10) } func onDamaged() { viewModel.loseLife() } } struct ContentView: View { StateObject private var store GameStore() var body: some View { VStack(spacing: 20) { Text(得分\(store.score)) Text(生命\(store.lives)) Text(关卡\(store.level)) Button(击中敌机 10) { store.onHitEnemy() } Button(受伤) { store.onDamaged() } } .padding() } }注意这里有两个易错点。第一Kotlin 的Int映射到 Swift 后需要通过Int(truncating:)转换因为 Kotlin/Native 在 Swift 中暴露的是KotlinInt类型它并不能直接作为 Swift 的Int使用。第二StateFlow在 Swift 中迭代时需要 Task 来启动一个异步循环。for await state in viewModel.playerState这种写法在 Swift 5.5 以上是支持的。5. 运行与验证5.1 Android 端运行打开androidApp模块选择模拟器或真机点击 Run。预期输出就是界面上的三个文本和一个按钮。点击“击中敌机 10”得分递增点击“受伤”生命减少。杀掉进程重新打开得分和生命会从上次状态恢复这就是存档逻辑在起作用。5.2 iOS 端运行在 Xcode 中打开 iosApp 工程选择模拟器运行。如果遇到 framework 路径找不到检查两项Framework Search Paths 是否指向shared/build/bin/iosSimulatorArm64/debugFramework。是否选择了正确的模拟器架构。Apple Silicon 使用iosSimulatorArm64Intel Mac 使用iosX64。5.3 验证共享逻辑一致性这是移植后最重要的一步。在 Android 和 iOS 上分别执行相同操作序列例如开始新游戏 - 加 30 分 - 受伤一次 - 进入下一关 - 杀掉进程 - 重新打开如果两端恢复后的状态一致说明共享层逻辑没有因为平台差异而出现偏差。这里有一个隐蔽但常见的问题Android 的 SharedPreferences 是异步落盘的而 iOS 的 NSUserDefaults 也是延迟写盘。如果测试时立即杀掉进程可能来不及写入。生产代码里最好在onPause、onStop或 App 进入后台时主动触发一次 flush 操作但 demo 阶段可以忽略这个问题。6. 常见问题与排查思路KMP 工程配置复杂编译问题多下面列几个高频问题。问题现象常见原因解决思路expect声明没有对应actual平台源集目录名写错或实际实现未加actual关键字检查androidMain、iosMain目录是否存在包名是否一致iOS framework 生成失败Xcode 版本与 Kotlin/Native 版本不兼容查看 Kotlin 版本对 Xcode 的支持要求必要时升级 Kotlin 或 XcodeSwift 调用 KMP 方法时找不到符号framework 不是最新版本重新执行linkDebugFramework或linkReleaseFrameworkkotlinx.coroutines依赖冲突Android 工程已有旧版本协程统一依赖版本移除传传递依赖中的重复版本编译速度极慢Kotlin/Native 首次编译需要下载依赖网络环境导致下载慢建议配置镜像或预下载依赖iOS 模拟器与真机 framework 混用不同架构的 framework 文件不同为模拟器和真机分别生成并选择对应 framework6.1 Kotlin/Native 编译慢问题Kotlin/Native 编译一个空工程通常也要几十秒到几分钟因为需要调用 LLVM 等底层工具链。首次构建会更慢。这不是代码问题也不是环境问题。建议的优化方案不要频繁 clean。尽量复用 Gradle 缓存。在 CI 中缓存~/.konan目录。调试阶段只链接一个目标架构例如只生成iosSimulatorArm64不要同时生成三个。6.2 Compose 与 KMP 的关系很多人把 Kotlin Multiplatform 和 Compose Multiplatform 搞混。这里有必要再强调一次Kotlin Multiplatform 负责共享逻辑不负责 UI。Compose Multiplatform 是 JetBrains 在 KMP 基础上做的 UI 框架可以让你同时写 Android、iOS、Desktop 的界面。“星球突击队”目前的方案是“KMP 共享逻辑 原生 UI”这是最稳妥、风险最低的过渡方案。如果团队后续希望进一步减少 iOS UI 开发量可以评估引入 Compose Multiplatform但那是另一个层面的决策不应在第一次移植时混在一起。6.3 遇到编译错误时怎么办KMP 编译错误信息通常比较长建议按下面的顺序排查先看是 Kotlin 编译错误还是 Gradle 配置错误。如果是 Kotlin 编译错误优先看 Error 前面第几行定位到具体文件。检查源集目录是否正确commonMain、androidMain、iosMain、iosTest等。检查expect / actual声明声明签名必须完全一致包括参数类型、返回值、可见性。检查共享依赖是否只写在commonMain里有没有误用到平台专属 API。6.4 Android 与 iOS 状态不一致如果两端状态不一致优先检查存档接口而不是游戏逻辑。因为GameViewModel是同一份代码极少出现逻辑分叉。存档不一致的常见原因Android 使用的是applicationContext而 iOS 使用的是 shared defaults。键名不一致Android 用star_raid_saveiOS 也用这个写一个常量别两边各写各的。序列化格式版本不一致新增字段时ignoreUnknownKeys能解决向后兼容但不能解决向前兼容老版本会读不了新格式。建议在共享层定义一个常量// commonMain const val SAVE_KEY star_raid_save然后两个平台实现都引用这个常量避免字符串写死。7. 最佳实践与工程建议7.1 先抽接口再动实现KMP 移植最忌讳上来就写代码。建议先画一张依赖图把“哪些类依赖 android.*”“哪些类依赖 UIKit”标出来。对“星球突击队”来说最终依赖方向应该是UI (Android Compose / iOS SwiftUI) ↓ 调用 共享 ViewModel 与 GameEngine ↓ 调用 共享接口 (SaveStorage) ↓ 实现 平台层 (Android / iOS)依赖方向必须是单向的。UI 可以依赖共享层共享层不能反向依赖 UI。如果发现共享层代码要 importandroidx.lifecycle.ViewModel说明设计已经越界了需要把ViewModel替换成纯 Kotlin 类。7.2 严格定义 expect / actual 的边界expect / actual用多了会降低代码可读性也会增加编译成本。一个社区里比较认可的原则是能用普通接口和多态解决的就不要用 expect / actual。例如存档功能表面上是“平台差异”但它其实可以抽象成一个接口SaveStorage不同平台传入不同实现并不一定需要expect / actual。只有当外部工具类、系统 API 无法用普通接口封装时再考虑 expect / actual。7.3 注意线程模型Kotlin/Native 的内存模型与 Android JVM 不一样。在新版 Kotlin/Native 中默认启用了新的内存管理器对象可以跨线程共享这比旧版方便很多。但依然有一些注意事项StateFlow和MutableStateFlow是线程安全的。不要在多个线程中同时调用同一个可变对象的方法而不加锁除非该对象本身就是线程安全的。iOS 端通过 framework 调用 KMP 时默认互操作线程是主线程耗时操作要放到协程里。7.4 渐进式迁移如果你的项目已经跑了很久不要试图一次性把整个工程搬到 KMP。建议按这个步骤推进新建 shared 模块加入工程并跑通空编译。把纯数据模型迁移到共享层确认 Android 编译通过。把网络层迁移到共享层。把状态管理迁移到共享层。创建一个最小的 iOS 工程接入 framework。逐步从 Android 端把业务逻辑删掉改为调用 shared。每一步都保持“可编译、可运行、可回滚”。这样如果某个环节出了问题影响范围是可控的。7.5 用 Gradle 隔离环境差异团队成员可能分别使用 Windows、macOS、Linux。KMP 工程在 Windows 上无法编译 iOS target但可以编译 Android target。因此建议在 CI 分成两个 JobJob 1在 Linux 编译 Android 目标。Job 2在 macOS 编译 iOS 目标。通过 Gradle 的tasks选择而不是让每个开发者在本地构建所有目标。8. 总结这次“星球突击队”的 KMP 移植整体路径可以概括成四步识别可共享的领域游戏状态、玩法规则、数据模型、存档接口。建立 shared 模块配置 commonMain、androidMain、iosMain。用接口沉淀平台差异存档分别用 SharedPreferences 和 NSUserDefaults。分别集成到 Android 与 iOSAndroid 用 Compose 订阅 StateFlowiOS 用 SwiftUI 对 framework 做桥接。移植完成后Android 和 iOS 的游戏规则、得分计算、关卡进度保存逻辑都复用同一份 Kotlin 代码。以后如果规则调整比如“击中敌机得分从 10 改为 15”只需要改一次两端同步生效。下一步如果你想继续深入建议按顺序学习这些主题Kotlin 协程在共享层的最佳实践。Ktor 客户端做跨平台网络请求。SQLDelight 做跨平台数据库存储。Compose Multiplatform 迁移 UI 层。为 shared 模块编写单元测试用kotlin.test在 JVM 上跑通后再验证 iOS。KMP 并不是一个能解决所有跨平台痛点的银弹但如果你手里正好有一个逻辑复杂、平台版本割裂的项目“星球突击队”这条移植路线是有参考价值的。建议你先在一个分支上按上面的步骤走一遍遇到问题再对照常见问题清单排查会比直接改线上代码稳妥得多。
返回列表