
把一款老游戏或者小游戏移植到 Kotlin Multiplatform听起来像是在“移动代码”但实际做起来更像是在做一次架构重写。Kotlin Multiplatform 移植的核心并不是把代码从单平台复制到 commonMain而是先回答一个问题哪些逻辑属于业务规则哪些逻辑必须依赖操作系统。这篇文章以“星球突击队”这个太空射击游戏项目为例介绍完整的移植路径包括共享层设计、平台差异隔离、Compose Multiplatform 界面接入、Android 与 iOS 入口处理、运行验证和常见问题排查。“星球突击队”是一个典型的街机风格射击游戏玩家控制飞船左右移动发射子弹击落从屏幕上方出现的敌机消灭一波后再刷新下一波。它的核心玩法并不复杂但包含状态机、实体管理、碰撞检测、计分规则和逐帧更新逻辑适合用来演示 KMP 的边界划分。文章中会给出可运行的最小工程结构、构建配置和核心代码也会说明哪些地方容易踩坑尤其是 Kotlin/Native 编译、Compose Multiplatform 版本矩阵和 iOS 模拟器调试这几类问题。1. 移植前先想清楚KMP 应该共享什么不共享什么很多项目第一次接触 Kotlin Multiplatform会倾向于把 UI 也全部塞进共享模块结果进入平台适配地狱。其实 KMP 的价值不是“一份代码跑所有平台”而是“一套业务规则在多端保持一致”。移植“星球突击队”之前先明确哪些内容进入 commonMain哪些内容留在各自平台后面会省掉大量返工。1.1 “星球突击队”类游戏的技术构成一个太空射击游戏可以拆成四个层级游戏状态当前是准备中、运行中、暂停还是结束当前波次、得分、玩家生命值。实体和规则玩家飞船、敌机、子弹的位置、速度、尺寸、存活状态以及碰撞判定、得分规则、敌机生成策略。渲染与交互把实体绘制到屏幕上监听键盘、触摸或手柄输入。平台能力窗口尺寸、触控事件、生命周期、日志、文件存储、音频播放。前两个层级几乎是纯计算不依赖操作系统适合放进 commonMain。第三个层级可以共享一大部分如果用 Compose Multiplatform 来绘制界面和接收 PointerInput一套 UI 代码可以在 Android、iOS 和桌面端运行。第四个层级则必须用 expect/actual 或者依赖注入来隔离。这一层划分的意义是共享部分可以写单元测试可以在 JVM 上快速验证规则能减少两端行为不一致的问题。平台部分只保留“系统能力提供者”这一角色。1.2 KMP 的责任边界共享业务规则保留平台壳Kotlin Multiplatform 移植的典型职责分配是模块层职责范围示例commonMain纯 Kotlin 业务逻辑、共享 UI 描述GameState、GameEngine、GameScreenandroidMainAndroid 入口、系统能力适配MainActivity、触控到输入事件的转换iosMainiOS 入口、系统能力适配MainViewController、时间戳和随机源适配各平台 App 壳打包、权限、启动配置AndroidManifest、Info.plist、Xcode 工程不要把平台 API 泄漏进 commonMain。例如java.util.Random、System.currentTimeMillis()这类 API 在 Android 可用但在 iOS 的 Kotlin/Native 目标里不存在。碰到这种情况要使用expect声明接口再在各个平台提供actual实现。反过来平台壳也不要维护游戏规则。Android 的 Activity 只负责创建引擎并把它传给 ComposableiOS 的 ViewController 同样只负责创建根视图。规则变化只改 commonMain平台壳尽量保持稳定。1.3 项目形式选择Compose Multiplatform 还是共享逻辑 原生 UI同一款游戏有两种移植方式。第一种是只共享业务逻辑Android 用 Compose 或 View 绘制iOS 用 SwiftUI 或 SpriteKit 绘制。第二种是业务逻辑和 UI 都放在 commonMain使用 Compose Multiplatform 统一渲染。“星球突击队”采用第二种更合适。原因是这个游戏的界面没有复杂的原生控件主要是 Canvas 绘制和输入事件监听Compose Multiplatform 已经覆盖这些能力。如果项目需要大量使用平台原生导航、系统组件或复杂手势才需要考虑第一种方案。用共享 UI 的代价是必须接受 Compose Multiplatform 的版本约束。Kotlin、Compose Multiplatform、Android Gradle Plugin 之间存在版本矩阵关系不能随意搭配。后面环境准备章节会给出检查和锁定版本的方法。2. 环境与版本KMP 移植最容易在版本矩阵上翻车Kotlin Multiplatform 移植的第一步不是写代码而是确认本机工具链和依赖版本。KMP 构建涉及 Kotlin 编译器、Android SDK、iOS SDK、Compose 编译器插件和 Gradle 插件任何一层版本错位都会出现难以理解的报错。2.1 学习环境需要的最小工具链一个能运行到 Android 和 iOS 的最小环境至少需要工具作用说明JDK 17 或更高运行 Gradle 和 Kotlin 编译建议使用官方 LTS 版本Android StudioAndroid 构建和模拟器也常被用来管理 KMP 工程Android SDKAndroid 编译目标通过 SDK Manager 安装XcodeiOS 编译和模拟器需要 Command Line ToolsKotlin Multiplatform 插件创建和构建 KMP 工程通过 IDE 插件市场安装模拟器或真机运行验证至少准备一个 Android 模拟器和一个 iOS 模拟器如果你只做学习验证Android Studio 可以在同一台机器上完成 Android 和桌面目标iOS 目标必须依赖 macOS 和 Xcode。如果本机不是 macOS可以先跑通 Android 和桌面目标iOS 部分放到后续 Mac 环境验证。注意KMP 构建会从 Maven Central 和 Google 仓库下载依赖首次构建耗时较长需要稳定的网络环境。可以把 Gradle 的org.gradle.jvmargs内存调大一点避免大型项目编译时 OOM。2.2 版本矩阵检查清单KMP 的构建链路包含多个互相依赖的版本。以示例工程为例常见版本组合如下组件推荐版本说明Kotlin2.1.0高于 2.0 时 Compose 编译器跟随 Kotlin 独立发布Compose Multiplatform1.7.3需要和 Kotlin 版本匹配Android Gradle Plugin8.5.2受 Android Studio 版本支持范围约束Gradle8.9 左右与 AGP 版本联动compileSdk / targetSdk35需要本机 Android SDK 包含该 API 级别iOS deployment target13.0 以上根据实际业务决定这些版本并不是固定不变的。官方每个季度会更新版本矩阵所以工程落地前要打开对应的官方兼容性页面确认。最稳妥的做法是先创建一个官方模板工程用模板锁定的版本作为起点再按项目需要调整。如果依赖版本不匹配常见的报错包括Compose compiler 与 Kotlin 版本不兼容This version of the Compose Compiler requires Kotlin version X.Y.Z but you appear to be using A.B.CAGP 与 Gradle 不兼容Minimum supported Gradle version is X.Y.ZKotlin/Native 与 Xcode 不兼容链接阶段出现Unsupported Xcode version或平台库缺失2.3 创建 KMP 项目的两种方式最稳妥的方式是使用 JetBrains 的 KMP 网页模板向导生成项目骨架然后导入 Android Studio。这样会自动生成正确的settings.gradle.kts、build.gradle.kts和libs.versions.toml。也可以手动创建。下面给出一个最简化的项目结构StarSquadKMP/ ├── build.gradle.kts ├── settings.gradle.kts ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ └── gradle-wrapper.properties ├── composeApp/ │ ├── build.gradle.kts │ └── src/ │ ├── commonMain/kotlin/com/example/starsquad/ │ ├── androidMain/kotlin/com/example/starsquad/ │ └── iosMain/kotlin/com/example/starsquad/根目录的settings.gradle.kts需要配置插件仓库和模块pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { google() mavenCentral() } } rootProject.name StarSquadKMP include(:composeApp)版本目录gradle/libs.versions.toml集中管理依赖版本减少后续升级成本[versions] kotlin 2.1.0 composeMultiplatform 1.7.3 agp 8.5.2 androidx-activity 1.9.3 [libraries] androidx-activity-compose { module androidx.activity:activity-compose, version.ref androidx-activity } [plugins] kotlinMultiplatform { id org.jetbrains.kotlin.multiplatform, version.ref kotlin } composeMultiplatform { id org.jetbrains.compose, version.ref composeMultiplatform } composeCompiler { id org.jetbrains.kotlin.plugin.compose, version.ref kotlin } androidApplication { id com.android.application, version.ref agp }这种写法有两个好处依赖版本集中管理多个模块之间不会出现版本漂移升级 Kotlin 或 Compose 时只需要改libs.versions.toml不用在多个 build 文件里搜索替换。3. 在 commonMain 里实现游戏核心逻辑核心逻辑是这次移植的主干。它不依赖界面不依赖平台 API只依赖 Kotlin 标准库。这样设计之后游戏规则可以在 JVM 上单测可以运行在 Android、iOS 和桌面端行为保持一致。3.1 用数据类描述实体、状态和输入先用不可变数据类描述游戏世界。这样写的好处是状态变化一目了然也方便测试快照对比。package com.example.starsquad enum class GameStatus { Ready, Playing, Paused, GameOver } data class Vec2( val x: Float, val y: Float ) data class Entity( val id: Int, val position: Vec2, val speed: Vec2 Vec2(0f, 0f), val size: Float 16f, val alive: Boolean true ) data class GameState( val status: GameStatus GameStatus.Ready, val player: Entity Entity( id PLAYER_ID, position Vec2(0f, 0f), size 28f ), val enemies: ListEntity emptyList(), val bullets: ListEntity emptyList(), val score: Int 0, val wave: Int 1, val elapsedTimeMs: Long 0L ) const val PLAYER_ID 0GameState是纯数据对象不包含任何逻辑。GameEngine负责根据输入和时间推进它保证“状态是数据、逻辑是函数”的职责分离。对于一个小型街机游戏这种写法比把所有字段散落在可变对象中更容易维护。3.2 游戏循环状态更新与时间步长游戏循环不需要自己创建线程。Compose 的withFrameNanos或平台消息循环会在每一帧回调每次回调传入一个时间增量。GameEngine根据时间增量推进逻辑。class GameEngine( private val fieldWidth: Float 800f, private val fieldHeight: Float 1000f ) { var state GameState() private set fun start() { state state.copy( status GameStatus.Playing, player state.player.copy( position Vec2(fieldWidth / 2f, fieldHeight - 120f) ) ) state state.copy(enemies spawnWave(state.wave)) } fun update(dtMs: Long) { if (state.status ! GameStatus.Playing) { return } // 限制单帧时间防止切后台回来时一次性推进太多 val dt dtMs.coerceIn(1L, 100L) / 1000f val movedBullets state.bullets .map { it.copy(position it.position it.speed * dt) } .filter { it.position.y 0f } val movedEnemies state.enemies .map { it.copy(position it.position it.speed * dt) } .filter { it.position.y fieldHeight it.size } val hitEnemyIds checkCollisions(movedBullets, movedEnemies) state state.copy( bullets movedBullets, enemies movedEnemies.filterNot { hitEnemyIds.contains(it.id) }, score state.score hitEnemyIds.size * 10, elapsedTimeMs state.elapsedTimeMs dtMs ) if (state.enemies.isEmpty()) { state state.copy( wave state.wave 1, enemies spawnWave(state.wave 1) ) } } fun movePlayer(dx: Float) { if (state.status ! GameStatus.Playing) { return } val newX (state.player.position.x dx) .coerceIn(state.player.size, fieldWidth - state.player.size) state state.copy( player state.player.copy(position Vec2(newX, state.player.position.y)) ) } fun fire() { if (state.status ! GameStatus.Playing) { return } val bullet Entity( id nextEntityId(), position Vec2(state.player.position.x, state.player.position.y - 30f), speed Vec2(0f, -600f), size 6f ) state state.copy(bullets state.bullets bullet) } private var entitySeed 1 private fun nextEntityId(): Int { entitySeed 1 return entitySeed } private fun spawnWave(wave: Int): ListEntity { val rows 3 val cols 8 val spacing 70f return buildList { for (row in 0 until rows) { for (col in 0 until cols) { add( Entity( id nextEntityId(), position Vec2( x 120f col * spacing, y 100f row * spacing ), speed Vec2(0f, 20f wave * 5f), size 18f ) ) } } } } }这里要注意时间步长。dtMs.coerceIn(1L, 100L)是必须的。如果用户切到后台再切回来两帧间隔可能达到几秒如果不做上限子弹和敌机会瞬间穿过整个屏幕。上限之后游戏只会累积少量位移再配合elapsedTimeMs记录真实时间后续可以做暂停恢复和统计。这个示例为了简洁省略了玩家和敌机的完整交互逻辑。实际项目可以在同一个方法里处理玩家中弹、游戏结束、音效触发的状态变更但原则不变所有规则写在引擎内UI 只负责调用方法。3.3 碰撞检测与计分规则碰撞检测是游戏逻辑的核心。对于“星球突击队”这种小体量游戏不需要引入物理引擎直接用矩形或圆形相交判断即可。private fun checkCollisions( bullets: ListEntity, enemies: ListEntity ): SetInt { return buildSet { for (bullet in bullets) { for (enemy in enemies) { if (enemy.alive intersects(bullet, enemy)) { add(enemy.id) } } } } } private fun intersects(a: Entity, b: Entity): Boolean { val dx a.position.x - b.position.x val dy a.position.y - b.position.y val radius (a.size b.size) / 2f return dx * dx dy * dy radius * radius }使用距离平方而不是开方性能更好。对于每帧最多几十个实体的场景这种双重循环完全没有压力。如果敌机数量达到几百甚至上千就要改成空间哈希或网格碰撞。得分规则放在引擎里而不是 UI 里这是为了避免两个端出现不同的计分逻辑。left计分的具体规则、波次生成策略都写在 commonMain平台端只需要展示state.score。3.4 用 expect/actual 隔离平台差异“星球突击队”需要读取当前时间戳和生成随机数。这两个能力在不同平台上有不同实现因此用expect声明再在平台模块提供actual。// commonMain/kotlin/com/example/starsquad/Platform.kt package com.example.starsquad expect fun currentTimeMillis(): Long expect fun randomFloat(): Float// androidMain/kotlin/com/example/starsquad/Platform.android.kt package com.example.starsquad import kotlin.random.Random actual fun currentTimeMillis(): Long System.currentTimeMillis() actual fun randomFloat(): Float Random.nextFloat()// iosMain/kotlin/com/example/starsquad/Platform.ios.kt package com.example.starsquad import platform.Foundation.NSDate import platform.Foundation.timeIntervalSince1970 import kotlin.random.Random actual fun currentTimeMillis(): Long (NSDate().timeIntervalSince1970 * 1000.0).toLong() actual fun randomFloat(): Float Random.nextFloat()这里最容易犯的错误是在 commonMain 直接使用java.util.Random或System.currentTimeMillis()。Android 编译时能通过但 iOS 的 Kotlin/Native 会直接报unresolved reference。所以移植第一步就应该扫描共享代码里的 JVM 专属 import把它们全部换成expect/actual或 Kotlin 标准库函数。如果平台差异很多可以考虑定义interface Platform并在引擎构造时注入而不是用大量顶层expect/actual。这样更接近依赖注入测试时也容易替换实现。4. 用 Compose Multiplatform 搭建共享 UI 层共享 UI 是 KMP 移植最有性价比的部分。用 Compose Multiplatform 写一遍 Canvas 绘制和手势监听Android、iOS 和桌面端都能复用同一份 UI 代码。它的本质是 Kotlin 编译器直接生成各平台可执行的产物底层映射到 Android View 和 iOS UIKit 的视图树。4.1 在 Gradle 中启用 Compose Multiplatform在composeApp/build.gradle.kts中启用 KMP 插件和 Compose 插件import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } listOf( iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { iosTarget - iosTarget.binaries.framework { baseName ComposeApp isStatic true } } sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) } androidMain.dependencies { implementation(libs.androidx.activity.compose) } } } android { namespace com.example.starsquad compileSdk 35 defaultConfig { applicationId com.example.starsquad minSdk 24 targetSdk 35 versionCode 1 versionName 1.0 } }这里有两点需要解释。第一iosX64()、iosArm64()、iosSimulatorArm64()三个目标分别对应 Intel 模拟器、真机和 Apple Silicon 模拟器。如果只保留真机目标模拟器无法运行如果只保留模拟器目标真机无法运行。学习阶段建议三种都保留后面按团队需求裁剪。第二isStatic true表示生成的 framework 是静态库。静态 framework 的链接速度更快打包体积通常也更小适合集成到现有原生 App。动态 framework 的支持在部分场景下还有限制静态库是最稳妥的起点。4.2 用 Compose 绘制游戏界面游戏界面使用Canvas绘制。Canvas是 Compose 中的一个 Composable可以在其中使用DrawScope绘制矩形、圆形、文本等图形。Composable fun GameScreen(engine: GameEngine) { val state engine.state Canvas( modifier Modifier .fillMaxSize() .background(Color.Black) .pointerInput(Unit) { detectDragGestures { change, dragAmount - change.consume() engine.movePlayer(dragAmount.x) } } .pointerInput(Unit) { detectTapGestures { engine.fire() } } ) { // 绘制玩家飞船 drawCircle( color Color.Cyan, radius state.player.size, center Offset(state.player.position.x, state.player.position.y) ) // 绘制敌机 state.enemies.forEach { enemy - drawCircle( color Color.Red, radius enemy.size, center Offset(enemy.position.x, enemy.position.y) ) } // 绘制子弹 state.bullets.forEach { bullet - drawCircle( color Color.Yellow, radius bullet.size, center Offset(bullet.position.x, bullet.position.y) ) } } // 分数显示 Text( text Score: ${state.score} Wave: ${state.wave}, color Color.White, modifier Modifier.padding(16.dp) ) }这里有一个必须解决的问题GameState使用的是逻辑坐标屏幕使用像素坐标。两者如果不做映射游戏在手机竖屏和桌面宽屏上会出现完全不同的大小和位置。最简单的方式是在GameEngine初始化时传入固定逻辑宽高绘制时用Canvas的尺寸把逻辑坐标映射到实际坐标。val scaleX size.width / 800f val scaleY size.height / 1000f在DrawScope内使用withTransform统一缩放Canvas(modifier Modifier.fillMaxSize().background(Color.Black)) { val scaleX size.width / 800f val scaleY size.height / 1000f withTransform({ translate( left (size.width - 800f * scaleX) / 2f, top (size.height - 1000f * scaleY) / 2f ) scale(scaleX, scaleY, pivot Offset.Zero) }) { // 绘制逻辑坐标下的实体 } }这样不同尺寸的手机和桌面窗口都能保持相对一致的游戏视野。4.3 把 UI 状态和用户输入接到 GameEngineUI 的入口是一个App()Composable。它负责创建引擎实例并通过一个循环驱动游戏更新。Composable fun App() { val engine remember { GameEngine() } var gameState by remember { mutableStateOf(engine.state) } LaunchedEffect(Unit) { engine.start() while (true) { withFrameNanos { nanos - val dtMs nanos / 1_000_000L engine.update(dtMs) gameState engine.state } } } MaterialTheme { Box { GameScreen(engine) } } }withFrameNanos会在每一帧绘制前回调时间游戏逻辑在这一刻推进。因为gameState是mutableStateOf更新后 Compose 会重新组合Canvas 会用最新的状态重新绘制。输入事件直接调用引擎方法。拖拽事件把手指移动的增量传给movePlayer点击事件触发fire。这样 UI 层只负责两件事把时间交给引擎把手势翻译成引擎调用。真正的规则和状态都在 commonMain 里平台两端行为一致。注意不要在remember之外创建GameEngine也不要每次重组都重建一次。remember保证引擎实例只在第一次组合时创建后续重组复用同一个实例。5. Android 与 iOS 入口只保留最薄的原生壳共享逻辑和共享 UI 完成后平台端只剩下“把 Compose 内容挂到系统视图上”这一步。入口代码非常薄这也是 KMP 移植收益最明显的地方。5.1 Android 入口 MainActivityAndroid 入口继承ComponentActivity调用setContent并渲染App()。package com.example.starsquad import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { App() } } }AndroidManifest.xml需要声明这个 Activity 为启动入口并设置为竖屏或根据配置支持横屏。对“星球突击队”这类竖屏街机游戏可以在 manifest 中固定竖屏减少尺寸适配成本application android:label星球突击队 android:iconmipmap/ic_launcher android:themestyle/Theme.AppCompat.NoActionBar activity android:name.MainActivity android:exportedtrue android:screenOrientationportrait intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application这里的Theme.AppCompat.NoActionBar建议替换成项目自己的主题。Compose 页面不需要系统 ActionBar使用无标题栏主题可以避免页面顶部出现额外区域。5.2 iOS 入口 MainViewControlleriOS 入口稍微特殊。它不需要创建UIViewController子类只需要返回一个由ComposeUIViewController包装的控制器。package com.example.starsquad import androidx.compose.ui.window.ComposeUIViewController import platform.UIKit.UIViewController fun MainViewController(): UIViewController ComposeUIViewController { App() }在 Xcode 工程中需要把生成的ComposeApp.framework添加到“Frameworks, Libraries, and Embedded Content”然后在AppDelegate或SceneDelegate中调用MainViewControllerKt.MainViewController()。如果使用 SwiftUI 工程可以直接用UIViewControllerRepresentable包装import SwiftUI import ComposeApp struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) - UIViewController { MainViewControllerKt.MainViewController() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } struct ContentView: View { var body: some View { ComposeView() .ignoresSafeArea() } }ignoresSafeArea()比较关键。iOS 默认会避开安全区域如果游戏希望全屏绘制需要让 Compose 内容铺满整个屏幕。5.3 屏幕适配与生命周期感知Android 和 iOS 对“生命周期”的处理不同。Android 通过Lifecycle的ON_PAUSE、ON_STOP状态管理后台行为iOS 通过UIApplicationDidEnterBackgroundNotification等通知处理。在 KMP 共享 UI 方案下最省事的做法是让游戏逻辑本身不依赖于生命周期事件而是依赖“时间增量是否异常”。上面GameEngine.update已经对dtMs做了 100ms 上限切后台再恢复时不会瞬间推进大量帧。但更完善的做法是增加暂停入口fun pause() { if (state.status GameStatus.Playing) { state state.copy(status GameStatus.Paused) } } fun resume() { if (state.status GameStatus.Paused) { state state.copy(status GameStatus.Playing) } }Android 端可以在onPause调用engine.pause()iOS 端可以在 App 进入后台时调用同样的方法。因为方法在 commonMain两端行为一致。屏幕适配要处理的问题包括逻辑坐标系和实际像素系的缩放。刘海屏和全面屏的安全区域。不同分辨率下字体和碰撞判定是否受影响。建议 Canvas 绘制时始终基于逻辑坐标由withTransform做整体缩放而不是为每个实体手动除以屏幕宽度。这样碰撞判定和 UI 绘制使用同一套坐标不容易出现“UI 显示在 A 位置碰撞判定在 B 位置”的错位。6. 运行验证三个入口、一套逻辑、各自的验证点移植完成后的验证不能只停留在“App 能启动”。需要从逻辑层、Android 端、iOS 端各自验证并整理一份可复用的检查清单。6.1 JVM 桌面预览与单元测试commonMain 里的GameEngine是纯 Kotlin 类可以在 JVM 上直接运行单元测试不需要启动模拟器。这是 KMP 移植带来的最大好处之一。在composeApp/src/commonTest/kotlin/com/example/starsquad/GameEngineTest.kt中编写测试package com.example.starsquad import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class GameEngineTest { Test fun player moves within field bounds() { val engine GameEngine(fieldWidth 800f, fieldHeight 1000f) engine.start() engine.movePlayer(-100000f) assertTrue(engine.state.player.position.x engine.state.player.size) } Test fun firing creates a bullet ahead of player() { val engine GameEngine(fieldWidth 800f, fieldHeight 1000f) engine.start() engine.fire() assertEquals(1, engine.state.bullets.size) assertTrue(engine.state.bullets.first().position.y engine.state.player.position.y) } Test fun time step is clamped to avoid huge jumps() { val engine GameEngine(fieldWidth 800f, fieldHeight 1000f) engine.start() engine.update(5000L) // 即使传入 5 秒也不会一次性推进到不可控状态 assertTrue(engine.state.elapsedTimeMs 6000L) } }运行命令./gradlew :composeApp:testDebugUnitTest如果配置了 iOS 目标也可以运行 Kotlin/Native 测试./gradlew :composeApp:iosSimulatorArm64Test这一步能验证规则是否正确而且发现问题的成本最低。每次修改GameEngine都应该先跑测试再去看 UI。6.2 Android 运行验证Android 端运行前先检查 SDK 版本是否满足compileSdk。使用 Android Studio 直接运行到模拟器或真机或者用命令行./gradlew :composeApp:assembleDebug安装后手动验证以下路径游戏启动后进入 Ready 或 Playing 状态。拖拽飞船时飞船不会超出左右边界。点击屏幕能发射子弹。子弹击中敌机后计分增加。一波敌机清除后进入下一波。切到后台再切回游戏不会瞬间结束。旋转屏幕或不同分辨率下画面边缘不被裁剪。如果发现触控位置和游戏内位置不一致优先检查 Canvas 的坐标变换而不是 Input 事件。拖动增量直接传给引擎理论上与屏幕缩放无关。6.3 iOS 运行验证iOS 运行需要在 macOS 上执行。模拟器使用 xcodebuild 构建或者在 Xcode 中打开工程后点击 Run。命令行构建示例./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64然后在 Xcode 工程中配置 framework 搜索路径并运行。iOS 端需要重点验证ComposeUIViewController是否正常铺满屏幕。安全区域是否导致游戏底部被遮挡。触摸拖拽和点击是否响应。点击 Home 键再回来游戏状态是否正确。模拟器和真机的屏幕尺寸差异是否影响布局。iOS 比较常见的问题是 framework 名称不对。Gradle 中设置的是baseName ComposeApp在 Xcode 中导入的 framework 就必须是ComposeApp.framework。如果改了 Gradle 里的baseNameXcode 工程也要同步调整否则链接阶段会报找不到符号。6.4 移植完整性检查清单每次完成一个功能后可以按下面的清单验证避免最后统一联调时出现一堆问题检查项验证方式通过标准commonMain 无 JVM 专属 import编译 iOS target不出现 unresolved reference时间戳和随机源正确运行测试两个平台返回值正常状态机转换正确单测覆盖 start、update、pause状态字段符合预期碰撞和计分正确单测覆盖构造场景得分和删除目标符合预期Android UI 正常模拟器运行绘制完整交互可用iOS UI 正常模拟器运行绘制完整交互可用屏幕适配正常多尺寸设备画面不裁剪碰撞不偏移后台恢复正常切后台再切回游戏状态不会瞬间跳变7. 常见问题排查从编译错误到运行异常KMP 移植的排查链路和普通 Android 项目不太一样。普通项目只在 JVM 上运行问题多出现在运行时KMP 项目编译阶段就可能因为目标平台差异报错所以排查顺序也要调整先查依赖和版本再查共享代码的平台兼容性最后查运行期表现。7.1 编译期java.* API 泄漏进共享代码现象Android 编译通过但 iOS target 编译报unresolved reference。报错信息中出现了java.util或javax.*的 import。原因commonMain 里的代码使用了只在 JVM 上存在的 API。检查方式在commonMain目录下搜索import java.逐个确认是否可以在 Kotlin/Native 下工作。尝试运行./gradlew :composeApp:compileKotlinIosSimulatorArm64看具体报错位置。处理方案把java.util.Random换成kotlin.random.Random。把System.currentTimeMillis()换成expect/actual或 Kotlin 标准库里的时间 API。如果某个功能确实只能在特定平台实现用expect声明接口在各平台提供实现。这条规则越早建立越好。移植一开始就扫描import java.比编译失败再逐个改要省时间。7.2 链接期Kotlin/Native 内存与 framework 问题现象Gradle 构建成功但 Xcode 工程运行时报动态链接错误。或者linkDebugFrameworkIosSimulatorArm64阶段报链接失败。可能原因Gradle 中 framework 的baseName与 Xcode 依赖名不一致。Xcode 中 framework 的搜索路径不正确。Kotlin/Native 版本和 Xcode 版本不匹配。静态 framework 和动态 framework 选型混用。检查方式查看composeApp/build/bin/iosSimulatorArm64/debugFramework/下生成的文件名。在 Xcode 的 Build Settings 中检查FRAMEWORK_SEARCH_PATHS。查看 Xcode 日志中的具体链接错误。处理方案统一 framework 名称Gradle 里的baseName必须和 Xcode 导入的名称一致。优先使用静态 framework减少动态加载导致的路径问题。升级 Kotlin 或 Xcode 前先确认官方版本兼容表。7.3 运行期入口、日志和时序问题现象模拟器启动后黑屏或白屏。点击屏幕没有反应。切后台再回来位置或得分异常。可能原因iOS 端没有把MainViewController设置成根视图控制器。视图控制器没有调用ignoresSafeArea()导致内容被安全区域遮挡。输入事件没有调用consume()导致手势冲突。时间增量没有做上限切后台后一次性推进了大量帧。检查方式在 Xcode 的AppDelegate或SceneDelegate中加断点确认MainViewController()被调用。在GameEngine.update中加日志打印dtMs观察是否出现超大时间差。在触控事件处理中打印引擎状态。处理方案确认 iOS 入口正确返回了ComposeUIViewController。在 Canvas 的pointerInput中调用change.consume()。在GameEngine.update中保留dtMs.coerceIn(1L, 100L)的逻辑并验证前后台切换。7.4 高频问题速查表问题现象可能原因检查方式处理建议iOS 编译报 unresolved referencecommonMain 使用了 JVM 专属 API搜索 commonMain 下所有java.import改为 expect/actual 或标准库 APICompose 编译器版本错误Kotlin 与 Compose Multiplatform 版本不匹配查看编译日志中的版本要求按官方版本矩阵调整libs.versions.tomlXcode 链接失败framework 名称或搜索路径不一致检查 Gradle baseName 和 Xcode 依赖统一名称检查FRAMEWORK_SEARCH_PATHSiOS 白屏根控制器未设置或安全区域遮挡在入口处加断点设置 rootViewController添加ignoresSafeArea()点击无响应手势冲突或未消费事件在 pointerInput 中打印事件调用change.consume()检查手势组合切后台后状态跳变时间增量未做上限打印每次 update 的 dtMs对 dtMs 做coerceIn处理画面位置和碰撞位置不一致逻辑坐标和屏幕坐标没有统一缩放检查 Canvas 坐标变换使用 withTransform 整体缩放Android 构建失败AGP 和 Gradle 版本不兼容查看 Gradle 最小版本报错升级 Gradle wrapper 或调整 AGP 版本8. 工程化建议移植完成后如何继续演进学习环境里只要代码能跑起来任务就完成了。但放到生产环境移植工作还有一截路要走。这里的建议都围绕着“如何让共享逻辑更可靠、让多端交付更顺畅”展开。8.1 为游戏逻辑补充自动化测试不要把测试只停留在示例中的三个用例。游戏规则的每个分支都应该有测试覆盖玩家边界向左、向右、快速连续移动。子弹发射连续发射时是否存在实体数量异常。碰撞子弹击中多个敌机是否只计一次分。波次切换清空所有敌机后是否正确进入下一波。暂停恢复暂停时 update 是否不推进状态。游戏结束玩家生命值归零后状态是否正确。每一条测试都直接写GameEngine的输入然后断言GameState输出。这类测试跑得很快适合在 CI 的每个 PR 中执行。8.2 把配置外置化与资源管理理清示例中fieldWidth 800f、fieldHeight 1000f直接写在构造函数里。生产项目里这类参数应该外置data class GameConfig( val fieldWidth: Float, val fieldHeight: Float, val initialWave: Int, val bulletSpeed: Float, val enemyBaseSpeed: Float )配置来源可以是expect/actual从本地文件读取也可以在App()初始化时通过参数传入。这样调整难度、分辨率、速度时不需要改动逻辑代码。音频、图标、字体这类资源在 KMP 中需要分平台处理。Compose Multiplatform 有资源库支持但在资源量大的项目中建议把资源目录和访问逻辑单独抽象避免 commonMain 直接依赖 Android 或 iOS 的资源 API。8.3 生产环境还需要关注的能力从学习项目到生产项目至少还要补齐日志建立统一的日志封装commonMain 打印到各平台日志系统。崩溃上报Android 接入 CrashlyticsiOS 接入对应崩溃收集工具。远程配置游戏难度、关卡参数可以下发不用发版。数据存储最高分、本地进度需要持久化通过 expect/actual 接各平台存储。回滚方案版本更新后如果出现逻辑异常服务端要能降级配置。监控统计帧率、崩溃率、关卡流失点为后续调整做数据支撑。这些能力不是 KMP 特有的但移植后更容易被忽略因为开发者容易把注意力放在“两端是否一致”上。8.4 扩展方向物理引擎、多人同步、CI 构建如果“星球突击队”只是起点后续可以从三个方向扩展。第一引入更完整的游戏引擎或物理系统。可以用纯 Kotlin 实现更复杂的刚体碰撞也可以接入第三方 KMP 游戏框架。选择标准是看它是否维护活跃、是否支持你的目标平台、是否覆盖了你的渲染和音频需求。第二增加多人同步。KMP 适合把网络协议、房间状态、消息序列化放在 commonMain两端只负责网络传输层。这样游戏规则和服务端逻辑可以共享减少两端规则不一致的问题。第三搭建多平台 CI。Android target 可以在 Linux 或 macOS 构建iOS target 只能 macOS。GitHub Actions 可以用 macOS Runner 一次构建 Android、iOS 和测试任务每次提交自动验证三个平台是否都能编译通过。CI 的价值在于把“我能编译”变成“每次提交都能编译”尽早发现问题。如果只记住一件事那就是 KMP 移植的重点不是把代码复制到 commonMain而是先定清楚边界业务规则进共享层平台能力留在壳层。从最小可运行版本开始先把状态机、碰撞、计分跑通再逐步加入共享 UI、平台适配、测试和 CI。这个过程里版本矩阵和 expect/actual 是最大的坑也是最能体现工程能力的地方。