开源鸿蒙平台 CMP 三方库「2048 Compose」适配全流程
本文记录
2048-kotlin-compose-material3接入 OpenHarmony 的完整过程,覆盖上游应用盘点、游戏引擎抽取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
2048-kotlin-compose-material3是 Alex Lockwood 经典 2048 的 Android Jetpack Compose Material 3 整理版:滑动合并数字块、位移动画、计分与 SharedPreferences 存档。本次适配与 KStore 的"整库真编译"路线有本质区别:上游是一个应用而不是库,Compose 壳(Hilt、androidx.lifecycle、ConstraintLayout-Compose、Compose BOM 2023.09)在鸿蒙工具链上没有对应物;但它的核心资产——GameViewModel里的规则引擎(网格旋转规范化、单遍扫描合并、90/10 随机新块、计分时序、终局判定、动画事件流)是纯 Kotlin。因此走的是 qrose / Vico / Reorderable 验证过的引擎抽取路线:规则逐字抽成game2048KMP 模块原样编译成 OpenHarmony KLIB,UI 由 ArkUI 复刻并消费同一份movements动画事件流——上游怎么算,鸿蒙就怎么算;上游怎么动,鸿蒙就怎么动。
项目地址: AtomGit/oh-tpc/2048-kotlin-compose-material3.git
上游仓库: alexjlockwood/twenty-forty-eight
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
上游为 Android 单 Activity Compose 应用:4×4 棋盘、四方向滑动、合并翻倍、计分与最高分、Animatable 驱动的位移动画与新块弹出、GameRepository(SharedPreferences + Gson)负责存档。它的全部规则住在 viewmodel/GameViewModel.kt(244 行)里,ui/ 目录下另有 861 行 Compose 界面代码(GameGrid / GameScreen / GameDialog / AppTheme 等)。KMP 思路下这些规则本应放 commonMain 由多端共享,但上游构建(Kotlin 1.9.10 / AGP 8.1.1 / Compose BOM 2023.09.01 / Hilt 2.48)不会生成 OpenHarmony 变体。
如果只把游戏页面改成 ArkTS,页面可以显示棋盘,却无法证明上游的规则引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行——合并不级联、计分时序这些细节一旦重写就会漂移。适配需要同时验证规则引擎、动画事件模型、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 1.9.10 / AGP 8.1.1 / Gradle 8.3 与鸿蒙工具链无法共存于一个 Gradle 构建,根构建必须整体替换。 |
| 依赖矩阵 | Hilt(KSP 注入)、androidx.lifecycle、androidx.compose.*、Gson、ConstraintLayout-Compose 全是 Android 专属,没有任何一个有 ohos 变体。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 动画语义跨不了桥 | 上游用 Compose Animatable 消费 gridTileMovements 事件列表,事件流必须以数据形式过桥,ArkUI 才能复刻而不是翻译。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:规则引擎从 GameViewModel 逐字抽取后的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。游戏规则完全由抽取的引擎负责,ArkTS 只负责手势识别、动画编排、持久化和展示。
1.2 库提供的能力
Demo 提供一局完整的 2048 游戏,对应桥接的全部入口:
- 新游戏(new):
engine.newGame()在空盘上生成 2 个随机初始块(90% 出 2 / 10% 出 4),返回全量状态与movements动画事件; - 四方向移动(move):
engine.move(direction)执行旋转规范化 + 单遍扫描滑动合并,返回moved标记、合并后棋盘、本步动画事件与存档快照;撞墙方向返回moved:false,棋盘不动、不刷新随机块; - 状态查询(state):随时读取当前
grid/ 分数 / 步数 / 终局标记,与画面逐格对账; - 存档恢复(restore):
engine.restore(snapshot)从快照字符串整局还原——杀进程重启后进度仍在,对应上游 SharedPreferences 语义(鸿蒙侧落到@ohos.data.preferences); - 版本握手(version):返回
1.0.0-ohos.1 · 4x4,来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok / error | 操作是否成功,失败时 error 带原文。 |
moved | 仅 move 返回:本步是否改变棋盘,UI 据此决定"不动就不刷新块"。 |
grid[4][4] | 行主序棋盘,空格 null,其余为块面值。 |
movements[] | 每步动画事件 {f, t, n}:f !== null 是位移,f === null 是新增(合并产物或随机块);shift 在前、add 在后。 |
score / best / moveCount / gameOver | 当前分 / 最高分(跨局存活)/ 有效步数 / 终局标记。 |
snapshot | 存档字符串(扁平 JSON),ArkTS 不透明、整串持久化。 |
引擎侧完整能力(旋转规范化、合并不级联、计分时序、终局判定、快照往返)全部在 game2048/src/commonMain 里随编译进入 .so,JVM 侧保留 10 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的规则没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | GameEngine 的移动 / 合并 / 计分 / 终局算法从上游 GameViewModel 逐字抽取,一行不改;平台壳替换为可注入依赖(Random 构造注入、floorMod → Int.mod、持久化外移为 snapshot / restore)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 Android app 模块留盘不进构建。 |
| 桥接稳定 | 单个 C ABI Game2048Action + UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 引擎零第三方依赖(纯 Kotlin 自包含),不依赖 kotlinx 制品线,不引入解析风险。 |
| UI 完整 | 单页覆盖全部桥接入口:手势滑动、动画编排、分数框、新游戏 / 恢复存档、终局弹窗、状态行和版本握手。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 Compose 壳与社区 CMP 工具链(1.9.2 / HarmonyOS Kotlin 2.2.21-1.0.0)版本对不齐,UI 由 ArkUI 复刻;但它消费的是与上游 Compose 相同的
movements事件流,动画节奏有数据依据,不是凭感觉重画。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点上游模块与依赖边界、确认"引擎抽取"路线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 钉版
第 3 阶段:引擎抽取 ── GameViewModel 规则逐字抽取、平台壳替换、10 项 jvmTest 规则钉版
第 4 阶段:桥协议与原生桥接 ── @CName C ABI、movements JSON 契约、C++ N-API、内存释放
第 5 阶段:ArkUI 宿主 ── 手势判向、两段动画、终局弹窗、preferences 存档、明暗双主题
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
game2048/ 抽取的游戏引擎(进 ohos 构建,jvm + ohosArm64 + ohosX64)
src/commonMain/.../domain/ Cell / Direction / GridTile / GridTileMovement / Tile
src/commonMain/.../game/ GameEngine(规则核心)/ GameState(快照模型)
src/jvmTest/ 10 项规则测试(JVM 交叉验证)
app/ 上游 Android Compose 应用(留盘,不进 settings.gradle.kts)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :game2048 与 :example:nativeApp,rootProject.name = "ohos_2048_compose"。上游的 AGP classpath、Android app 模块全部随根构建替换剥离。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 写进 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从上游 8.3 升级;2.2.21 线配套版本,与 qrose / Vico / Reorderable 一致) |
| 引擎依赖 | 零第三方依赖(纯 Kotlin 自包含) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.game2048 |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 上游位置 | 内容 | 依赖形态 | 处置 |
|---|---|---|---|
viewmodel/GameViewModel.kt | 全部游戏规则(244 行) | 纯算法 + androidViewModel / Hilt / StateFlow 壳 | 逐字抽取为 GameEngine(壳替换) |
repository/GameRepository.kt | SharedPreferences + Gson 存档 | Android 专属 | 语义外移:引擎出 snapshot(),宿主自选持久化 |
ui/(861 行) | Compose 界面、动画、主题 | Compose BOM / Material3 / ConstraintLayout | 留盘不进构建,ArkUI 复刻 |
di/、MainActivity、MainApplication | Hilt 装配 | Android 专属 | 留盘不进构建 |
抽取的增量只有三处,全部是"壳换依赖",算法零改动:
class GameEngine(private val random: Random = Random.Default) // Math.random → 构造注入
updatedGrid.rotate((-numRotations).mod(GRID_SIZE)) // floorMod → Kotlin mod
fun snapshot(): GameState / fun restore(state: GameState) // 持久化外移,宿主负责落盘
1.4 页面边界
标题区 2048 / 副标题(滑动合并相同数字,冲向 2048!)
分数区 当前分数 / 最高分 双卡片
操作区 新游戏 / 恢复存档 双按钮
棋盘区 4×4 空格底板 + 块层(Stack 叠放,position 定位,PanGesture 手势)
状态行 步数 / 进行中 / 游戏结束 / 错误信息
关于区 版本握手、架构分层、仓库与社区链接
页面启动时先调 {"action":"version"} 做握手,再读 preferences 存档决定 restore 还是 new。
第 2 阶段:目标与依赖打通
2.1 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
ohosArm64() / ohosX64() 只存在于 HarmonyOS Kotlin 插件,社区此前批准的钉法是 2.2.21-1.0.0(qrose / haze / Vico / Reorderable 同线),配套 Gradle 8.14.1。上游是 Kotlin 1.9.10 + AGP 8.1.1 的 Android 构建,根构建整体替换,不存在元数据兼容问题——引擎抽取后零依赖,也不需要 kotlinx 制品线。settings.gradle.kts 把 eazytec 仓库写在最前:
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()
}
}
rootProject.name = "ohos_2048_compose"
include(":game2048")
include(":example:nativeApp")
仓库顺序是硬约束:eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64。
2.2 编译真实引擎
game2048/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
库模块不开 binaries.sharedLib——sharedLib 只放 example/nativeApp,否则每个模块都吐一份带 Kotlin/Native 运行时的 so。实测:
./gradlew :game2048:compileKotlinOhosArm64 :game2048:compileKotlinOhosX64 # BUILD SUCCESSFUL
2.3 测试任务的处置
根构建全局禁用 ohos 测试任务(HarmonyOS Kotlin 的 ohos 原生测试任务不完备),测试统一在 JVM 跑:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
第 3 阶段:引擎抽取(本适配的成败手)
3.1 抽取原则
规则一行不改,改的只有壳。上游算法的五个关键点逐字保留:
| 规则 | 上游实现 | 抽取后 |
|---|---|---|
| 旋转规范化 | 四方向统一转成"向左滑":WEST 0 次 / SOUTH 1 次 / EAST 2 次 / NORTH 3 次 | 同 |
| 单遍扫描 | 每行维护 lastSeenTileIndex / lastSeenEmptyIndex,遇块要么 noop、要么 shift、要么合并 | 同 |
| 合并不级联 | 合并后 lastSeenTileIndex 置空——4 | 2 2 只会变成 4 | 4 | 同 |
| 计分时序 | 增量 = 本步 add 事件面值和,在随机块入列之前计算——合并块计分、随机新块不计分 | 同(jvmTest 有断言钉死) |
| 终局判定 | 四方向各跑一次 makeMove,全部无变化即结束 | 同 |
fun move(direction: Direction): Boolean {
var (updatedGrid, updatedGridTileMovements) = makeMove(grid, direction)
if (!hasGridChanged(updatedGridTileMovements)) {
return false // 撞墙:不动、不刷块、不计步
}
val scoreIncrement = updatedGridTileMovements
.filter { it.fromGridTile == null }
.sumOf { it.toGridTile.tile.num } // 此时随机块尚未入列 → 上游计分怪癖
currentScore += scoreIncrement
bestScore = max(bestScore, currentScore)
// ... 追加随机块、排序(shift 前 add 后)、终局判定、步数 +1
}
3.2 动画事件模型
上游用 GridTileMovement(add / shift / noop 三类事件)驱动 Compose Animatable。这套模型本身就是跨语言的数据契约,原样过桥:
sealed 三类事件 → JSON: {"f":[fromRow,fromCol]|null,"t":[toRow,toCol],"n":面值}
f !== null → 位移(ArkUI 侧做 120ms 平移动画)
f === null → 新增(合并产物或随机块,ArkUI 侧做缩放弹出)
noop → 不出桥(UI 只需要"谁动了、谁生了")
3.3 快照与随机数
fun snapshot(): GameState = GameState( // {"grid":"2,null,4,...","score":0,"best":0,"moveCount":0}
grid = grid.map { tiles -> tiles.map { it?.num } },
currentScore = currentScore, bestScore = bestScore, moveCount = moveCount)
grid 拍扁成 16 个行主序 token 的字符串是刻意的:桥上的手写 MiniJson 只需要解一层扁平对象。Random 从构造函数注入,JVM 测试用 Random(seed) 做确定性断言;restore() 重建块时分配新的自增 id(动画 key 唯一性),鸿蒙侧对应 appearKeys 机制。
3.4 十项规则测试
./gradlew :game2048:jvmTest # 10/10 PASSED
| 用例 | 钉住什么 |
|---|---|
| 初始局恰好 2 块、零分 | newGame 语义 |
| 同种子逐局一致 | Random 注入的确定性 |
2+2 合并计 4 分 | 计分基础 |
合并不级联(4 | 2 2 → 4 | 4) | 单遍扫描 + 置空规则 |
| 一行两对同合并计 12 分 | 多合并计分 |
| 撞墙不动不刷块 | moved:false 语义 |
| 棋盘锁死判终局 | 四方向探测 |
| 快照往返一致 | snapshot / restore |
bestScore 跨新局存活 | 上游行为 |
| 恢复后可继续移动 | 存档闭环 |
第 4 阶段:桥协议与原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libgame2048_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosgame2048.so)
│ Game2048Action
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出棋盘快照又会让 UI 丢失动画语义,所以选择单 C ABI + 全量 JSON(状态与事件一把抓)。
4.2 Kotlin/Native 导出函数
@CName("Game2048Action")
fun game2048Action(request: String): String = try {
val json = MiniJson.parse(request)
when (json.str("action").lowercase()) {
"version" -> """{"ok":true,"version":"1.0.0-ohos.1","gridSize":4}"""
"new" -> { engine.newGame(); stateJson("new", null, engine.gridTileMovements) }
"move" -> { val moved = engine.move(parseDirection(json.str("direction")))
stateJson("move", moved, if (moved) engine.gridTileMovements else emptyList()) }
"state" -> stateJson("state", null, null)
"restore" -> { engine.restore(parseSnapshot(json.str("snapshot"))); stateJson("restore", null, null) }
else -> """{"ok":false,"error":"unknown action"}"""
}
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
Game2048Action | game2048Action(req) | newGame / move / snapshot / restore + 动画事件 + 版本握手一口进 |
桥内是全局单例 GameEngine,异常整体 try/catch,Kotlin 异常永远不跨 FFI;未知 action / direction、残缺 snapshot 全部走同一个 ok:false 通道。
4.3 为什么不上 kotlinx.serialization
C 签名是 const char* → const char*,序列化库的唯一收益是少写 50 行解析器;代价是运行时进 KN so(体积、链接面变大)加编译器插件。协议刻意保持一层扁平对象(snapshot 的 grid 拍成字符串也是为此),手写 MiniJson 状态机(引号 / 转义 / 逗号分层)足够——与 cmp_qrose 审核模式同款方案。
4.4 C++ N-API 与 CMake
napi_property_descriptor desc[] = {
{"game2048Action", nullptr, NapiGame2048Action, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosgame2048_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。
add_library(ohosgame2048 SHARED IMPORTED)
set_target_properties(ohosgame2048 PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(game2048_napi SHARED napi_init.cpp)
target_link_libraries(game2048_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosgame2048)
OHOS_ARCH 为空或 libohosgame2048.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。Gradle 侧 copyKnSo* 任务在链接完成后把双 ABI 三件套自动回填 entry/libs/。
export const game2048Action: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 启动握手与存档恢复
aboutToAppear(): void {
this.openPrefs(); // preferences.getPreferencesSync(context, { name: 'game2048_prefs' })
this.handshake(); // {"action":"version"} → 引擎版本 · 4x4
this.loadSaved(); // 有存档 → restore;无 → new
}
5.2 手势判向与两段动画
.onActionUpdate((event: GestureEvent) => { // PanGesture 累计位移
this.swipeDx += event.offsetX - this.swipeLastX;
this.swipeDy += event.offsetY - this.swipeLastY;
})
.onActionEnd(() => this.handleSwipe()) // ≥24px 才判向(防误触)
private handleSwipe(): void {
// atan2 角度 → 45° 分区 → north / west / south / east → doMove(direction)
}
doMove 先按 movements 的 shift 事件构建 shiftTargets 映射,第一段 animateTo({ duration: 120, curve: Curve.EaseOut }) 只改块的 row / col(position 跟随,产生平移动画);130ms 后 finishMove 按响应里的最终 grid 重建块列表——合并块表现为"目标格数值翻倍 + 新 key",第二段 animateTo({ duration: 200, delay: 50 }) 让 appearKeys 里的新块从 0 缩放弹出。终局时 400ms 后弹 showAlertDialog(重新开始 / 恢复存档)。
5.3 持久化与主题
每步 move 成功后把响应里的 snapshot 整串写入 @ohos.data.preferences(键 snapshot),对应上游 SharedPreferences 语义;mediaquery.matchMediaSync('(prefers-color-scheme: dark)') 监听系统深浅色,2…16384 每个面值都有明暗两套 Material 3 风格色板。
5.4 新 SDK 的 ArkTS 严格检查(真机构建时踩到)
@State成员不能叫offset——与通用属性offset()撞名,改名cellPitch;FontWeight.Light不存在(枚举只有 Lighter / Normal / Regular / Medium / Bold / Bolder),用数值fontWeight(300);preferences.getPreferencesSync第二参必须是{ name }对象;showAlertDialog的cancel是VoidCallback(关闭回调)不是按钮,双按钮弹窗用buttons: [...]数组。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosgame2048)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName("Game2048Action") + MiniJson(cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.game2048、versionName 1.0.0-ohos.1
└── entry/
├── build-profile.json5 externalNativeOptions + abiFilters [arm64-v8a, x86_64]
├── libs/{arm64-v8a,x86_64}/ linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
├── src/main/cpp/CMakeLists.txt IMPORTED 引入 KN so、$ORIGIN rpath
├── src/main/cpp/napi_init.cpp N-API 导出、参数检查、DisposeString
├── src/main/cpp/include/libohosgame2048_api.h
├── src/main/cpp/types/libgame2048_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :game2048:jvmTest # 10 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。
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.game2048 -m entry
四、完整代码对照
4.1 整体架构
com.alexjlockwood.twentyfortyeight.game/**(上游 GameViewModel 规则,逐字抽取)
│ GameEngine / GameState / domain 模型 / movements 事件流
Bridge.kt + MiniJson
│ Game2048Action(new / move / state / restore / version)
libgame2048_napi.so
│ game2048Action
Index.ets
│ 手势判向 + 两段动画 + preferences 存档
▼
ArkUI 真机页面(单页 2048)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_2048_compose、app 移出 |
gradle/libs.versions.toml | HarmonyOS Kotlin 2.2.21-1.0.0 钉版 |
build.gradle.kts(根) | 版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务 |
game2048/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,零第三方依赖 |
game2048/src/commonMain/.../game/GameEngine.kt | 规则核心(旋转 / 扫描 / 合并 / 计分 / 终局 / 快照) |
game2048/src/jvmTest/.../GameEngineTest.kt | 10 项规则钉版测试 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 单 C ABI、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面(手势 / 动画 / 存档 / 双主题) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(22 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | GameEngine.newGame() | 空盘生成 2 个随机块(90/10),best 保留 |
| 引擎 | move(direction): Boolean | 旋转规范化 + 单遍扫描合并,返回是否变化 |
| 引擎 | gridTileMovements | 本步动画事件(add / shift),排序 shift 前 add 后 |
| 引擎 | isGameOver | 四方向无变化即终局 |
| 引擎 | snapshot() / restore(state) | 快照出 / 入,持久化由宿主负责 |
| Native | Game2048Action | C ABI JSON 导出(五动作一口进) |
| N-API | game2048Action(req) | 向 ArkTS 暴露能力 |
| ArkTS | doMove(direction) | 手势 → 桥调用 → 两段动画 → 存档 |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(game2048Action(JSON.stringify({
action: 'move', direction: 'west'
})));
// res.moved === true;res.movements 描述谁动了谁生了;res.snapshot 整串落 preferences
"move" -> {
val moved = engine.move(parseDirection(json.str("direction")))
stateJson("move", moved, if (moved) engine.gridTileMovements else emptyList())
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:抽取引擎而不是重写规则,也不是整库编译
上游是应用不是库:Hilt / lifecycle / Compose 壳没有任何 ohos 对应物,"整库真编译"无从谈起;而在 ArkTS 里重写规则等于把 244 行验证过的算法手抄一遍,合并不级联、计分时序这类细节必然漂移。抽取路线让"算法一行不改"成为可验收的命题——jvmTest 的 10 项断言就是契约。
决策 2:工具链钉 2.2.21-1.0.0,Gradle 降到 8.14.1
与 qrose / Vico / Reorderable 同线(社区批准的钉法)。引擎零第三方依赖,不需要 kotlinx 制品线,不存在 KStore 那种"版本跟着依赖走"的反推——上游 AGP 构建整体替换即可。2.2.21 线配套 Gradle 8.14.1,wrapper 从上游 8.3 升级。
决策 3:动画语义用事件流过桥,UI 复刻而非翻译
上游 Compose 消费的就是 gridTileMovements 事件列表;把它原样序列化过桥,ArkUI 拿到相同的 shift / add 序列,动画节奏与上游同源。如果只传棋盘快照,UI 就得自己 diff 棋盘猜动画——合并的两块会被当成"消失 + 出现",节奏必然走样。
决策 4:单 C ABI + 扁平 JSON + snapshot 拍扁
游戏是单进程单局,一个 Game2048Action 覆盖五个动作,协议版本化靠 version 握手。snapshot 的 grid 拍成 16 token 字符串、整串对 ArkTS 不透明:MiniJson 只解一层,改存档结构只动 parseSnapshot / snapshotString 两处。
决策 5:jvmTest 交叉验证 + 计分怪癖逐字保留
"合并块计分、随机新块不计分"是上游计分增量在刷新块入列之前计算的时序结果——它是个怪癖,但它是上游行为。抽取版逐字保留并由测试钉死;同理保留的还有 bestScore 跨新局存活。适配的纪律是复刻行为,包括怪癖,而不是"顺手修正"。
决策 6:存档时序修正为"移动后快照"(记录在案的唯一分歧)
上游 saveState 在移动前保存(恢复等于回退一步),与玩家直觉不符。本 fork 改为每步移动后快照,存档即当前画面——这是全项目唯一有意为之的行为分歧,在 README 与测试注释中显式记录。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin,路径写进 gradle.properties)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64,自动签名)。
6.2 JVM 交叉验证
./gradlew :game2048:jvmTest
10 项用例全部通过(见 3.4 节用例表)。引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码且无 expect/actual 分叉点——设备侧只需对账三件事:grid 与画面一致、score / best 与状态行一致、终局时机与测试第 7 条同判。
6.3 链路与 HAP 验证
:game2048:jvmTest: 10/10 PASSED
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(33s,16 tasks)
llvm-nm -D: Game2048Action 为全局 T 符号
CompileArkTS: finished(修复 4 类新 SDK 严格检查报错后)
BuildNativeWithCmake: finished(双 ABI 编出 libgame2048_napi.so)
PackageHap: finished
install bundle successfully
start ability successfully
| 产物 | 位置 | 大小 |
|---|---|---|
libohosgame2048.so (arm64) | entry/libs/arm64-v8a/ | 2,541,272 B(含 Kotlin/Native 运行时) |
libohosgame2048.so (x86_64) | entry/libs/x86_64/ | 2,180,584 B(模拟器用) |
libc++_shared.so ×2 | 各 ABI 目录 | 1,850,184 / 1,731,432 B(Gradle 自动回填) |
链接期的 CAdapter clang 警告(api.cpp:448:1: non-void function does not return a value in all control paths)来自 HarmonyOS Kotlin 生成的胶水代码,不影响产物。
6.4 功能用例
- 启动后关于区出现
引擎版本 1.0.0-ohos.1 · 4x4(来自真实 Native 调用,非静态文字); - 冷启动无存档自动开新局,棋盘 2 个初始块缩放弹出;
- 四方向滑动:块 120ms 平移到位,合并块目标格数值翻倍并弹出;状态行步数递增;
- 朝已满方向滑动:棋盘完全不动、不刷新随机块、步数不变(
moved:false); - 计分只有合并加分,随机新块不加分;
best跨新局保留; - 棋盘填满且无可合并 → 400ms 后终局弹窗(重新开始 / 恢复存档);
- 玩几步后杀进程重启:自动
restore,棋盘 / 分数 / 步数与退出时一致(preferences 持久化闭环); - 系统切换深浅色:整盘色板联动切换(2…16384 明暗两套)。
6.5 验证结论
游戏引擎、动画事件模型、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,完整跑通手势、动画、计分、终局与存档闭环(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/2048-kotlin-compose-material3.git
cd 2048-kotlin-compose-material3
export JAVA_HOME="/path/to/jdk-21"
./gradlew :game2048:jvmTest # 10 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.game2048 -m entry
hdc shell hilog | grep Game2048
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算适配,必须让
:game2048真正编译成ohosArm64/ohosX64目标并经 NAPI 在设备上执行; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- HAP 缺当前 ABI 的 so 会报安装失败
9568347:真机要arm64-v8a、模拟器要x86_64,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - ArkTS 直接
import 'libohosgame2048.so'报does not provide an export named 'game2048Action'——HarmonyOS Kotlin 的 CAdapter 不会把@CName业务符号挂到 ArkTS 模块,必须经 CMake 包一层 NAPI so; - 新 SDK 的 ArkTS 严格检查连报四类错:
@State成员名offset与通用属性撞名(改cellPitch)、FontWeight.Light不存在(改fontWeight(300))、getPreferencesSync第二参要{ name }对象、showAlertDialog的cancel是VoidCallback不是按钮(双按钮用buttons数组); - HAP 打包期报
$media:app_icon/$media:icon找不到——图标 PNG 缺失或放错目录(本仓早期曾把图标错放进仓库根的空壳AppScope//entry/);app_icon.png必须在example/harmonyApp/AppScope/resources/base/media/,icon.png必须在entry/src/main/resources/base/media/; libohosgame2048.so的DT_NEEDED包含libc++_shared.so,拷 so 必须带同 triple 的 libc++,否则运行期dlopen失败——不要手拷,copyKnSo*任务负责;- KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); hvigorw assembleHap用系统低版本 JDK 会报不相干错误,用 DevEco 自带 JBR;- DevEco Studio 要打开
example/harmonyApp子目录,不能打开仓库根(根目录是 Gradle 工程); - 签名材料由 DevEco 自动写入
build-profile.json5,证书绝不进入 AtomGit(仓库内signingConfigs保持空数组)。
8.2 已知问题
- 鸿蒙侧无 Compose UI,界面是 ArkUI 复刻:位移动画(120ms EaseOut)与弹出动画(200ms / delay 50)近似上游
Animatable行为,非逐帧一致; - 计分时序保留上游怪癖:合并块计分、随机新块不计分(上游行为,测试钉死,不是适配偏差);
- 存档时序与上游有一处有意分歧:上游移动前保存(恢复回退一步),本 fork 移动后快照(存档即画面),已在文档记录;
- 棋盘固定 4×4(
GRID_SIZE常量),改它需要重编引擎且 UI 布局 / 色板要跟着改; - 桥内是全局单例引擎,一进程一局;多局需要带会话 ID 的协议(刻意不做的过度设计);
- HAP 安装必须使用与 bundle name
org.terminator.ohos.game2048匹配的签名。
8.3 未来优化方向
- 桥协议增加
undo(撤步)动作与多局会话(sessionId),保持"单函数、JSON 进出、错误不跨界"三原则; - 在 CI(
.github/workflows/build.yml已有 jvmTest + 双 ABI 链接)中加入 ELF 符号检查与 HAP 构建; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本;
- 把
game2048/的模块化抽取作为 KMP 化提案回馈上游(鸿蒙侧增量不提)。
九、总结
9.1 核心难点回顾
上游 GameViewModel(244 行规则)
→ 引擎抽取(算法逐字、壳换依赖:Random 注入 / mod / 快照外移)
→ 工具链对齐 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(cmp_qrose 模式根构建替换)
→ ohosArm64 / ohosX64 KLIB + 10 项 jvmTest 规则钉版
→ C ABI(@CName × 1)+ movements 事件流 JSON 契约
→ C++ N-API(DisposeString 纪律)
→ ArkTS 手势判向 + 两段动画 + preferences 存档
→ ArkUI 真机单页验收(滑动 / 合并 / 计分 / 终局 / 重启存档闭环)
9.2 封装层次
- 引擎层:抽取的
GameEngine——旋转规范化、单遍扫描合并、计分时序、终局判定、快照往返; - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出全量 JSON(状态 + 动画事件 + 存档);
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:手势识别、动画编排、preferences 持久化、明暗双主题与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 应用型仓库的资产在 ViewModel:上游 861 行 Compose UI 一行没带走,244 行规则引擎却撑起了整个适配——盘点"哪部分是纯 Kotlin"永远比盘点"哪部分能编译"更接近正确路线;
- 动画语义要用事件流过桥:UI 复刻的依据不是截图而是数据——上游 Compose 消费什么事件,ArkUI 就消费什么事件,节奏同源、验收有据;
- 交叉验证是"规则没漂"的唯一可信证明:10 项 jvmTest 钉死的包括"随机新块不计分"这类时序怪癖——适配的纪律是复刻行为(包括怪癖)而不是顺手修正,测试零妥协才让"逐字抽取"有说服力。
9.4 适配成果
当前适配已完成:
- 上游规则引擎的双 ABI Kotlin/Native 编译(算法逐字抽取、零重写,平台壳替换为可注入依赖);
- 单个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 单页验收页面(手势、两段动画、计分、终局弹窗、存档恢复、明暗双主题、版本握手);
- 10 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、自动签名、设备安装和真机验收;
- AtomGit 项目文档(README 四件套,主文档 7700+ 字)、OHOS CI 工作流和本地效果图。
适配过程 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)