本文记录 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 Multiplatformexample 工程使用 HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
Gradleexample 独立 wrapper 8.14.1,上游根工程保持 8.9
JDK21
OpenHarmony 目标ohosArm64 + ohosX64
DevEco product5.1.0(18) / runtimeOS HarmonyOS
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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—开新局,最佳分保留。
movedirection: NORTH/SOUTH/EAST/WEST执行一步,moved=false 表示无效滑动。
restoregrid(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.ktsjvm + ohosArm64 + ohosX64 三目标
example/game2048/.../domain/*.ktCell/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.cppN-API 导出、参数校验和 DisposeString
example/harmonyApp/.../CMakeLists.txtIMPORTED KN so、$ORIGIN rpath、POST_BUILD 拷贝
example/harmonyApp/.../Index.etsArkUI 棋盘、手势、动画、存档
README.OpenHarmony.md / _CN.mdOpenHarmony 双语文档

4.3 关键 API 对照

层次API作用
引擎GameEngine.startNewGame()开局并返回初始 movements
引擎GameEngine.move(direction)一步移动,返回 MoveResult
引擎GameEngine.restore(...)快照回放
NativeGame2048Command唯一 C ABI 入口,JSON 进 JSON 出
Nativelibohos2048_symbols()->DisposeString释放 KN 字符串
N-APIgame2048Command向 ArkTS 暴露引擎调用
ArkTSdoMove/send/finalTiles发请求、两阶段动画、推导最终方块集
ArkTSpersistState/initpreferences 存档与冷启动恢复

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 功能用例

  1. 首次启动无存档,自动开新局,出现两枚初始方块;
  2. 四方向滑动:无效方向棋盘不动,有效方向方块滑动并在空位补新方块;
  3. 同值方块相撞合并翻倍,合并结果缩放进场,得分即时累加;
  4. SCORE/BEST 实时刷新,新游戏后最佳分保留;
  5. 棋盘无可动方块时弹出"游戏结束"对话框,可一键再来一局;
  6. 杀进程冷启动后棋盘、当前分与最佳分从 preferences 快照恢复;
  7. 引擎状态行显示"得分 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 踩坑复盘

  1. 只改 ArkTS 页面不算适配,必须真正编译 ohosArm64/ohosX64 动态库并打进 HAP;
  2. ArkTS 不能 import Kotlin/Native 的 libohos2048.so,@CName 符号必须经 CMake NAPI 包装暴露;
  3. KN 返回的 C 字符串在 napi_create_string_utf8 之后必须立即 DisposeString,否则泄漏;
  4. Area.width 的类型是 Length(string | number | Resource),直接赋给 number 会编译失败,需 typeof 收窄;
  5. preferences.put/flush 返回 Promise,不接 .catch 会触发 ArkTS 告警;
  6. 模拟器是 x86_64,缺对应 so 安装报 9568347,双 ABI 必须都链接;
  7. 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 三条经验

  1. 应用型 CMP 仓库适配,先把"值得共享的纯 Kotlin"圈出来(这里是游戏规则),UI 留给 ArkUI 重绘,比硬对齐两套 Compose 工具链靠谱;
  2. 跨语言边界只传 JSON 和稳定的 id 契约(tile id),规则判断永远只在一端存在;
  3. 每一层的可用性单独留证:JVM 编译、双 ABI 链接、hvigor 构建、真机运行逐层验收,避免把编译成功误认为设备链路可用。

9.4 适配成果

当前适配已完成:

  • 上游仓库零改动,鸿蒙侧完整收在 example/ 独立工程;
  • game2048 引擎模块:jvm + ohosArm64 + ohosX64 三目标,domain 原样复用;
  • libohos2048.so 双 ABI 动态库与 CMake NAPI libgame2048_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。

参考文档

Logo

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

更多推荐