本文记录 Spinnable 接入 OpenHarmony 的完整过程,覆盖组件语义盘点、纯 Kotlin 状态机提取、鸿蒙工具链钉版、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

Spinnable 是一个 Compose Multiplatform 双面翻转卡片组件:SpinnableView(front, back, state) 用 graphicsLayer 的 rotationX / rotationY 做欧拉角 3D 翻转,拖拽手势手动翻、Animatable 匀速自动转,角度过半时自动切换正反面内容并在背面套 180° 抵消镜像。本次适配与 KStore 的"真编译整库"路线正好相反:spinnable 的全部价值都在 Compose UI 层(graphicsLayer / pointerInput / Animatable),而上游构建(Kotlin 1.7.0 / Compose 1.2.0-alpha01-dev755 / Gradle 7.5.1)与社区鸿蒙工具链(HarmonyOS Kotlin 2.2.21 / CMP 1.9.2)隔着三个断层,UI 层编不进 ohos。因此走社区早期已过审的"零依赖内核提取"路线(qrose / haze / PreCompose / voyager 同款):把旋转状态机抽成纯 Kotlin SpinEngine 原样编译成 OpenHarmony 动态库,Compose 层隔离进 composeMain 留源待接,ArkUI 页面只做渲染与手势采样——拖拽映射、自动推进、正反判定、attrs 解析全部是上游原件的数学,一行没有重写。

项目地址: AtomGit/oh-tpc/spinnable.git

上游仓库: williankl/spinnable

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

Spinnable 为 Compose 应用提供"一张会翻的卡片"这一件事的完整答案:front / back 两个任意 Composable 正反面,SpinnableState 一个密封类管全部行为——None 禁止转动、Manual.Horizontal / Vertical / Both 控制手动拖拽的可用轴向、Automatic(verticalSpeed, horizontalSpeed) 按度/秒匀速自转(360F 即每秒一整圈)。渲染原理是两层 Box 套 Modifier.graphicsLayer { rotationY = yRotation; rotationX = xRotation },没有 3D 引擎,3D 效果完全靠两个欧拉角;换面判定每帧重算,背面额外套 rotationY = 180° 保证文字不镜像。KMP 项目把这些能力放在 commonMain,由 Desktop / Android 共享。OpenHarmony 应用同样需要这套交互能力,但上游构建不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示一张卡片,却无法证明翻转语义在 OpenHarmony ARM64 设备上完成编译、链接和执行——拖到一半换不换面、自动旋转双轴怎么叠加、XML attrs 怎么降级,全都会在 TypeScript 重写里悄悄漂移。适配需要同时验证状态机、Native 动态库、C ABI、N-API、ArkUI 手势与渲染、HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 动态库。
工具链不一致上游 Kotlin 1.7.0 / Gradle 7.5.1 / AGP 7.2.2,构建全靠 buildSrc 约定插件钉死,与鸿蒙工具链无法共存于一个 Gradle 构建。
依赖矩阵Compose 走 1.2.0-alpha01-dev755,依赖早已关闭的 maven.pkg.jetbrains.space dev 仓库,ohos 侧 CMP 是 1.9.2,两条 Compose 线对不到同一 API 面。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
渲染语义跨不了桥graphicsLayer / pointerInput / Animatable 全在 androidx.compose.* 下,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:旋转状态机的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。翻转语义完全由上游原版数学负责,ArkTS 只负责采样手势、调用 Native 和渲染角度。

1.2 库提供的能力

Demo 提供一个真实的翻转交互会话,对应桥接的全部入口:

  • 手动翻转(drag):PanGesture 增量进 SpinEngine.applyDrag——水平拖驱动 Y 轴(y -= dx)、垂直拖驱动 X 轴(x += dy),只有 Manual.* 状态响应,未启用轴向拖动被引擎直接拦成空操作;
  • 自动旋转(auto):SpinEngine.autoAt 按 angle = (elapsed % duration) / duration × 360、duration = 360 / speed × 1000ms 纯函数取角,双轴独立、speed <= 0 恒静止;
  • 朝向查询(facing):(angle + 90) % 360 ∈ [0,180] 判正面,两轴同向显示 front、一正一反显示 back;
  • attrs 解析(attrs):上游 Android XML buildSpinnableAttr 的等价物——none/manual/automatic × 双轴向开关 × 速度缺省 360°/s 的完整降级矩阵。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
op / state回显本次算子与解析出的状态名(attrs 算子)。
x / yX 轴 / Y 轴角度(度),恒为有限数(NaN/Infinity 钳为 0)。
front / facingX / facingY当前是否正面 + 两轴各自朝向。

引擎侧完整能力(五种状态、双向拖拽映射、匀速自转、换面判定、attrs 降级)全部来自上游 commonMain 数学,JVM 目标保留编译对照,Compose 版组件同步改写为委托同一份 SpinEngine——两端行为由单一事实源保证。

1.3 实现适配

维度要求
代码复用旋转状态机从上游 Composable 逐行提取为纯 Kotlin SpinEngine(不重写、不换算法);上游 SpinnableState.kt 原样编译;Compose 层隔离进 composeMain 不参与 ohos 构建。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作对照验证,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);buildSrc 约定插件整体移除(git 历史可查);上游 sample / sample-android 留盘不进构建。
桥接稳定使用 1 个 C ABI 和 UTF-8 JSON,无状态纯函数、不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律状态机零第三方依赖,commonMain 不需要任何 kotlinx 制品线——这是与 KStore 路线分叉的根本原因。
UI 完整双 Tab 覆盖全部桥接入口:翻转卡片(五模式 + 拖拽 + 双速度滑杆 + 状态回显)、Attrs 解析(降级矩阵)。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。Spinnable 本身就是 Compose UI 组件,ArkTS Demo 是桥暴露的翻转状态机在鸿蒙上的渲染宿主:卡片用 ArkUI 双层 .rotate 嵌套逼近 graphicsLayer 复合,手势与定时器只负责把增量喂给引擎。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认 Compose 断层不可编、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、buildSrc 移除
第 3 阶段:状态机提取     ── SpinEngine 逐行搬运、语义陷阱清单、四算子 JSON 契约、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 双层 rotate 渲染、PanGesture、16ms 心跳、模式/速度面板
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
core/                                 上游组件库(进 ohos 构建,jvm + ohosArm64 + ohosX64)
core/src/commonMain/                  SpinEngine(新增,纯 Kotlin 状态机)+ SpinnableState(上游原样)
core/src/composeMain/                 上游 SpinnableView Composable(改写为委托 SpinEngine,不编译)
core/src/androidMain/                 上游 XML View 封装(留源不编译,attrs 语义由桥算子承接)
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 页面
sample/ sample-android/               上游 Desktop / Android 示例,留盘不进 settings
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :core 与 :example:nativeApp,rootProject.name = "ohos_spinnable"。上游的 vanniktech 发布链路(Sonatype S01)、kotlinter、AGP classpath 全部随 buildSrc 移除剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Adoptium jdk-21.0.12)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作对照验证)
Gradle8.14.1(上游 wrapper 7.5.1 升级;2.2.21 线要求 Gradle 8.x)
三方依赖状态机零依赖;桥零依赖(MiniJson 手写,不引 kotlinx.serialization)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.spinnable
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块文件依赖形态处置
commonMain/Spinnable.kt1graphicsLayer / pointerInput / Animatable,全在 androidx.compose.*提取状态机后隔离到 composeMain
commonMain/SpinnableState.kt1纯 Kotlin 密封类,零依赖原样编译(jvm + 双 ohos)
androidMain/*2 + resLinearLayout / ComposeView / viewBinding / styleable留源不编译,attrs 语义进桥

与 KStore 的分野在这里就定了:KStore 的 commonMain 是纯 Kotlin + kotlinx 制品,制品线补齐后能整库真编译;spinnable 的 commonMain 只有两个文件,其中一个 100% 站在 Compose 上——“值得共享的"只有那份旋转数学,而数学恰好又是这个库的全部行为语义。所以路线不是"补 actual”,而是"抽引擎"。

1.4 页面边界
标题区          SPINNABLE · OPENHARMONY / 副题行(CMP 翻转卡片 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换        翻转卡片 / Attrs 解析
卡片区          260×340 舞台:外层绕 X 轴 rotate 包内层绕 Y 轴 rotate,背面额外 +180° 防镜像
手势区          PanGesture 增量 → drag 算子(manual 模式生效)
模式区          无 / 手动·水平 / 手动·垂直 / 手动·双向 / 自动 五枚切换 chip
速度区          垂直速度 / 水平速度 双滑杆(0–720 °/s,步进 10,默认 360,仅自动模式)
状态行          X/Y 角度一位小数、FRONT/BACK 徽章、facingX / facingY 朝向 chip
操作区          重置按钮(角度归零 + 自动模式重新计时)
Attrs 区        attrState 三态 + 双轴向 Toggle + 双速度滑杆 → 解析结果卡(含请求 JSON 回显)

页面启动时先跑一次 facing 算子做握手,.so 加载或协议异常会在第一时间暴露。

第 2 阶段:目标与依赖打通

2.1 制品线验证(本适配的路线判据)

先查社区 Maven(https://maven.eazytec-cloud.com)上的可达性,结论与 KStore 相反:spinnable 的 commonMain 不需要任何 kotlinx 制品——编码器为零、状态机只用 Kotlin 标准库。真正挡路的是 Compose:上游钉 org.jetbrains.compose 1.2.0-alpha01-dev755,制品住在早已关闭的 JetBrains dev 仓库,而 ohos 侧社区 CMP 是 1.9.2,两条线连 Compose Compiler 插件形态都不同(1.2 时代靠 kotlinCompilerExtensionVersion,2.x 时代靠 Compose Compiler Gradle plugin)。结论:UI 层不可编,走零依赖内核提取路线——判据永远是当天的制品与工具链可达性,而不是上一次适配的结论。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

社区已过审的钉法:HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同款)。KStore 后来证明 coroutines 1.11 线需要 2.4.20,但 spinnable 没有任何社区制品依赖拉高版本,2.2.21 即最优;上游 Gradle 7.5.1 跑不动该 Kotlin 发行版,升到 8.14.1(与 qrose 完全同款,本机已有 wrapper 缓存可复用)。KStore 的经验反过来:它上游就是 Gradle 9.6.1 + Kotlin 2.4.0,所以沿用 9.x——工具链版本跟着依赖走,两条线的经验不能互相套用。

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_spinnable"

include(":core")
include(":example:nativeApp")
2.3 移除 buildSrc(本适配独有的一步)

上游没有根 build.gradle.kts,全部模块配置住在 buildSrc 约定插件里:spinnable.multiplatform(apply AGP 7.2.2 + compose 1.2-dev + kotlinter)、spinnable.sample、spinnable.sample-android。问题在于 Gradle 会在配置期自动编译 buildSrc,不管模块用不用——这套绑定 Kotlin 1.7 的插件在 Gradle 8.14 + Kotlin 2.2.21 下必然解析失败。处置:整目录移除(git 历史可查),模块构建改为直写 build.gradle.kts,同时新增根构建脚本承接版本与测试纪律:

// 根 build.gradle.kts(新增)
plugins {
    alias(libs.plugins.kotlin.multiplatform) apply false
}

allprojects {
    group = "io.github.williankl.spinnable"
    version = providers.gradleProperty("version").orElse("0.5.1-ohos.1").get()
}

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

core/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()          // 对照验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}
    }
}

实测:

./gradlew :core:compileKotlinJvm          # BUILD SUCCESSFUL

上游 compileOnly(compose.foundation / compose.ui) 的 commonMain 依赖随适配消失——composeMain 未注册 source set,不参与任何目标编译。

第 3 阶段:状态机提取与桥协议建模(技术难点)

3.1 语义陷阱清单(提取前先列全)

上游 Spinnable.kt 只有 120 行,但每一行都是语义,四个陷阱必须先钉住:

  1. 命名陷阱:isXFrontFacing 由 Y 轴角度推出、isYFrontFacing 由 X 轴角度推出(上游源码如此,不是笔误);
  2. 取模符号:判定式 (angle + 90) % 360 ∈ [0,180] || < -180,Kotlin % 保留被除数符号——ArkTS 侧绝不能自己再算一遍(JS 取模语义不同);
  3. 拖拽映射:y -= dx(减号)、x += dy(加号),水平拖绕 Y 轴、垂直拖绕 X 轴;
  4. 换面条件:(X正 && Y正) || (X反 && Y反) 显示 front,一正一反显示 back,且 back 外再套 rotationY = 180° 抵消镜像。

SpinEngine 把这四条原样搬进纯 Kotlin,成为 Compose 版与 ArkTS 版共用的单一事实源:

public object SpinEngine {
    public fun applyDrag(state: SpinnableState, xRotation: Float, yRotation: Float,
                         dragX: Float, dragY: Float): SpinSnapshot
    public fun autoAt(state: SpinnableState.Automatic, elapsedMs: Long): SpinSnapshot
    public fun facing(xRotation: Float, yRotation: Float): SpinSnapshot
    public fun resolveAttrs(stateIndex: Int, horizontalEnabled: Boolean = true,
        verticalEnabled: Boolean = true, horizontalSpeed: Float = DEFAULT_SPEED,
        verticalSpeed: Float = DEFAULT_SPEED): SpinnableState
}

public data class SpinSnapshot(
    public val xRotation: Float, public val yRotation: Float,
    public val isXFrontFacing: Boolean, public val isYFrontFacing: Boolean,
) {
    public val isShowingFront: Boolean
        get() = (isXFrontFacing && isYFrontFacing) || (!isXFrontFacing && !isYFrontFacing)
}

composeMain 里的上游 Composable 同步改写为委托 SpinEngine——updateRotationState 与拖拽分支换成一次 applyDrag 调用,待社区 Compose 工具链对齐接回时,两端天然共享同一套数学。

3.2 JSON 契约
williankl.spinnable.core/**(SpinEngine + SpinnableState,纯 Kotlin)
        ↓ applyDrag / autoAt / facing / resolveAttrs
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API spinnableStep()
        ↓
ArkUI Index.ets(双 Tab)

请求与响应:

请求 : {"op":"drag","state":"manual-both","x":15,"y":-45,"dx":3,"dy":2.5}
       {"op":"auto","state":"automatic","verticalSpeed":360,"horizontalSpeed":180,"elapsedMs":500}
       {"op":"facing","x":0,"y":0}
       {"op":"attrs","attrState":1,"horizontalEnabled":true,"verticalEnabled":true,
        "horizontalSpeed":360,"verticalSpeed":360}
响应 : {"ok":true,"op":"drag","x":17.5,"y":-48.0,"front":true,"facingX":true,"facingY":true}
       {"ok":true,"op":"attrs","state":"manual-both","verticalSpeed":0,"horizontalSpeed":0}
       {"ok":false,"error":"unknown op, expected drag|auto|facing|attrs"}

state 取五个值:none / manual-horizontal / manual-vertical / manual-both / automatic(缺省 manual-both),与上游密封类一一对应。

3.3 为什么桥是无状态纯函数

备选设计是"引擎持状态 + C ABI 句柄"(create/step/dispose),但那会引入跨语言对象生命周期问题:ArkTS 异常退出谁来 dispose、页面重建句柄怎么迁移。spinnable 的状态本来就只有两个角度浮点数,让 ArkTS 持有、每帧带进带出,任何一侧崩溃都不泄漏内存,协议天然幂等。代价是 JSON 里多传两个 number,16ms 一帧的节奏下可以忽略。自动旋转同理:匀速运动的角度是时间的纯函数(autoAt(elapsedMs)),根本不需要累积状态。

3.4 错误通道

导出整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("SpinnableStep")
fun spinnableStep(request: String): String = try {
    /* op 分发 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、未知 op、非数字角度、NaN/Infinity(组包时钳为 0),全部走同一个 ok:false 通道,ArkTS 侧统一渲染成错误横幅。

第 4 阶段:原生桥接

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libspinnable_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosspinnable.so)
  │ SpinnableStep
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制翻转语义,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("SpinnableStep")
fun spinnableStep(request: String): String   // drag / auto / facing / attrs 一口进
C ABI 符号ArkTS 方法覆盖内核
SpinnableStepspinnableStep(req)applyDrag + autoAt + facing + resolveAttrs 全部分发

一个符号覆盖四个算子(op 字段分发),比 KStore 的双符号更收敛——spinnable 没有版本握手的独立诉求。

4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"spinnableStep", nullptr, NapiSpinnableStep, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosspinnable_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosspinnable_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

4.4 CMake 和类型声明
add_library(ohosspinnable SHARED IMPORTED)
set_target_properties(ohosspinnable PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(spinnable_napi SHARED napi_init.cpp)
target_link_libraries(spinnable_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosspinnable)

OHOS_ARCH 为空或 libohosspinnable.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const spinnableStep: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 渲染:双层 rotate 逼近 graphicsLayer

Compose 的 graphicsLayer { rotationX; rotationY } 是单层复合,ArkUI 的 .rotate 是单轴角度——用嵌套逼近:外层容器绕 X 轴、内层容器绕 Y 轴,背面内容额外 rotate({ y: 1, angle: 180 }) 抵消镜像(与上游逐行对应):

Stack() {
  Column() {
    if (this.front) {
      this.FrontFace()
    } else {
      this.BackFace().rotate({ x: 0, y: 1, z: 0, angle: 180, centerX: '50%', centerY: '50%' })
    }
  }
  .rotate({ x: 0, y: 1, z: 0, angle: this.yAngle, centerX: '50%', centerY: '50%' })
}
.rotate({ x: 1, y: 0, z: 0, angle: this.xAngle, centerX: '50%', centerY: '50%' })

换面时机由引擎返回的 front 决定,ArkTS 不自算朝向。

5.2 手势与心跳
.gesture(
  PanGesture({ fingers: 1 })
    .onActionStart((event: GestureEvent) => {
      this.lastOffsetX = event.offsetX; this.lastOffsetY = event.offsetY;
    })
    .onActionUpdate((event: GestureEvent) => {
      const dx = event.offsetX - this.lastOffsetX;
      const dy = event.offsetY - this.lastOffsetY;
      this.lastOffsetX = event.offsetX; this.lastOffsetY = event.offsetY;
      this.applySpin(this.callBridge({ op: 'drag', state: this.mode,
        x: this.xAngle, y: this.yAngle, dx: dx, dy: dy }));
    })
)

自动模式用 16ms setInterval 心跳调 auto 算子(elapsedMs = Date.now() - autoStart),切模式 / 改速度 / 重置都重启 autoStart,aboutToDisappear 清定时器。每帧的角速度语义与上游 Animatable 一致,帧节奏差异见 8.2。

5.3 面板与错误边界

模式 chip 五枚对应 SpinnableState 全集;速度滑杆仅自动模式出现;状态行实时回显 X/Y 角度、FRONT/BACK 徽章与 facingX / facingY chip——所有数字都来自引擎响应,页面没有一处本地旋转数学。ok:false 时错误原文进横幅;Native 调用本身抛错(理论不该发生)同样被 callBridge 的 try/catch 收进横幅。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosspinnable)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/Bridge.kt         @CName × 1 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.spinnable、versionName 0.5.1-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/libohosspinnable_api.h
        ├── src/main/cpp/types/libspinnable_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 双 Tab 验收页面(约 930 行)
6.2 构建、签名和安装
./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 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

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.spinnable -m entry

四、完整代码对照

4.1 整体架构

williankl.spinnable.core/**(SpinEngine 状态机 + SpinnableState)
    │ applyDrag / autoAt / facing / resolveAttrs
Bridge.kt + MiniJson
    │ SpinnableStep
libspinnable_napi.so
    │ spinnableStep
Index.ets
    │ JSON parse + @State 更新
    ▼
ArkUI 真机页面(双层 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_spinnable、sample 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单条钉版(零三方依赖)
build.gradle.kts(根)版本 0.5.1-ohos.1、全局禁用 Ohos*Test* 任务
core/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
core/src/commonMain/.../SpinEngine.kt新增源文件(状态机,上游数学逐行搬运)
core/src/composeMain/.../Spinnable.kt上游 Composable(改写为委托 SpinEngine,不编译)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 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 验收页面(双 Tab)
README.OpenHarmony_CN.md鸿蒙适配主文档(14 节,含验收清单与 FAQ)

4.3 关键 API 对照

层次API作用
引擎SpinEngine.applyDrag手动翻转一步(轴向拦截 + 正负号映射)
引擎SpinEngine.autoAt自动旋转取角(纯时间函数,speed≤0 恒静止)
引擎SpinEngine.facing正反判定(上游取模符号语义)
引擎SpinEngine.resolveAttrs上游 XML attrs 降级矩阵
引擎SpinSnapshot.isShowingFront两轴同向判正面
ComposeSpinnableView(front, back, state)上游 Composable(composeMain,委托同一引擎)
NativeSpinnableStepC ABI JSON 导出
N-APIspinnableStep(req)向 ArkTS 暴露能力
ArkTSapplySpin(res) / callBridge(req)状态回写 / 统一调用与错误捕获

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(spinnableStep(JSON.stringify({
  op: 'drag', state: 'manual-both', x: 15, y: -45, dx: 3, dy: 2.5
}))) as SpinResult;
// res = { ok: true, x: 17.5, y: -48.0, front: true, facingX: true, facingY: true }
val snap = SpinEngine.applyDrag(
    state = parseState(json),           // manual-both
    xRotation = json.dbl("x", 0.0).toFloat(),
    yRotation = json.dbl("y", 0.0).toFloat(),
    dragX = json.dbl("dx", 0.0).toFloat(),
    dragY = json.dbl("dy", 0.0).toFloat(),
)
ok("drag", snap)                        // {"ok":true,"x":17.5,"y":-48.0,...}

验算:y = -45 - 3 = -48(水平拖是减号)、x = 15 + 2.5 = 17.5;facingX 看 Y 轴 (-48+90)%360 = 42 ∈ [0,180] → 正面;两轴同向 → front=true。两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:提取状态机而不是整库编译

KStore 真编译整库的前提是"ohos 上有它依赖的全部制品"。spinnable 的 commonMain 一半是 Compose、Compose 不可编,强行整库等于先重写 UI——那就不是适配而是重写了。提取状态机后,core 对 ohos 的依赖面收敛到零,任何一个 Kotlin 版本都能编;而旋转数学是库的全部行为语义,抽出来就是完整内核。

决策 2:工具链钉 2.2.21-1.0.0、Gradle 升 8.14.1、移除 buildSrc

零依赖意味着没有制品拉高版本,2.2.21 社区已过审钉法直接适用;上游 Gradle 7.5.1 跑不动该发行版,升 8.14.1 与 qrose 对齐。buildSrc 是本适配独有障碍:Gradle 配置期强制编译它,绑定 Kotlin 1.7 / AGP 7.2 / compose dev 仓库的约定插件必然崩,只能整体移除、模块直写构建脚本。

决策 3:composeMain 隔离 + Composable 改写为委托

上游 Composable 不是删掉而是隔离:移入 composeMain 后同步改写为委托 SpinEngine,文件头注明"待社区 compose.ui ohos 制品与 CMP 对齐后接回"。这样做的收益是单一事实源——将来接回时 Compose 版与 ArkTS 版跑同一份数学,不存在两份实现对不齐的问题;ArkTS 渲染届时降级为 fallback 而非删除。

决策 4:桥为无状态纯函数

角度状态由 ArkTS 持有,每次调用带进带出。跨语言句柄(create/step/dispose)会引入生命周期问题,而 spinnable 的状态只有两个浮点数,纯函数方案零泄漏风险、天然幂等;匀速自动旋转的角度本来就是时间的纯函数。

决策 5:换面判定绝不留在 ArkTS

上游判定式跨两个轴、依赖 Kotlin 取模符号语义(% 结果符号随被除数),JS 的 % 语义不同。ArkTS 只要"顺手"自己算一次朝向就会在负角度区间翻车——所以协议把 front / facingX / facingY 作为响应字段由引擎返回,页面只消费不计算。

六、测试与验证

6.1 测试环境

本次验证使用 Windows 11(x64)、JDK 21.0.12(Adoptium)、Gradle 8.14.1、DevEco Studio 26、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 对照验证

./gradlew :core:compileKotlinJvm

上游仓库没有测试用例,JVM 目标承担的是编译对照:SpinEngine + SpinnableState 在 JVM 与双 ohos 目标编译同一份源码,签名与语义一致性由"逐行搬运 + 真机验收清单"钉住(上游无 desktopTest 可跑,这点与 KStore 的 51 项交叉验证不同)。

6.3 HAP 验证

:core:compileKotlinJvm: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
  → libohosspinnable.so arm64 约 3.6MB、x86_64 约 3.3MB(debugShared,含 KN 运行时)
  → 二进制动态符号表确认 SpinnableStep / libohosspinnable_symbols 导出
BuildNativeWithCmake + BuildNativeWithNinja: finished(双 ABI 编出 libspinnable_napi.so)
CompileArkTS: finished(5.4s,零错误零警告)
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 6.7 MB,内含双 ABI 各三件套(libspinnable_napi.so / libohosspinnable.so / libc++_shared.so)。

6.4 功能用例

  1. 启动即跑 facing 握手,.so 加载或协议异常第一时间暴露在错误横幅;
  2. 模式 = 手动·双向,水平拖卡片绕 Y 轴翻转,过 90° 自动换面,背面文字不镜像;
  3. 垂直拖卡片绕 X 轴翻转,同样过 90° 换面;
  4. 模式 = 手动·水平时垂直拖(或手动·垂直时水平拖),卡片纹丝不动——引擎拦截,状态行角度不变;
  5. 模式 = 无,任意方向拖动完全无效;
  6. 模式 = 自动,v=360 / h=180 时垂直方向每秒一整圈、水平方向每两秒一圈,互不干扰;
  7. 自动模式下拖卡片无持续效果(与上游一致:动画每帧覆盖手势);
  8. 速度滑杆拖到 0 对应轴停转;「重置」角度归零、显示正面、自动模式重新计时;
  9. Attrs 解析 Tab:manual + 只开水平 → manual-horizontal;manual + 全关 → none;automatic + 垂直关 + 360/180 → automatic 且 verticalSpeed=0 / horizontalSpeed=180;
  10. 全程状态行 X/Y 角度实时变化,FRONT/BACK 徽章与所见一致。

6.5 验证结论

旋转状态机、Native 动态库、C ABI、N-API、ArkUI 手势与渲染、HAP 交付链路已完成验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的翻转交互:五模式切换、双向拖拽、过 90° 换面、双轴独立自动旋转与 attrs 降级矩阵(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/spinnable.git
cd spinnable
export JAVA_HOME="/path/to/jdk-21"
./gradlew :core:compileKotlinJvm                                                  # 对照验证
./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.spinnable -m entry
hdc shell hilog | grep Spinnable

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :core 的状态机真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. buildSrc 是隐形地雷:Gradle 配置期无条件编译它,哪怕没有任何模块引用——绑定旧工具链的约定插件必须在升级前整体移除,不能"留着给上游示例用";
  4. gradle.properties 的 org.gradle.java.home 只管守护进程,gradlew 启动器仍需环境变量 JAVA_HOME,否则报 JAVA_HOME is not set 且极易被误诊为构建脚本问题;
  5. ArkTS 在 SDK 5.1.0(18) 下的三个兼容性坑:FontWeight.Black 不存在(枚举只到 Bolder);ToggleButton 是旧 JS-UI 组件,ArkTS 必须用 Toggle({ type: ToggleType.Switch });Circle.fill 触发 SDK 26 版本告警——圆点改用 Column + borderRadius + backgroundColor 一劳永逸;
  6. 命令行跑 hvigorw assembleHap 到 PackageHap 阶段报 spawn java ENOENT:打包工具要找 PATH 里的 java,把 DevEco 自带 JBR(<DevEco>/jbr/bin)加进 PATH 即可——IDE 内构建无此问题;
  7. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  8. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  9. 上游 % 取模保留被除数符号,换面判定绝不能在 ArkTS 用 JS 语义重算;
  10. DevEco 构建会在 harmonyApp 下生成 .clangd / .clang-tidy,qrose 审核基线不跟踪它们——追加 gitignore,lock 文件(oh-package-lock.json5)保持跟踪;
  11. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填完整配置;
  12. 签名材料由 DevEco 自动写入 build-profile.json5,提交前用 git update-index --skip-worktree 保护,证书绝不进入 AtomGit。

8.2 已知问题

  • ArkUI 双层 .rotate 嵌套是 graphicsLayer 单层复合的欧拉角近似,且 .rotate 无 cameraDistance 式透视补偿,极端角度下透视感与 Compose 略有差异;
  • 自动旋转由 16ms setInterval 驱动桥的 auto 算子,帧节奏与 Compose Animatable(挂帧回调)不同,角速度语义一致;
  • composeMain 不参与任何目标编译,等社区 compose.ui ohos 制品与 CMP 对齐后接回,届时 ArkTS 渲染降级为 fallback;
  • androidMain(XML View 封装)不编译,attrs 语义由 attrs 算子承接;
  • 上游 Roadmap(snap-to-side 释放吸附、按需设角)不在本次范围;
  • 桥为无状态纯函数,角度在 ±90° 边界的换面抖动与上游一致,未做迟滞;
  • 上游 vanniktech 发布链路随 buildSrc 剥离,本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.spinnable 匹配的签名。

8.3 未来优化方向

  • 接回 composeMain:社区 CMP 工具链对齐后让 Composable 直接跑 ohos,ArkTS 页面转为 fallback;
  • 渲染层用 ArkUI 属性动画对定时器角度做帧间插值,降低心跳调桥频率;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 spinnable(commonMain 两文件,一半是 Compose)
    → 判定 Compose 不可编(1.2-alpha dev 仓库 vs 社区 CMP 1.9.2)
    → 旋转状态机逐行提取为纯 Kotlin SpinEngine(语义陷阱清单先行)
    → 工具链对齐 Kotlin 2.2.21-1.0.0 / Gradle 8.14.1(qrose 同款)
    → 根构建替换 + buildSrc 移除(cmp_qrose 模式)
    → ohosArm64 / ohosX64 双 ABI 动态库
    → C ABI(@CName × 1,四算子分发)+ 无状态 JSON 协议
    → C++ N-API(DisposeString 纪律)
    → ArkTS 双层 rotate 渲染 + PanGesture + 16ms 心跳
    → ArkUI 真机双 Tab 验收(五模式 / 拖拽 / 自动 / attrs)

9.2 封装层次

  • 引擎层:上游原版旋转数学(拖拽映射、自动推进、换面判定、attrs 降级),零依赖纯 Kotlin;
  • Compose 层:上游 Composable 隔离保留并改写为委托引擎(待接回);
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、手势采样、心跳调度、面板与错误横幅;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 路线选择基于当天的可达性:KStore 因为 kotlinx 制品线补齐走了"真编译整库",spinnable 因为 Compose 断层走了"零依赖内核提取"——同一个社区、同一周内的两个正确答案,判据都是"今天什么能编",而不是"上次怎么做的";
  2. 工具链版本跟着依赖走:spinnable 零依赖所以用最低过审钉法 2.2.21(Gradle 必须 8.x),KStore 被 coroutines 制品拉到 2.4.20(Gradle 留 9.x)——两条线的 Gradle 经验不能互相套用;
  3. UI 库适配的保真单位是数学不是界面:把 graphicsLayer 复刻成 ArkUI 只是渲染近似,真正不能漂移的是换面判定、拖拽正负号、取模符号这层"看不见的语义"——把它们收进单一事实源(SpinEngine),Compose 版与 ArkTS 版共用,界面怎么换皮都不怕。

9.4 适配成果

当前适配已完成:

  • spinnable 0.5.1 旋转状态机的双 ABI Kotlin/Native 编译(上游数学零重写,唯一新增 Kotlin 源文件是 SpinEngine 与桥);
  • 1 个 C ABI 符号和 C++ N-API 包装层(动态符号表验证导出);
  • DevEco Stage ArkUI 双 Tab 验收页面(五模式、双向拖拽、双轴自动旋转、attrs 降级矩阵、实时状态回显);
  • HAP 构建(双 ABI 六件套 so,约 6.7MB)、签名、设备安装和真机验证(适配提交 鸿蒙适配 已推送);
  • AtomGit 项目文档、OpenHarmony README(中英双份,中文主文档 6400+ 字)和本地效果图。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐