本文记录 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 验证过的引擎抽取路线:规则逐字抽成 game2048 KMP 模块原样编译成 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 写进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 从上游 8.3 升级;2.2.21 线配套版本,与 qrose / Vico / Reorderable 一致)
引擎依赖零第三方依赖(纯 Kotlin 自包含)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.game2048
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
上游位置内容依赖形态处置
viewmodel/GameViewModel.kt全部游戏规则(244 行)纯算法 + androidViewModel / Hilt / StateFlow 壳逐字抽取为 GameEngine(壳替换)
repository/GameRepository.ktSharedPreferences + Gson 存档Android 专属语义外移:引擎出 snapshot(),宿主自选持久化
ui/(861 行)Compose 界面、动画、主题Compose BOM / Material3 / ConstraintLayout留盘不进构建,ArkUI 复刻
di/、MainActivity、MainApplicationHilt 装配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 方法覆盖内核
Game2048Actiongame2048Action(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.tomlHarmonyOS 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.kt10 项规则钉版测试
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt单 C ABI、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面(手势 / 动画 / 存档 / 双主题)
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)快照出 / 入,持久化由宿主负责
NativeGame2048ActionC ABI JSON 导出(五动作一口进)
N-APIgame2048Action(req)向 ArkTS 暴露能力
ArkTSdoMove(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. 启动后关于区出现 引擎版本 1.0.0-ohos.1 · 4x4(来自真实 Native 调用,非静态文字);
  2. 冷启动无存档自动开新局,棋盘 2 个初始块缩放弹出;
  3. 四方向滑动:块 120ms 平移到位,合并块目标格数值翻倍并弹出;状态行步数递增;
  4. 朝已满方向滑动:棋盘完全不动、不刷新随机块、步数不变(moved:false);
  5. 计分只有合并加分,随机新块不加分;best 跨新局保留;
  6. 棋盘填满且无可合并 → 400ms 后终局弹窗(重新开始 / 恢复存档);
  7. 玩几步后杀进程重启:自动 restore,棋盘 / 分数 / 步数与退出时一致(preferences 持久化闭环);
  8. 系统切换深浅色:整盘色板联动切换(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 踩坑复盘

  1. 只改 ArkTS 页面不算适配,必须让 :game2048 真正编译成 ohosArm64 / ohosX64 目标并经 NAPI 在设备上执行;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. HAP 缺当前 ABI 的 so 会报安装失败 9568347:真机要 arm64-v8a、模拟器要 x86_64,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  4. ArkTS 直接 import 'libohosgame2048.so' 报 does not provide an export named 'game2048Action'——HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务符号挂到 ArkTS 模块,必须经 CMake 包一层 NAPI so;
  5. 新 SDK 的 ArkTS 严格检查连报四类错:@State 成员名 offset 与通用属性撞名(改 cellPitch)、FontWeight.Light 不存在(改 fontWeight(300))、getPreferencesSync 第二参要 { name } 对象、showAlertDialog 的 cancel 是 VoidCallback 不是按钮(双按钮用 buttons 数组);
  6. 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/;
  7. libohosgame2048.so 的 DT_NEEDED 包含 libc++_shared.so,拷 so 必须带同 triple 的 libc++,否则运行期 dlopen 失败——不要手拷,copyKnSo* 任务负责;
  8. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  9. hvigorw assembleHap 用系统低版本 JDK 会报不相干错误,用 DevEco 自带 JBR;
  10. DevEco Studio 要打开 example/harmonyApp 子目录,不能打开仓库根(根目录是 Gradle 工程);
  11. 签名材料由 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 三条经验

  1. 应用型仓库的资产在 ViewModel:上游 861 行 Compose UI 一行没带走,244 行规则引擎却撑起了整个适配——盘点"哪部分是纯 Kotlin"永远比盘点"哪部分能编译"更接近正确路线;
  2. 动画语义要用事件流过桥:UI 复刻的依据不是截图而是数据——上游 Compose 消费什么事件,ArkUI 就消费什么事件,节奏同源、验收有据;
  3. 交叉验证是"规则没漂"的唯一可信证明: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。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐