开源鸿蒙平台 CMP 三方库「compose-multiplatform-2048」适配全流程
本文记录
compose-multiplatform-2048接入 OpenHarmony 的完整过程,覆盖 CMP 应用源集盘点、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建、签名和真机验收。
compose-multiplatform-2048是 Alex Lockwood 编写的 100% Jetpack Compose Multiplatform 2048 游戏,上游覆盖 Android、iOS、桌面和 Web 四端,游戏规则全部位于共享commonMain。本次适配把其中的纯 Kotlin 游戏引擎逐语义移植进 OpenHarmony ARM64 运行时,ArkUI 页面作为真机验收宿主,Compose Multiplatform(CMP)应用可以通过同一份引擎代码获得完全一致的规则行为。
项目地址: AtomGit/oh-tpc/compose-multiplatform-2048
上游仓库: alexjlockwood/compose-multiplatform-2048
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
compose-multiplatform-2048 用一份 commonMain 同时服务四个平台:domain 包承载棋盘数据模型,viewmodel/GameViewModel 承载移动/合并/计分/结束判定算法,ui 包用 Material3 渲染棋盘与动画,repository/GameRepository 通过 KStore 持久化对局。OpenHarmony 应用同样需要这套游戏能力,但上游默认构建不会生成 OpenHarmony 的 Kotlin/Native 变体。
如果只把示例页面改成 ArkTS,页面可以显示棋盘,却无法证明共享游戏规则已经在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证引擎层、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64/ohosX64 才能生成 OpenHarmony KLIB 与动态库。 |
| 工具链不一致 | 上游 Kotlin 2.0.20 / CMP 1.7.0 / AGP 8.7.1,鸿蒙社区线是 HarmonyOS Kotlin 2.2.21-1.0.0 / CMP 1.9.2,插件无法共存于同一 Gradle 构建。 |
| UI 全是 Compose | 棋盘、TopAppBar、动画全部依赖 androidx.compose,鸿蒙 CMP 工具链对不齐,UI 层无法直接编译。 |
| 持久化依赖缺失 | 上游用 KStore + okio + kotlinx-serialization 存档,ohos classpath 上没有对应变体。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在游戏引擎层、C ABI/N-API 桥接和 ArkUI 验收层。规则语义仍由 Kotlin 引擎负责,ArkTS 只负责调用 Native、解析 JSON、渲染棋盘和展示结果。
1.2 库提供的能力
上游 commonMain 提供的游戏能力按层拆分后,可观察场景包括:
- 移动:四方向滑动,网格旋转归一后按统一算法处理;
- 合并:相邻同值方块合并翻倍,合并结果生成新 tile id;
- 计分:得分增量为本次新增方块数值之和,最佳分跨对局保留;
- 生成:每次有效移动后随机补一枚方块,90% 为 2、10% 为 4;
- 结束判定:四个方向都无有效移动即游戏结束;
- 存档恢复:对局快照持久化,冷启动回放。
引擎入口 GameEngine 暴露:
| 成员 | 作用 |
|---|---|
startNewGame() | 开新局(分数清零、最佳分保留),返回初始两枚方块的 movements。 |
move(direction) | 执行一步移动,返回 MoveResult(moved, movements)。 |
restore(grid, score, best) | 从快照恢复对局,返回 noop movements。 |
grid/currentScore/bestScore/isGameOver | 只读状态,供桥接层序列化。 |
movements 列表与上游 GameViewModel 喂给 Compose 动画的数据同构:
| kind | 含义 |
|---|---|
add | 新方块(含合并结果与随机补位),fromRow/fromCol 为 -1。 |
shift | 方块滑动,保持原 tile id。 |
noop | 方块未动。 |
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | domain 四个文件从上游原样复制,引擎算法逐语义移植自 GameViewModel。 |
| 平台目标 | 引擎模块 jvm + ohosArm64 + ohosX64,双 ABI 动态库,JVM 留作交叉验证。 |
| 桥接稳定 | 单个 @CName("Game2048Command") 入口 + UTF-8 JSON,不传递 Kotlin 对象地址。 |
| CMP 兼容 | 上游四端工程零改动照常构建;ArkUI 只负责真机验收。 |
| UI 完整 | 棋盘、手势、两阶段动画、分数区、结束弹窗和存档恢复齐备。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 Compose UI 因工具链版本对不齐而保留在原工程;等鸿蒙 CMP 线与上游对齐后,可直接把
composeApp的 UI 接回同一份引擎。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点上游源集、四端入口和 OpenHarmony 交付物
第 2 阶段:目标与依赖打通 ── 独立 example 工程、HarmonyOS Kotlin 和双 ABI 目标
第 3 阶段:游戏引擎移植 ── domain 原样复制、GameEngine 逐语义移植、JVM 交叉验证
第 4 阶段:原生桥接 ── @CName 单入口、MiniJson、C++ N-API 和内存释放
第 5 阶段:ArkUI 宿主 ── 棋盘渲染、PanGesture、两阶段动画和 preferences 存档
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
composeApp/ 上游 CMP 应用源码(零改动,四端照常构建)
example/ 独立 Gradle 工程(HarmonyOS Kotlin 2.2.21-1.0.0)
├── game2048/ 纯 Kotlin 引擎:jvm + ohosArm64 + ohosX64
├── nativeApp/ Kotlin/Native 动态库 libohos2048.so + @CName 桥
└── harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
doc/ 本文章和真机效果图
example 是独立 Gradle 工程,自带 Gradle 8.14.1 wrapper,不把 DevEco 工程作为根项目的 Kotlin 子模块,Gradle 和 Hvigor 分开执行。上游根工程的 settings.gradle.kts、CI 与四端构建完全不动。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | example 工程使用 HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| Gradle | example 独立 wrapper 8.14.1,上游根工程保持 8.9 |
| JDK | 21 |
| OpenHarmony 目标 | ohosArm64 + ohosX64 |
| DevEco product | 5.1.0(18) / runtimeOS HarmonyOS |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.twentyfortyeight |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 页面边界
标题区 2048 标题 + 新游戏按钮
棋盘区 4×4 空格底板 + 按 (row, col) 定位的方块层
分数区 SCORE 当前分 / BEST 最佳分(位于棋盘下方,对齐上游竖屏布局)
状态区 引擎标识 libohos2048.so + 得分/最佳/存档状态
页面启动时读取 preferences 快照:有存档走 restore,无存档走 new。
第 2 阶段:目标与依赖打通
2.1 为什么上游不能直接加 ohosArm64
上游根工程同时挂着 AGP、Compose 编译器插件(版本随 Kotlin 2.0.20)和 wasmJs 目标;HarmonyOS Kotlin 2.2.21-1.0.0 是另一套插件与依赖线。两套工具链在同一个 Gradle 构建里必然冲突。独立 example 工程是唯一让上游零改动的路径。
2.2 仓库与插件
// example/settings.gradle.kts
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
google()
mavenCentral()
}
}
// dependencyResolutionManagement 同样加上述两个 HarmonyOS Maven 仓库
# example/gradle/libs.versions.toml
[versions]
kotlin = "2.2.21-1.0.0"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
# example/gradle.properties 关键项
kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true
2.3 引擎模块目标
// example/game2048/build.gradle.kts
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证:证明引擎没有偏离上游语义,也没偷依赖 ohos
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {} // 零第三方依赖,只用 Kotlin stdlib
}
}
第 3 阶段:游戏引擎移植
3.1 domain 层:原样复制
Cell、Direction、GridTile、GridTileMovement 四个文件从上游 composeApp/src/commonMain/.../domain/ 逐字复制,包名不变——将来工具链对齐后可直接换回 srcDir 引用。Tile 唯一的改动是摘掉 @Serializable 注解与自定义序列化器(ohos 侧不需要),自增 id 与 times 操作符保留:
data class Tile(
val num: Int,
val id: Int = tileIdCounter++,
) {
operator fun times(operand: Int): Tile = Tile(num * operand)
}
3.2 GameEngine:逐语义移植
引擎把上游 GameViewModel 的算法逐行搬过来,剥掉 mutableStateOf、ViewModel、viewModelScope 和 KStore,状态改为普通属性,持久化交给调用方:
class MoveResult(val moved: Boolean, val movements: List<GridTileMovement>)
class GameEngine {
var grid: List<List<Tile?>> = EMPTY_GRID
private set
var currentScore: Int = 0
private set
var bestScore: Int = 0
private set
var isGameOver: Boolean = false
private set
fun startNewGame(): List<GridTileMovement> { /* 洗牌空格,补两枚初始方块 */ }
fun restore(grid: List<List<Tile?>>, currentScore: Int, bestScore: Int): List<GridTileMovement> { /* 快照回放 */ }
fun move(direction: Direction): MoveResult { /* 见下 */ }
}
核心移动算法保持上游原貌:先把网格旋转 N 次,让四个方向都按"从右向左滑"处理,每行单趟线性扫描完成 shift/merge,再旋转回去:
val numRotations = when (direction) {
Direction.WEST -> 0
Direction.SOUTH -> 1
Direction.EAST -> 2
Direction.NORTH -> 3
}
var updatedGrid = grid.rotate(numRotations)
// 每行单趟扫描:lastSeenTileIndex / lastSeenEmptyIndex 两个游标
// 同值相邻 → shift 到目标格 + add 合并结果(新 tile id)
// 有空位 → shift 左移
// 否则 → noop
updatedGrid = updatedGrid.rotate((-numRotations).floorMod(4))
计分、生成与结束判定同样照搬上游语义:
val scoreIncrement = updatedGridTileMovements
.filter { it.fromGridTile == null }
.sumOf { it.toGridTile.tile.num } // 得分增量 = 新增方块数值之和
currentScore += scoreIncrement
bestScore = max(bestScore, currentScore)
// ...
isGameOver = checkIsGameOver(grid) // 四方向都无有效移动
3.3 交叉验证
cd example
./gradlew :game2048:compileKotlinJvm
引擎在纯 JVM 上编译通过,证明它没有偷偷依赖任何 ohos 或 Kotlin/Native 特有的东西,语义也没有偏离上游。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string(game2048Command)
▼
C++ N-API entry(libgame2048_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohos2048.so)
│ Game2048Command
▼
UTF-8 JSON + explicit free
ArkTS 不能直接 import libohos2048.so——HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务符号挂到 ArkTS,必须经 CMake NAPI 包装。
4.2 Kotlin/Native 导出函数
@CName("Game2048Command")
fun game2048Command(request: String): String {
return try {
val req = Json.parse(request) as? Json.Obj ?: error("request must be a JSON object")
when (req.str("action").lowercase()) {
"new" -> respond("new", moved = true, movements = engine.startNewGame())
"move" -> {
val result = engine.move(parseDirection(req.str("direction")))
respond("move", result.moved, result.movements)
}
"restore" -> respond("restore", moved = true, movements = restoreFromRequest(req))
"state" -> respond("state", moved = false, movements = currentGridAsNoops())
else -> error("unknown action")
}
} catch (t: Throwable) {
Json.Obj(linkedMapOf("ok" to Json.Bool(false), "error" to Json.Str(t.message ?: "unknown"))).serialize()
}
}
单入口、一进一出:以后加 action(比如 undo)只动分发逻辑,C 签名永远不变。
4.3 JSON 协议
请求:
| action | 参数 | 说明 |
|---|---|---|
new | — | 开新局,最佳分保留。 |
move | direction: NORTH/SOUTH/EAST/WEST | 执行一步,moved=false 表示无效滑动。 |
restore | grid(4×4 数字/null)、currentScore、bestScore | 冷启动回放快照,tile id 由引擎重新分配。 |
state | — | 查询当前快照,不改变状态。 |
响应:
{
"ok": true, "action": "move", "moved": true,
"score": 4, "best": 20, "gameOver": false,
"grid": [[2, null, null, null], ["..."]],
"movements": [
{"kind": "shift", "id": 3, "num": 2, "fromRow": 0, "fromCol": 3, "toRow": 0, "toCol": 1},
{"kind": "add", "id": 7, "num": 4, "fromRow": -1, "fromCol": -1, "toRow": 0, "toCol": 1}
]
}
失败返回 {"ok":false,"error":"..."}。ohos classpath 上没有 kotlinx.serialization,桥接层手写递归下降 MiniJson(对象/数组/字符串/数字/布尔/null、转义与 \uXXXX 全支持),零第三方依赖。
4.4 C++ N-API 与 CMake
static napi_value NapiGame2048Command(napi_env env, napi_callback_info info) {
// 1. 校验参数必须是 string,UTF-8 转换
// 2. const char *raw = Game2048Command(request.c_str());
// 3. napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
// 4. DisposeKnString(raw); ← 立即释放 Kotlin/Native 字符串
}
static void DisposeKnString(const char *raw) {
libohos2048_ExportedSymbols *symbols = libohos2048_symbols();
if (symbols != nullptr && symbols->DisposeString != nullptr) {
symbols->DisposeString(raw);
}
}
add_library(ohos2048 SHARED IMPORTED)
set_target_properties(ohos2048 PROPERTIES
IMPORTED_LOCATION ${KN_SO} # entry/libs/${OHOS_ARCH}/libohos2048.so
IMPORTED_NO_SONAME TRUE)
add_library(game2048_napi SHARED napi_init.cpp)
target_link_options(game2048_napi PRIVATE "-Wl,--no-undefined" "-Wl,-rpath,\$ORIGIN")
target_link_libraries(game2048_napi PUBLIC libace_napi.z.so libhilog_ndk.z.so ohos2048)
# POST_BUILD 把 libohos2048.so 和 libc++_shared.so 拷到 NAPI so 旁,$ORIGIN 在 dlopen 时可解析
头文件 include/libohos2048_api.h 只声明用到的两个服务符号(DisposeStablePointer/DisposeString)与 Game2048Command,与 Kotlin/Native 生成的完整头文件前两个成员布局一致。类型声明一行:
export const game2048Command: (request: string) => string;
4.5 双 ABI 产物
// example/nativeApp/build.gradle.kts
ohosArm64 { binaries.sharedLib { baseName = "ohos2048" } }
ohosX64 { binaries.sharedLib { baseName = "ohos2048" } }
// linkDebugSharedOhosArm64/X64 完成后自动 Copy 到
// example/harmonyApp/entry/libs/{arm64-v8a,x86_64}/(含 libc++_shared.so)
真机是 arm64,DevEco 模拟器是 x86_64;HAP 里缺哪个 ABI 的 so,安装就报 9568347,所以链接命令必须两个 ABI 一起跑。
第 5 阶段:ArkUI 宿主
5.1 棋盘与手势
棋盘用 Stack({ alignContent: Alignment.TopStart }) 承载两层:16 个空格底板 + 按方块 id 作 ForEach key 的方块层,位置由 cellPos(row, col) 绝对定位。方向判定用 PanGesture,只取结束时的总位移:
.gesture(
PanGesture({ fingers: 1, distance: 8 })
.onActionEnd((event: GestureEvent) => {
const dx: number = event.offsetX;
const dy: number = event.offsetY;
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;
if (Math.abs(dx) > Math.abs(dy)) {
this.doMove(dx > 0 ? 'EAST' : 'WEST');
} else {
this.doMove(dy > 0 ? 'SOUTH' : 'NORTH');
}
})
)
5.2 两阶段动画
ArkTS 不复制规则,只消费 movements,把上游 Compose 动画拆成两个阶段:
阶段 1(110ms EaseOut) shift/noop 方块按 id 匹配,animateTo 滑到目标格
阶段 2(160ms Smooth) 换入最终方块集:
合并双方移除(TransitionEffect 淡出)
合并结果 + 随机补位插入(scale 0.3 → 1 缩放进场)
kind=add 落在哪个格子,就把之前 shift 进该格的方块替换掉——最终方块集完全由引擎 movements 推导,UI 不做任何规则判断。
5.3 配色与布局
方块配色逐色取自上游 ui/GameGrid.kt 浅色配色表(2 #50c0e9、4 #1da9da、8 #cb97e5 …… 16384 #9933cc,空格 #dddddd);竖屏布局对齐上游 GameLayout:棋盘居中在上,SCORE/BEST 分数行在棋盘正下方左右分列。
5.4 持久化
const state: SavedState = {
grid: resp.grid,
currentScore: resp.score ?? 0,
bestScore: resp.best ?? 0,
};
this.prefs.put(SAVE_KEY, JSON.stringify(state)).catch(/* hilog */);
this.prefs.flush().catch(/* hilog */);
上游各平台的 KStore 存档在鸿蒙侧由 @ohos.data.preferences 等价替换:每次有效落子后存快照,冷启动解析后调 restore,语义与上游一致(最佳分跨对局保留)。
第 6 阶段:示例与验证
6.1 构建 Native 产物
cd example
./gradlew :nativeApp:linkDebugSharedOhosArm64 :nativeApp:linkDebugSharedOhosX64
# 产物自动拷入 example/harmonyApp/entry/libs/{arm64-v8a,x86_64}
./gradlew :game2048:compileKotlinJvm # 引擎交叉验证
6.2 HAP 构建
用 DevEco Studio 打开 example/harmonyApp(不是仓库根目录),Build HAP:
BuildNativeWithNinja: finished
CompileArkTS: finished
PackageHap: finished
SignHap: finished
BUILD SUCCESSFUL
6.3 安装与启动
hdc list targets -v
hdc -t <设备序列号> install -r \
entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
-a EntryAbility -b org.terminator.ohos.twentyfortyeight -m entry
四、完整代码对照
4.1 整体架构
GameEngine.kt(domain 原样复制 + GameViewModel 逐语义移植)
│ startNewGame / move / restore
▼
Bridge.kt
│ @CName("Game2048Command"),MiniJson in/out
▼
libgame2048_napi.so
│ game2048Command(request: string): string
▼
Index.ets
│ JSON parse + 两阶段动画 + preferences 存档
▼
ArkUI 真机棋盘
4.2 文件清单
| 文件 | 职责 |
|---|---|
example/game2048/build.gradle.kts | jvm + ohosArm64 + ohosX64 三目标 |
example/game2048/.../domain/*.kt | Cell/Direction/GridTile/GridTileMovement 原样复制,Tile 剥离序列化 |
example/game2048/.../engine/GameEngine.kt | 移动/合并/计分/结束判定/恢复,逐语义移植 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib 与产物自动拷贝 |
example/nativeApp/.../Bridge.kt | @CName 单入口、action 分发和 JSON 响应 |
example/nativeApp/.../MiniJson.kt | 手写递归下降 JSON 解析/序列化,零依赖 |
example/harmonyApp/.../napi_init.cpp | N-API 导出、参数校验和 DisposeString |
example/harmonyApp/.../CMakeLists.txt | IMPORTED KN so、$ORIGIN rpath、POST_BUILD 拷贝 |
example/harmonyApp/.../Index.ets | ArkUI 棋盘、手势、动画、存档 |
README.OpenHarmony.md / _CN.md | OpenHarmony 双语文档 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | GameEngine.startNewGame() | 开局并返回初始 movements |
| 引擎 | GameEngine.move(direction) | 一步移动,返回 MoveResult |
| 引擎 | GameEngine.restore(...) | 快照回放 |
| Native | Game2048Command | 唯一 C ABI 入口,JSON 进 JSON 出 |
| Native | libohos2048_symbols()->DisposeString | 释放 KN 字符串 |
| N-API | game2048Command | 向 ArkTS 暴露引擎调用 |
| ArkTS | doMove/send/finalTiles | 发请求、两阶段动画、推导最终方块集 |
| ArkTS | persistState/init | preferences 存档与冷启动恢复 |
4.4 ArkTS 与 Kotlin 的边界
const raw: string = game2048Command(JSON.stringify({ action: 'move', direction: direction }));
const resp: EngineResponse = JSON.parse(raw) as EngineResponse;
@CName("Game2048Command")
fun game2048Command(request: String): String
两者之间只传输 JSON 字符串:不传 Kotlin 对象地址、不传未校验的动态结构,movements 里的 tile id 是两端唯一的动画契约。
五、关键决策说明
决策 1:上游零改动 + 独立 example 工程
上游是活的 CMP 应用工程(Android/iOS/Desktop/Web + CI)。与其在根工程里塞进一套互相冲突的插件,不如把鸿蒙侧整体放进 example/ 独立 Gradle 工程:上游四端照常构建发布,鸿蒙工具链独立演进。
决策 2:引擎与 UI 分层,Compose 不进 ohos
上游 UI 深度绑定 Compose(LayoutModifierNode 自定义动画、Material3)。鸿蒙 CMP 线与上游版本对不齐时,硬编 UI 只会引入不可控的兼容层。把规则引擎完整移植、UI 用 ArkUI 重绘,是社区已过审库(Vico / Reorderable / qrose)验证过的同一条路径。
决策 3:单入口 JSON + 手写 MiniJson
一个 @CName 函数、一进一出。协议扩展只动 Kotlin 分发逻辑,C ABI、N-API 与 ArkTS import 永不改变。ohos classpath 上没有序列化库,手写 150 行递归下降解析器比引入一串版本依赖更可控。
决策 4:tile id 作为跨端动画契约
上游用自增 tile id 驱动 Compose 动画;本次适配把这个 id 原样带过 NAPI,ArkTS 用它做 ForEach key——同一枚方块滑动时 id 不变(位置动画),合并结果与随机补位拿新 id(缩放进场)。规则层与动画层因此解耦。
决策 5:preferences 等价替换 KStore
上游 GameRepository 的"每步落子即存、冷启动即恢复"语义,在鸿蒙侧由 preferences + restore action 等价实现,存档格式(grid + currentScore + bestScore)与上游 UserData 字段一一对应。
决策 6:签名材料与源码分离
DevEco 自动签名会把本机证书路径与加密密码写进 build-profile.json5。仓库里 signingConfigs 永远留空占位,签名在本机手动完成——本次提交前实测剥离过一次自动签名写入的材料,证书绝不进 AtomGit。
六、测试与验证
6.1 测试环境
本次验证使用 macOS(arm64)、JDK 21、Gradle 8.14.1、DevEco Studio 5.1.0 Release(内置 OpenHarmony SDK 与 hvigor)、HarmonyOS 真机(arm64)与 DevEco 模拟器(x86_64)。
6.2 引擎交叉验证
cd example
./gradlew :game2048:compileKotlinJvm :game2048:compileKotlinOhosArm64
引擎同时在 JVM 与 ohosArm64 上编译通过,证明移植代码不依赖任何平台特有 API。
6.3 Native 链接验证
> Task :nativeApp:linkDebugSharedOhosArm64
> Task :nativeApp:copyKnSoArm64
> Task :nativeApp:linkDebugSharedOhosX64
> Task :nativeApp:copyKnSoX64
BUILD SUCCESSFUL
产物:libohos2048.so(arm64 约 8.5MB、x86_64 约 7.7MB)+ libc++_shared.so,双 ABI 齐备;Kotlin/Native 生成的头文件符号 Game2048Command / libohos2048_symbols 与手写声明一致。
6.4 HAP 验证
CompileArkTS: finished (1 s 254 ms)
PackageHap: finished
SignHap: finished
BUILD SUCCESSFUL in 3 s 274 ms
entry-default-signed.hap (7.6 MB)
6.5 功能用例
- 首次启动无存档,自动开新局,出现两枚初始方块;
- 四方向滑动:无效方向棋盘不动,有效方向方块滑动并在空位补新方块;
- 同值方块相撞合并翻倍,合并结果缩放进场,得分即时累加;
- SCORE/BEST 实时刷新,新游戏后最佳分保留;
- 棋盘无可动方块时弹出"游戏结束"对话框,可一键再来一局;
- 杀进程冷启动后棋盘、当前分与最佳分从 preferences 快照恢复;
- 引擎状态行显示"得分 x · 最佳 x · 已存档",证明每次落子都经过 Native 引擎并落盘。
6.6 验证结论
Kotlin/Native 双 ABI 动态库、C ABI、C++ N-API、ArkUI 棋盘和签名 HAP 已完成链路验证。应用在 HarmonyOS 真机上成功安装并进入前台,滑动、合并、计分、结束判定与存档恢复全部由 libohos2048.so 内的 Kotlin 引擎驱动。
七、运行效果
7.1 真机截图

截图中可以看到:
- 页面标题为"2048",右侧是"新游戏"按钮;
- 棋盘上 2/4/8 方块配色与上游
GameGrid.kt浅色配色表一一对应; - SCORE 与 BEST 分数行位于棋盘正下方,与上游竖屏布局一致;
- 底部状态行显示"Compose Multiplatform 2048 · Kotlin/Native 引擎 libohos2048.so"与"得分 20 · 最佳 20 · 已存档",证明对局由 Native Kotlin 引擎驱动且每步已落盘。
7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-multiplatform-2048.git
cd compose-multiplatform-2048
export JAVA_HOME="/path/to/jdk-21"
(cd example && ./gradlew :nativeApp:linkDebugSharedOhosArm64 \
:nativeApp:linkDebugSharedOhosX64 :game2048:compileKotlinJvm)
# DevEco Studio 打开 example/harmonyApp,配置自动签名后 Build HAP
hdc install entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.twentyfortyeight -m entry
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算适配,必须真正编译
ohosArm64/ohosX64动态库并打进 HAP; - ArkTS 不能 import Kotlin/Native 的
libohos2048.so,@CName符号必须经 CMake NAPI 包装暴露; - KN 返回的 C 字符串在
napi_create_string_utf8之后必须立即DisposeString,否则泄漏; Area.width的类型是Length(string | number | Resource),直接赋给number会编译失败,需typeof收窄;preferences.put/flush返回 Promise,不接.catch会触发 ArkTS 告警;- 模拟器是 x86_64,缺对应 so 安装报
9568347,双 ABI 必须都链接; - DevEco 自动签名会把证书路径和加密密码写进
build-profile.json5,提交前必须剥离,签名材料绝不进仓库。
8.2 已知问题
- 当前 ArkUI 宿主为浅色主题单页面,未跟随系统深色模式(上游有双主题配色表可补);
- 动画相对上游
LayoutModifierNode的自定义动画做了简化(滑动 + 合并缩放两阶段); - ArkUI 示例不是 CMP UI;上游 Compose 界面等鸿蒙 CMP 工具链对齐后才能接回;
- MiniJson 为示例手写实现,生产业务可替换为正式序列化库;
- HAP 安装必须使用与 bundle name 匹配的签名。
8.3 未来优化方向
- 协议增加
undo等 action,只动 Kotlin 分发层; - 补深色主题配色与方块出场音效/振动反馈;
- 增加 Compose Multiplatform 示例宿主,验证同一引擎双 UI 消费;
- 在 CI 中加入 Native 链接、ELF 符号检查和 HAP 构建;
- 补充不同设备 ABI 与 API 版本的验收矩阵。
九、总结
9.1 核心难点回顾
上游 commonMain 游戏规则(domain + GameViewModel)
→ 独立 example 工程(HarmonyOS Kotlin 2.2.21-1.0.0)
→ GameEngine 逐语义移植(jvm 交叉验证)
→ ohosArm64/ohosX64 Kotlin/Native 动态库
→ C ABI Game2048Command + MiniJson
→ C++ N-API libgame2048_napi.so
→ ArkTS JSON 解析 + 两阶段动画 + preferences 存档
→ ArkUI 真机棋盘 + 签名 HAP
9.2 封装层次
- 引擎层:domain 原样复制 + 移动/合并/计分/结束判定/恢复逐语义移植,零第三方依赖;
- Native 层:双 ABI 动态库,单一
@CName入口输出 JSON; - N-API 层:参数类型检查、字符串转换和 KN 内存释放,不做业务判断;
- ArkTS 层:棋盘渲染、手势、两阶段动画、快照存档和错误边界;
- DevEco 层:CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 应用型 CMP 仓库适配,先把"值得共享的纯 Kotlin"圈出来(这里是游戏规则),UI 留给 ArkUI 重绘,比硬对齐两套 Compose 工具链靠谱;
- 跨语言边界只传 JSON 和稳定的 id 契约(tile id),规则判断永远只在一端存在;
- 每一层的可用性单独留证:JVM 编译、双 ABI 链接、hvigor 构建、真机运行逐层验收,避免把编译成功误认为设备链路可用。
9.4 适配成果
当前适配已完成:
- 上游仓库零改动,鸿蒙侧完整收在
example/独立工程; game2048引擎模块:jvm + ohosArm64 + ohosX64 三目标,domain 原样复用;libohos2048.so双 ABI 动态库与 CMake NAPIlibgame2048_napi.so;- ArkUI 2048 宿主:手势、两阶段动画、结束弹窗和 preferences 存档;
- HAP 构建、签名、设备安装和 HarmonyOS 真机验证;
- AtomGit 项目文档、OpenHarmony README 四件套和本地效果图。
适配过程 FAQ
以下问题来自
ohosArm64目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。
Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?
- 确认 DevEco Studio 版本与工程
build-profile.json5的compatibleSdkVersion一致; - 在
File > Settings > Build, Execution, Deployment > Ohpm配置可用的 ohpm 仓库镜像或代理; - 删除
oh_modules、oh-package-lock.json5与.hvigor缓存后重新 Sync; - 公司网络需要代理时,同时给 ohpm 与 hvigor 配置
http.proxy。
Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?
- 先在
example目录执行./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用linkDebugSharedOhosX64); - 确认
libxxx.so与libc++_shared.so已复制到ohos/entry/libs/<abi>/; - 检查
entry/build-profile.json5的abiFilters与设备 ABI 是否一致(真机arm64-v8a,模拟器x86_64); - 清理
entry/.cxx与entry/build后重试。
Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?
- 确认 CMake 的
add_library(<target> SHARED ...)名称与import ... from 'libxxx_napi.so'完全一致; - 解包 HAP,确认
libs/<abi>/libxxx_napi.so已打包; - 删除
entry/build、entry/.cxx与oh_modules后重新构建; - 检查
module.json5的srcEntry与页面路径是否正确。
Q4:真机安装报 signature verification failed 或 install failed?
- 在 DevEco Studio 的
Signing Configs配置证书、Profile 与密码; bundleName必须与签名 profile 中的 bundle name 一致;- 设备需加入 profile 的
deviceIds,调试证书有效期需覆盖当前时间; - 用
hdc list targets确认设备在线,再执行hdc install -r xxx-signed.hap。
Q5:应用启动白屏、闪退或 NAPI 调用崩溃?
- 用
hdc hilog | grep -i "napi\|kotlin"查看原生日志; - 确认
libc++_shared.so与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致; - 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用
XxxFree释放; - 在 DevEco 的 Native 调试配置中附加
lldb,定位空指针与越界访问。
Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?
- 使用鸿蒙发行版 Kotlin(如
2.2.21-1.0.0),并在settings.gradle.kts配置 eazytec 仓库; - Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
- 确认
kotlin { ohosArm64() }已应用,且local.properties的 SDK 路径有效; - 清理
~/.gradle/caches中对应版本的 Kotlin/Native 缓存后重试。
Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?
- 对齐 DevEco Studio、HarmonyOS SDK 与
build-profile.json5中的compatibleSdkVersion; - 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免
modelVersion与 IDE 不匹配; - 命令行构建时设置
DEVECO_SDK_HOME指向 DevEco 的sdk目录。
Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?
- 使用 DevEco 自带的 node 与 hvigor:
"$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...; - 设置
JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME; - 加上
--no-daemon排除守护进程缓存影响,并清理entry/build与.hvigor。
参考文档
更多推荐

所有评论(0)