十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kotlin Multiplatform移植指南:从Android游戏到共享逻辑架构

Kotlin Multiplatform移植指南:从Android游戏到共享逻辑架构 很多团队第一次接触 Kotlin Multiplatform不是因为想追新而是因为遇到了一个很现实的问题一套核心逻辑要在 Android 和 iOS 各写一遍或者要在服务端、桌面端再各同步一份。如果你正在被这类问题困扰又恰好有一个已经跑起来的 Kotlin 项目那最稳妥的演进路径不是推倒重来而是“移植”。“星球突击队”就是这样一个典型场景。它最初是为 Android 平台设计的游戏项目核心玩法、关卡数据、资源调度和网络协议都直接写在 App 工程里。当要把它扩展到其他平台时摆在面前的选项有三个重写业务逻辑、引入跨平台方案重新开发、或者用 Kotlin Multiplatform 把共享部分逐步剥离出来。前两个成本太高第三个正好落在 KMP 最擅长的领域。这篇文章会从“移植”这个动作出发而不是空讲 Kotlin Multiplatform 是什么。我们会用一个贴近真实项目的视角拆解如何把一个 Android 项目中的游戏核心逻辑逐步移植到 KMP 架构中哪些代码应该共享哪些必须留在平台层expect/actual 到底该怎么设计网络层、数据层、状态管理各放在哪一层。文章会给出完整代码示例、环境配置、验证方法和排错路径目标是让读者看完之后能对自己手头的项目做一次类似的移植评估而不是只会对着文档敲命令。先说一个核心判断Kotlin Multiplatform 的真正价值不是“一份代码到处跑”而是“把业务逻辑沉淀成独立于平台的资产”。如果只是追求 UI 跨平台KMP 并不是最合适的选择但如果是游戏逻辑、业务规则、数据处理这类与界面无关的部分KMP 能帮你省掉大量重复劳动同时保留每个平台的原生体验。这个边界感是决定移植成败的第一个关键认知。1. 这篇文章真正要解决的问题很多开发者在搜索“Kotlin Multiplatform 移植”时其实已经看过一些官方文档和入门教程但真正动手时会卡在三个地方。第一不知道从项目的哪个位置开始拆。一个 Android 项目里有 UI、ViewModel、Repository、网络层、数据库、业务逻辑、工具类如果一股脑全塞进 commonMain编译期就各种报错平台相关的 API 根本找不到。第二expect/actual 的粒度把握不好。有的类写完 expect 后发现 actual 里代码一模一样等于白写了一层抽象有的类在 common 里调用了非公共 API被迫把大量代码下沉到平台层共享代码比例大幅缩水。第三验证方式不明确。代码从 Android 工程移植到 KMP 模块后不知道应该跑哪套测试、怎么看是否真正做到了跨平台可用。这篇博客就是为了解决这三个问题而写的。我们以“星球突击队”这个游戏项目作为分析对象演示从普通 Android 工程迁移到 Kotlin Multiplatform 架构的完整路径。你会看到如何对现有代码做依赖分析找出真正值得共享的模块如何用 expect/actual 隔离平台 API而不是到处写 if/else网络层和数据层在 KMP 里应该怎么组织如何用一套测试验证共享代码的行为移植后项目结构会变成什么样构建和发布流程有哪些变化。读完这篇文章你能对自己手头的 Kotlin 项目做一个清晰的移植评估哪些部分值得共享、哪些部分应该留在原生侧、第一步动刀时从哪个文件开始。2. Kotlin Multiplatform 的核心概念与适用场景在动手移植之前需要先把 KMP 的几个核心概念理清楚。这些概念并不难但它们在工程中的含义往往被低估了。2.1 什么是 Kotlin MultiplatformKotlin Multiplatform 是 JetBrains 推出的跨平台技术方案它不是把代码翻译成各平台的语言而是通过 Kotlin 编译器的多平台后端把同一份 Kotlin 代码编译成不同平台的目标产物。对于 Android它编译成 JVM 字节码对于 iOS它编译成可直接嵌入的 framework对于 JavaScript/Wasm它编译成对应的目标文件。这意味着你的业务逻辑代码可以只写一次但最终在 Android 上运行的是 JVM 字节码在 iOS 上运行的是原生 ARM 代码。它们不是解释型跨平台也不是 WebView 套壳而是真正编译为各平台的原生产物。KMP 在项目中的组织结构是共享 UI 的跨平台方案由 Compose Multiplatform 负责而 KMP 本身只负责共享逻辑。在编写代码时源代码集一般分为commonMain存放平台无关的共享代码。androidMain存放 Android 平台相关实现。iosMain存放 iOS 平台相关实现。desktopMain存放桌面平台相关实现如果启用了 JVM 目标。2.2 与 Flutter、React Native 的本质区别Flutter 和 React Native 是自带 UI 渲染引擎的跨平台框架而 Kotlin Multiplatform 是共享逻辑的跨平台方案。这个区别很重要直接决定了技术选型的方向。如果项目里大量代码是界面渲染、动画、手势交互Flutter 这类方案更合适如果项目中真正复杂的是业务状态、算法、数据模型、网络协议、领域规则而且你希望保留 Android 和 iOS 各自的原生 UI 能力那么 KMP 是更好的选择。在“星球突击队”这样的游戏项目中界面部分由各平台原生渲染而玩法逻辑、关卡数据、评分计算、资源生成规则等内容恰恰是 KMP 最能发挥价值的部分。这就是为什么选择 KMP 作为移植目标而不是把整个游戏用 Flutter 重写。2.3 expect/actual 机制的本质expect/actual 是 KMP 中最核心的语言机制它解决的问题是在共享代码中调用一个平台相关的 API但把具体实现交给各平台。用一句话总结expect是在commonMain里声明一个“这里有一个东西各平台必须有实现”actual是在各平台源码集里给出真正的实现。这里有一个容易踩坑的地方。很多初学者把 expect/actual 当成万能灵药但在实践中能不用 expect/actual 就不用因为每增加一个 expect 声明你就要在每一个目标平台里维护一份 actual 实现。更好的做法是先尝试用纯 Kotlin 代码解决只有遇到平台 API 或第三方库不支持时才引入 expect/actual。2.4 KMP 的共享边界KMP 能共享的是与平台无关的纯逻辑而不是平台的 UI 或系统能力。具体来说适合放入共享层的包括数据模型与业务实体数据解析与序列化逻辑网络请求封装基于 Ktor Client 等跨平台网络库本地存储封装基于 multiplatform-settings 等库状态管理与业务用例工具函数、时间处理、字符串处理不适合放入共享层的包括Android 的 Activity/Fragment/ViewiOS 的 UIKit/SwiftUI 视图平台特有的传感器、定位、蓝牙等能力封装但可以通过 expect/actual 暴露统一接口由平台层实现在移植“星球突击队”时这个边界是第一步就要定义清楚的。游戏规则引擎、关卡数据结构、分数系统都属于可共享逻辑而音效播放、振动反馈、广告 SDK 接入等则留在平台层。3. 移植前的项目结构与现状分析移植不是从“新建 KMP 模块”开始的而是从“搞清楚现有代码的依赖关系”开始的。3.1 分析现有代码的模块依赖以“星球突击队”为例假设它当前的 Android 工程结构如下app/ src/main/java/com/example/planetassault/ ui/ MainActivity.kt GameScreen.kt BattleFragment.kt viewmodel/ GameViewModel.kt BattleViewModel.kt repository/ LevelRepository.kt PlayerRepository.kt network/ ApiClient.kt GameApiService.kt model/ Player.kt Level.kt Enemy.kt Weapon.kt game/ GameEngine.kt LevelGenerator.kt ScoreCalculator.kt ResourceManager.kt util/ TimeUtils.kt DataFormatter.kt移植前需要做一次依赖分析目标是识别出哪些模块是纯 Kotlin 代码、依赖了哪些第三方库、哪些代码直接调用了 Android SDK API。一个简单的方法是逐个类检查 import 语句如果 import 里只有java.*、kotlin.*和自己项目内部的类那大概率可以移入共享层如果 import 里有android.*、androidx.*则要么留在 Android 层要么用 expect/actual 做一个接口封装。注意java.*也不能直接认为可以在 iOS 上运行。KMP 的 commonMain 只包含 Kotlin 标准库的公共 API。部分java.text、java.util类在 Apple 平台上没有直接映射需要查找对应的 Kotlin 跨平台 API。3.2 给代码打上边界标签我建议在动手改代码之前先给每个类打上“图层标签”。这个标签不是代码注释而是移植计划的一部分。标签含义示例SHARED可以原样移入 commonMainLevel.kt, Enemy.kt, ScoreCalculator.ktPLATFORM-ADAPT逻辑本身是共享的但有少量平台调用GameEngine.kt使用了 System.currentTimeMillisANDROID-ONLY强依赖 Android SDKMainActivity.kt, GameScreen.ktTHIRD-PARTY依赖的库是否支持 KMPApiClient.kt如果依赖 Retrofit则需要替换这个标签过程会直接影响后续的移植顺序。推荐先处理 SHARED 类因为它们难度最低、价值最直接然后处理 PLATFORM-ADAPT 类这是移植的核心工作最后再评估 THIRD-PARTY 和 ANDROID-ONLY。3.3 评估第三方依赖的 KMP 支持网络层往往是移植中的第一个大坑。在 Android 上很多人习惯用 Retrofit OkHttp Gson但这个组合在 KMP 中并不通用。Retrofit 目前不支持非 JVM 目标Gson 同样不支持。对于要迁移到 KMP 的网络层通常是选用 Ktor Client 作为 HTTP 客户端配合 kotlinx.serialization 做 JSON 序列化。Ktor Client 提供了统一的跨平台 API在不同平台上有不同的底层引擎实现。在“星球突击队”项目中网络层虽然不像大型在线游戏那样复杂但排行榜上传、关卡配置拉取等功能也离不开网络请求。移植时需要考虑的是是保留 Retrofit 只给 Android 用还是切换到 Ktor 实现跨平台如果这个项目立刻要支持 iOS选择 Ktor 基本是必然的。3.4 确定移植目标平台不同目标平台会直接影响代码组织和依赖选择。典型的 KMP 目标组合包括Android iOS移动端共享逻辑Android iOS Desktop三端共享逻辑Android iOS Server服务端共享领域模型对于“星球突击队”这种游戏项目目标一般是 Android iOS后续可能会加入桌面端用于开发调试。因此建议在 gradle 配置里同时启用 androidTarget 和 iosArm64、iosSimulatorArm64方便先用模拟器验证。4. Kotlin Multiplatform 环境搭建与基础配置环境搭建这部分看似基础但配置不正确会导致很多奇怪问题比如 Android Studio 无法识别 iOS target、构建时找不到 Kotlin/Native 编译器、依赖下载失败等。4.1 需要的开发工具Android Studio 最新稳定版用于 Android 开发和 KMP 共享模块开发IntelliJ IDEA 或 Android Studio两者均可编辑 KMP 项目Xcode仅 macOS 上构建 iOS framework 时需要JDK 11 或更高版本需要说明的是Kotlin Multiplatform 插件和 Android Gradle Plugin 的版本兼容性至关重要。不同版本的 Kotlin 插件对 Gradle 版本、AGP 版本都有要求。如果配置时遇到“Invalid plugin descriptor”或者编译报错优先检查版本矩阵。由于版本迭代很快本文不写死具体版本号建议以 Kotlin 官方文档中的兼容性表格为准。4.2 创建或改造一个 KMP 工程如果从零创建Android Studio 的模板已经支持 Kotlin Multiplatform。但我们是做“移植”所以更常见的情况是在已有 Android 工程中增加一个 KMP 模块。一个推荐的做法是新建一个shared模块把现有 Android 工程中的可共享代码逐步迁移进去而不是直接在app模块里做改造。这样做的好处是原有 Android 工程不会被破坏每迁移一部分代码都可以独立编译验证出问题时回滚也简单。使用 Gradle 创建一个shared模块后shared/build.gradle.kts的配置骨架如下// 文件路径shared/build.gradle.kts plugins { kotlin(multiplatform) kotlin(plugin.serialization) } kotlin { androidTarget { compilations.all { kotlinOptions { jvmTarget 1.8 } } } listOf( iosArm64(), iosSimulatorArm64() ).forEach { iosTarget - iosTarget.binaries.framework { baseName Shared isStatic true } } sourceSets { val commonMain by getting { dependencies { implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:latest.release) implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:latest.release) implementation(io.ktor:ktor-client-core:latest.release) } } val androidMain by getting { dependencies { implementation(io.ktor:ktor-client-okhttp:latest.release) } } val iosMain by getting { dependencies { implementation(io.ktor:ktor-client-darwin:latest.release) } } } } android { namespace com.example.planetassault.shared compileSdk 34 }这里使用latest.release是示意实际项目中应该锁定具体版本号避免依赖不一致导致构建不稳定。另外iosArm64()是 iOS 真机目标iosSimulatorArm64()是 Apple Silicon Mac 上的模拟器目标。如果你的 Mac 是 Intel 芯片还需要加上iosX64()。4.3 Android 工程依赖 shared 模块配置好shared模块后需要在app/build.gradle.kts里添加依赖// 文件路径app/build.gradle.kts dependencies { implementation(project(:shared)) }至此环境搭建完成。接下来开始移植具体的游戏逻辑代码。5. 核心逻辑移植以游戏引擎为例“星球突击队”中最重要的共享模块是game包下的核心逻辑。我们从三个典型场景来演示移植纯数据模型、纯计算方法、以及包含平台调用的逻辑类。5.1 移植纯数据模型先看最简单的例子游戏中的敌人实体类。原始代码可能是这样的// 文件路径app/src/main/java/com/example/planetassault/model/Enemy.kt package com.example.planetassault.model data class Enemy( val id: String, val type: EnemyType, val health: Int, val speed: Float, val positionX: Float, val positionY: Float ) enum class EnemyType { SOLDIER, TANK, BOSS }这个类只依赖 Kotlin 标准库也没有 Android 特定 API。移植方式是直接把这个文件移动到shared/src/commonMain/kotlin/com/example/planetassault/model/Enemy.kt包名不变代码不需要做任何修改。这类“纯数据模型”是整个移植过程中最轻松的部分。但在大型项目中数据模型往往不是孤立的它们会引用 Json 序列化注解、数据库注解或平台依赖的类。在引入注解时需要确保注解库支持 KMP例如 kotlinx.serialization 的Serializable注解可以在 commonMain 中直接使用。假设敌人的数据需要序列化我们可以在 commonMain 中这样写// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/model/Enemy.kt package com.example.planetassault.model import kotlinx.serialization.Serializable Serializable data class Enemy( val id: String, val type: EnemyType, val health: Int, val speed: Float, val positionX: Float, val positionY: Float ) Serializable enum class EnemyType { SOLDIER, TANK, BOSS }这样封装后无论是 Android 端还是 iOS 端都可以用同一个数据模型做 JSON 解析。5.2 移植纯计算方法游戏中的得分计算逻辑是一个典型场景它接收玩家操作记录和关卡参数输出最终得分。这个方法完全不涉及平台 API// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/game/ScoreCalculator.kt package com.example.planetassault.game import com.example.planetassault.model.EnemyType object ScoreCalculator { private const val BASE_KILL_SCORE 100 private const val COMBO_MULTIPLIER 2 fun calculateKillScore(enemyType: EnemyType, comboCount: Int): Int { val baseScore when (enemyType) { EnemyType.SOLDIER - BASE_KILL_SCORE EnemyType.TANK - BASE_KILL_SCORE * 3 EnemyType.BOSS - BASE_KILL_SCORE * 10 } val comboBonus if (comboCount 1) comboCount * COMBO_MULTIPLIER else 0 return baseScore * (comboBonus 1) } fun calculateTotalScore(scores: ListInt): Int { return scores.sum() } }这种计算逻辑是 KMP 最理想的共享代码。它们不存在平台差异、不依赖第三方库、测试容易。在移植过程中我们应当优先把所有这类函数抽取到独立文件并配套单元测试。5.3 expect/actual 处理平台 API 调用游戏引擎中的某些功能会依赖当前时间。比如根据系统时间计算游戏内的日夜循环。原来的 Android 代码可能直接用System.currentTimeMillis()这个 API 在 JVM 上可以运行但 commonMain 中不能直接调用System.currentTimeMillis()因为 Apple 平台没有 java.lang.System。这种情况下就需要 expect/actual。首先在commonMain中声明一个接口或函数// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/platform/TimeProvider.kt package com.example.planetassault.platform expect fun currentTimeMillis(): Long然后在androidMain中提供 Android 的实现// 文件路径shared/src/androidMain/kotlin/com/example/planetassault/platform/TimeProvider.android.kt package com.example.planetassault.platform import java.lang.System actual fun currentTimeMillis(): Long { return System.currentTimeMillis() }在iosMain中提供 iOS 实现// 文件路径shared/src/iosMain/kotlin/com/example/planetassault/platform/TimeProvider.ios.kt package com.example.planetassault.platform import platform.Foundation.NSDate import platform.Foundation.timeIntervalSince1970 actual fun currentTimeMillis(): Long { return (NSDate().timeIntervalSince1970 * 1000).toLong() }这样commonMain中的GameEngine就可以调用currentTimeMillis()而不需要关心具体平台实现// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/game/GameEngine.kt package com.example.planetassault.game import com.example.planetassault.platform.currentTimeMillis class GameEngine { fun now(): Long { return currentTimeMillis() } }这里真正容易踩坑的地方是expect/actual 的函数名、参数和返回值必须完全一致。如果 commonMain 中定义了带默认参数的函数那么 actual 中不能重复写默认参数。如果 commonMain 的 expect 函数有默认参数actual 里直接写参数类型即可默认值只保留在 expect 端。另一个常见错误是 expect 类中定义了多个函数但 actual 类只实现了其中一部分编译时会直接报错。所以实际开发中建议用接口加工厂函数的方式替代 expect class这样控制粒度更细。5.4 用接口替代 expect class 的实践在稍大的模块中expect class 在某些场景下不够灵活。比如一个平台管理器在 Android 上需要持有 Context 来访问系统资源在 iOS 上则需要通过 UIApplication 获取某些信息。这两种实现差异比较大初始化方式也不同直接用 expect class 会导致两个平台的构造签名很难统一。更推荐的模式是在commonMain中只定义接口和工厂函数在androidMain和iosMain中实现这个接口。// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/platform/GamePlatform.kt package com.example.planetassault.platform interface GamePlatform { fun getDeviceLevel(): Int fun logEvent(eventName: String, params: MapString, String) } expect fun createGamePlatform(): GamePlatformAndroid 实现// 文件路径shared/src/androidMain/kotlin/com/example/planetassault/platform/GamePlatform.android.kt package com.example.planetassault.platform import android.content.Context import android.util.Log class AndroidGamePlatform(private val context: Context) : GamePlatform { override fun getDeviceLevel(): Int { return 1 } override fun logEvent(eventName: String, params: MapString, String) { Log.d(GamePlatform, $eventName: $params) } } actual fun createGamePlatform(): GamePlatform { // 注意这里需要通过初始化时传入的 Context 来创建实际项目中可以将 Context 存到全局持有者中 val context AndroidContextHolder.context return AndroidGamePlatform(context) }iOS 实现// 文件路径shared/src/iosMain/kotlin/com/example/planetassault/platform/GamePlatform.ios.kt package com.example.planetassault.platform import platform.Foundation.NSLog class IosGamePlatform : GamePlatform { override fun getDeviceLevel(): Int { return 1 } override fun logEvent(eventName: String, params: MapString, String) { NSLog(GamePlatform: % %, eventName, params.toString()) } } actual fun createGamePlatform(): GamePlatform { return IosGamePlatform() }这个模式的优点是commonMain中的业务代码只依赖GamePlatform接口而不是具体实现各平台可以自定义构造方式不受 expect class 构造签名限制测试时可以方便地注入 mock。在“星球突击队”移植中所有涉及系统能力的地方比如震动、声音播放、广告模块都可以用这种模式封装。6. 网络层与数据层移植游戏中的排行榜、签到和关卡更新功能离不开网络请求。在 Android 原始工程中网络层可能用的是 Retrofit Gson。这部分移植到 KMP 时需要做比较大的调整。6.1 网络依赖的替换为了让网络层可以在 Android 和 iOS 共享需要把 Retrofit 替换为 Ktor Client同时用 kotlinx.serialization 替代 Gson。以排行榜接口为例原始代码可能是这样的// 简化示例Retrofit 接口 interface LeaderboardApi { GET(leaderboard/top) suspend fun getTopPlayers(): ListPlayerScore }在 KMP 中使用 Ktor Client 的实现方式如下// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/network/LeaderboardApi.kt package com.example.planetassault.network import com.example.planetassault.model.PlayerScore import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter class LeaderboardApi( private val client: HttpClient ) { suspend fun getTopPlayers(limit: Int 10): ListPlayerScore { return client.get(https://api.example.com/leaderboard/top) { parameter(limit, limit) }.body() } }6.2 HttpClient 的平台引擎配置Ktor Client 的核心 API 在 commonMain 中是统一的但具体网络引擎需要按平台配置。Android 平台通常使用 OkHttp 引擎iOS 平台使用 Darwin 引擎。在 shared 模块中创建统一工厂// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/network/HttpClientFactory.kt package com.example.planetassault.network import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json expect fun createHttpClient(): HttpClientAndroid 端实现// 文件路径shared/src/androidMain/kotlin/com/example/planetassault/network/HttpClientFactory.android.kt package com.example.planetassault.network import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp actual fun createHttpClient(): HttpClient { return HttpClient(OkHttp) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true isLenient true }) } } }iOS 端实现// 文件路径shared/src/iosMain/kotlin/com/example/planetassault/network/HttpClientFactory.ios.kt package com.example.planetassault.network import io.ktor.client.HttpClient import io.ktor.client.engine.darwin.Darwin actual fun createHttpClient(): HttpClient { return HttpClient(Darwin) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true isLenient true }) } } }这样在 commonMain 中使用时只需要调用createHttpClient()不需要关心底层引擎差异。6.3 数据层与本地存储游戏进度和玩家偏好需要持久化。在 Android 上通常使用 SharedPreferences 或者 Room。在 KMP 中这些 API 也需要统一封装。一个轻量级方案是使用 multiplatform-settings 库它提供了跨平台偏好存储的抽象// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/repository/PlayerPrefs.kt package com.example.planetassault.repository import com.russhwolf.settings.Settings import com.russhwolf.settings.get class PlayerPrefs( private val settings: Settings ) { var lastLevel: Int get() settings.getInt(last_level, 1) set(value) { settings.putInt(last_level, value) } var playerName: String get() settings.getString(player_name, 玩家) set(value) { settings.putString(player_name, value) } }注意Settings实例在 Android 端需要传入 Context 才能创建。因此shared 层中不应该自己实例化Settings而是通过工厂函数注入。这跟前面GamePlatform的写法保持一致。6.4 数据库方案的取舍如果游戏需要本地存储大量结构化数据比如关卡配置、玩家战斗记录那么需要选择一个跨平台数据库方案。SQLDelight 是目前 KMP 生态中最成熟的方案它通过定义 SQL 文件生成各平台的数据库访问代码。SQLDelight 的接入会增加项目复杂度但从“移植”的角度来说它能把数据库建表、查询逻辑全部放在共享层业务代码不用区分 Android 或 iOS 的数据库差异。对于“星球突击队”这类项目如果当前使用 Room那么移植时需要评估本地数据量是不是真的很大如果只是配置文件、进度存档这种轻量级数据用 multiplatform-settings 就够了不需要引入 SQLDelight。等到数据模型复杂到一定程度再切成本会更可控。6.5 依赖注入方案在 KMP 中依赖注入可以选择 Koin 或手动构造。考虑到移植的渐进性建议先手动管理依赖。在 commonMain 中创建一个简单的 ServiceLocator 或者直接使用工厂函数避免引入额外框架。例如// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/di/AppContainer.kt package com.example.planetassault.di import com.example.planetassault.game.GameEngine import com.example.planetassault.network.LeaderboardApi import com.example.planetassault.network.createHttpClient import com.example.planetassault.platform.createGamePlatform import com.example.planetassault.repository.PlayerPrefs object AppContainer { val gameEngine: GameEngine by lazy { GameEngine() } val leaderboardApi: LeaderboardApi by lazy { LeaderboardApi(createHttpClient()) } val playerPrefs: PlayerPrefs by lazy { PlayerPrefs(createSettings()) } private fun createSettings(): Settings { return createPlatformSettings() } }这里createPlatformSettings()也需要用 expect/actual 在各平台实现// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/platform/SettingsFactory.kt package com.example.planetassault.platform import com.russhwolf.settings.Settings expect fun createPlatformSettings(): Settings7. 移植后的流程验证与结果确认移植完成不是“编译通过”就结束了。对“星球突击队”来说验证要分好几个层面。7.1 编译验证在 Android Studio 中选择 Gradle 任务:shared:compileDebugKotlinAndroid可以验证 Android 目标的编译。验证 iOS 目标时在 mac 上执行./gradlew :shared:compileKotlinIosArm64如果编译通过说明共享代码基本符合各个平台的要求。但编译通过不代表逻辑正确还需要运行测试。7.2 单元测试验证共享逻辑的单元测试放在commonTest中。KMP 会自动在各平台执行这些测试。以ScoreCalculator为例// 文件路径shared/src/commonTest/kotlin/com/example/planetassault/game/ScoreCalculatorTest.kt package com.example.planetassault.game import com.example.planetassault.model.EnemyType import kotlin.test.Test import kotlin.test.assertEquals class ScoreCalculatorTest { Test fun testCalculateKillScoreForSoldier() { val score ScoreCalculator.calculateKillScore(EnemyType.SOLDIER, 1) assertEquals(100, score) } Test fun testCalculateKillScoreWithCombo() { val score ScoreCalculator.calculateKillScore(EnemyType.TANK, 3) assertEquals(300 * 7, score) } Test fun testCalculateTotalScore() { val total ScoreCalculator.calculateTotalScore(listOf(100, 200, 300)) assertEquals(600, total) } }在 Android Studio 中运行./gradlew :shared:allTests即可执行所有平台的测试。如果只想跑 iOS 模拟器测试可以执行./gradlew :shared:iosSimulatorArm64Test。7.3 Android 端集成验证在app模块中把原来直接调用GameEngine的地方改为调用AppContainer.gameEngine然后运行 Android App确认所有功能正常。这一步的重点是检查是否所有平台相关的调用都已经通过接口或者工厂函数隔离而不是在共享代码里直接引用了 Android API。7.4 iOS 端集成验证在 iOS 侧把 shared 模块编译生成的 framework 链接到 Xcode 工程中。由于 KMP 的 iOS framework 生成配置已经写好了iOS 端只需要在 Swift 代码中导入 framework 并调用。如果还没有 Xcode 工程可以先创建一个空工程测试 framework 能够正常加载并调用一个简单的函数再逐步接入完整逻辑。7.5 判断移植成功的关键指标从工程角度判断移植成功不能只看“能跑”还要看共享代码中是否还残留对 Android API 的引用commonMain 中代码覆盖率是否达到预期新增一个平台目标时是否只需要补充 expect/actual 实现构建产物是否包含所有目标平台。8. 移植过程中的常见问题与排查方法KMP 移植最难受的不是代码写不出来而是踩各种环境、依赖和编译问题。下面整理几个高频问题。问题现象可能原因排查方式解决方案commonMain 中调用System.currentTimeMillis()编译失败JVM API 在 commonMain 中不可用查看编译错误提示改用 expect/actual 封装或使用 kotlin.time 相关 APIiOS framework 生成失败提示缺少 CocoaPods 配置工程使用了 CocoaPods 集成方式但未配置 podspec查看 gradle 日志和 Podfile 配置按官方文档配置 KMP 与 CocoaPods 集成Android 可以编译iOS 编译时第三方库找不到该库不支持 iOS target检查库的发布元数据和源码集依赖配置为 iosMain 单独添加依赖或替换为支持 KMP 的库expect fun存在但actual fun找不到文件放在错误的源码集中检查文件路径和源码集名称将 actual 实现放到androidMain或iosMain对应目录所有的 shared 代码改完了app 模块仍然编译失败app模块还在引用被移动的旧文件查看import和类名更新 app 模块 import 路径或保留一个 deprecated 的 wrapper 类过渡iOS 测试运行时报 “unexpected service loaded”Ktor 引擎配置不一致查看测试日志中的 engine 加载信息在测试配置中明确指定引擎设置正确的 classpath 依赖使用 Gson 在 commonMain 中报错Gson 不支持 KMP将 Gson 引用替换为 kotlinx.serialization添加 kotlinx-serialization 插件并改注解Android 端构建时同时存在新旧两份数据模型类数据模型尚未完全迁移两个模块都在引用旧类检查AppContainer和 Android 侧是否引用了同一个类统一入口逐步淘汰旧文件排查时有一个通用技巧先用./gradlew :shared:compileDebugKotlinAndroid验证 Android 目标再用./gradlew :shared:compileKotlinIosArm64验证 iOS 目标。如果 Android 编译通过但 iOS 失败问题大概率出在 KMP 平台兼容性上。9. Kotlin Multiplatform 移植的最佳实践与工程建议9.1 从最容易的纯逻辑开始不要一次拆完移植最忌讳“大爆炸式”地一次性把所有代码迁到 shared 模块。正确的节奏是先抽离纯数据模型和纯计算函数保证编译通过、测试通过再抽离不依赖平台 API 的仓库类然后处理网络层把 HTTP 客户端替换成 Ktor最后封装平台能力引入 expect/actual。每完成一步就提交一次代码做一次回归测试。这样出现问题时能快速定位到最近一次改动。9.2 expect/actual 数量越少越好expect/actual 是抽象手段但它本身也有维护成本。如果发现很多 expect/actual 的 actual 实现几乎一样说明抽象不合理。更好的做法是优先在 commonMain 中寻找纯 Kotlin 跨平台 API。例如时间戳获取除了自定义 expect/actual也可以考虑使用 kotlin.time 以及 Kotlin 标准库中的跨平台时间 API。版本较新的 Kotlin 已经内置了一些时间工具团队应结合具体项目场景选择。9.3 网络层统一封装禁止平台层直接调用共享网络代码在 shared 模块中网络请求的入参和返回值都应该是普通 Kotlin 数据类。不要把 Ktor 的 Response 对象传到平台层也不要在 iOS 端直接操作 Ktor 对象。平台层只知道“有一个方法可以获取排行榜”不关心内部如何实现。9.4 接口先行构造器注入在 shared 层设计 API 时优先暴露接口而不是具体类。调用方依赖接口具体实现在 AppContainer 或依赖注入框架中装配。这样写测试时可以轻松替换 mock 对象也有利于后续接入 Koin 等框架。9.5 日志与异常处理要分层Android 上常用的Log.d在 Apple 平台不可用。shared 层的日志不应该直接调用 Android Log而是通过一个跨平台日志接口。简单的方式是定义// 文件路径shared/src/commonMain/kotlin/com/example/planetassault/platform/Logger.kt package com.example.planetassault.platform expect object Logger { fun d(tag: String, message: String) fun e(tag: String, message: String, throwable: Throwable? null) }Android 端实现转发给android.util.LogiOS 端转发给NSLog。9.6 版本管理避免多平台依赖冲突KMP 项目的依赖管理比单平台更严格。同一个库的 JVM 版本和 Native 版本可能不同如果不统一版本容易出现链接错误。建议在gradle/libs.versions.toml中集中管理所有依赖版本号并把 shared 模块的版本号与 app 模块保持一致。9.7 尽早接 CI多目标并行验证当项目开始支持 Android iOS 双平台后建议尽早配置 CI。CI 至少要包含这几个任务./gradlew :shared:allTests./gradlew :app:assembleDebug./gradlew :shared:compileKotlinIosArm64这样可以确保每次提交都能发现跨平台兼容性问题。9.8 关于“星球突击队”后续迭代的一点建议在完成第一步移植后团队往往会遇到新的纠结要不要继续把 ViewModel 层也放到 shared 里这可能要分情况看。如果游戏的状态管理逻辑确实复杂而且你希望 Android 和 iOS 的状态流转完全一致那么可以引入 ViewModel 到 shared 层。KMP 现在已经有对应的 ViewModel 支持方案但它在复杂 UI 场景下需要谨慎评估。更稳妥的增量路径是先把业务领域层完全沉淀到 shared让平台层只负责 UI 渲染和系统能力调用。等这个架构稳定运行一段时间之后再决定是否把 UI 状态管理也上提到共享层。“星球突击队”目前的架构如果按这个路径演进团队可以随时在“共享多少”这个维度上做调整而不需要受制于某个方案的定义。10. 总结与后续学习方向Kotlin Multiplatform 移植不是一个“非黑即白”的重写工程而是一个持续剥离和沉淀的过程。本文通过“星球突击队”这个项目视角梳理了从现有 Android 工程逐步迁移到 KMP 架构的完整路径。现在需要对这次的移植经验做一个复盘共享逻辑的边界在哪里、expect/actual 怎么设计、网络层和数据层如何跨平台、验证和排错流程是怎样的。最核心的收获是KMP 不是为了让所有代码都变成一份而是为了让业务逻辑资产脱离平台依赖从而在多个端上复用。如果你手头有一个 Kotlin 项目下一步可以这样做先画出模块依赖图把代码分成 SHARED、PLATFORM-ADAPT、ANDROID-ONLY 三类然后从最纯粹的模型和计算开始迁移。过程中用 AppContainer 统一管理依赖用 commonTest 持续验证共享逻辑。后续值得继续学习的方向包括Compose Multiplatform 是否要把 UI 层整合进来、SQLDelight 管理复杂本地数据、Koin 或自定义依赖注入框架在共享层中的应用、以及 CI 多目标并行测试的配置。每个方向都值得单独写一篇实践笔记但前提是先把“逻辑共享”这层地基打好。“星球突击队”的移植不是终点它只是证明了一个原本紧紧绑定在 Android 平台上的游戏项目可以通过合理的分层逐渐长出一套独立的跨平台核心。这个核心以后可以同时服务移动端、桌面端甚至未来可能出现的其他平台。
返回列表