本文记录 Panels 接入 OpenHarmony 的完整过程,覆盖 Discord 式三面板布局库语义盘点、路线判定(为什么抽引擎而不是硬编 Compose)、HarmonyOS Kotlin 工具链钉版、ohosArm64 / ohosX64 目标、纯 Kotlin 引擎抽取、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

Panels(MateriiApps/panels 1.0.1,Apache-2.0,坐标 io.github.materiiapps.panels:panels)是 Compose Multiplatform 的 Discord 式三面板布局库:SwipePanels 给移动端提供可拖拽的 start/center/end 三面板(松手按「位置 + 速度投影」判定开哪侧,StiffnessMediumLow 弹簧 settle,隐藏侧不参与组合),StaticPanels 给桌面端提供并排三面板(侧栏即时展开/收起,widthIn 钳制宽度)。本次适配与 KStore 那类「整库真编译」路线有本质区别:panels 是纯 Compose UI 库(上游 Compose 1.3.1 / Kotlin 1.8.10,只发 jvm+android),语义粘在 BoxWithConstraints + foundation.gestures.draggable + Animatable 组合宿主上,而社区 CMP 的 HarmonyOS 线没有场景宿主——所以走的是 cmp_qrose / Swipeable-KMP / Vico / Reorderable / compose-shimmer 同一条已过审的路:把手势与动画语义抽取为纯 Kotlin 引擎,双 ABI 编成 libohospanels.so,@CName("PanelsBridge") 单函数 JSON 桥出去,CMake NAPI 包装成 libpanels_napi.so,ArkTS Demo 拿数值帧自绘同款交互。引擎语义由 JVM 上 26 项交叉验证用例逐位钉死(夹取公式、判定树、velocity/26f 魔数、临界阻尼弹簧闭式解、widthIn 区间全部与上游同构)。

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

上游仓库: MateriiApps/panels

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

Panels 为 Compose Multiplatform 应用提供「Discord 式三面板」这一件事的完整答案:SwipePanelsState 管三态(Start/Center/End)与拖拽标志,SwipePanels 用 BoxWithConstraints 拿容器宽度、Modifier.draggable 收手势增量、Animatable.animateTo(spring(stiffness = Spring.StiffnessMediumLow)) 做松手吸附,隐藏侧面板按偏移符号条件组合;StaticPanelsState 管两侧展开布尔,StaticPanels 用 Row + widthIn(min, max) 把侧栏宽度钳在 72…256dp。KMP 项目把这些能力放在 commonMain,由 Android、桌面共享。OpenHarmony 应用同样需要这套布局交互,但上游构建(Kotlin 1.8.10 / Compose 1.3.1 / AGP 7.4.2 / Gradle 8.0)不会生成 OpenHarmony 变体,而且上游停在 2023 年 4 月的 1.0.1 不再演进。

如果只把示例页面改成 ArkTS,页面可以画出三栏,却无法证明上游的手势判定、速度投影与弹簧语义在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证布局引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 1.8.10 / Compose 1.3.1 / AGP 7.4.2 / Gradle 8.0 与鸿蒙工具链无法共存于一个 Gradle 构建;org.jetbrains.compose 插件与 AGP 在 ohos 构建里没有对应制品。
UI 语义粘在宿主上全部价值是 @Composable:BoxWithConstraints、draggable、Animatable、derivedStateOf 都要求组合宿主;社区 CMP 的 HarmonyOS 线没有场景宿主,Composable 编上去也无处运行。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
手势语义跨不了桥拖拽增量、速度投影、settle 动画是 Kotlin 侧概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:手势与动画语义的纯 Kotlin 引擎抽取(逐位复刻上游)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。面板语义完全由引擎负责,ArkTS 只负责喂手势、取数值帧和绘制。

1.2 库提供的能力

Demo 提供一个真实的布局会话,对应桥接的全部入口:

  • 拖拽(drag):onDragStart 取消进行中的弹簧并置拖拽态,onDrag 把增量夹取进 ±maxWidthSynthetic(maxPanelWidth × 容器宽 + inBetweenPadding)——与上游 rememberDraggableState 的 clamp 逐字一致;
  • 松手判定(dragEnd):offsetWithVelocity = offset + velocity / 26f 过上游判定树——开侧门控 ±max × changeThreshold、关侧门控 ±max × (1 − changeThreshold)、已开侧不可被小拖「重开」;
  • 吸附(frame):临界阻尼弹簧闭式解 x(t) = target + (x0 − target)(1 + ωt)e^(−ωt)(ω = √400 = 20,即 Animatable.animateTo(spring(stiffness = Spring.StiffnessMediumLow)) 的零初速解),advance(dtMs) 解析积分,16ms ticker 逐帧取值,settle 后 currentValue = targetValue;
  • 可见性:startVisible = offset > 0、endVisible = offset < 0(对应上游「隐藏侧不组合」);
  • 程序化开合(setValue):openStart / openEnd / close 等价上游 SwipePanelsState 三方法;
  • 静态面板(static):两侧展开/收起即时翻转(上游无动画),widthIn 钳制把内容宽度夹进 72…256;
  • 版本握手(ping):返回 fork 版本号,来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
op / id回显本次操作与会话 id。
offset / value / target中心面板偏移(vp)、当前态、目标态。
isDragging / startVisible / endVisible拖拽标志与两侧可见性(Demo 条件渲染的依据)。
settled / maxWidthSyntheticticker 停表依据与夹取上界(Demo 布局计算用)。

引擎侧完整能力(判定树、弹簧、可见性、配置重开)全部在 commonMain 里随编译进入 .so,JVM 侧保留 26 项交叉验证用例,证明 ohos .so 里执行的布局逻辑与上游逐位一致。

1.3 实现适配

维度要求
代码复用上游 SwipePanels.kt / StaticPanels.kt 经 git mv 归档到 panels/src/composeMain(保留上游署名,不参与任何目标编译);引擎在 commonMain 逐位复刻,不是改写上游文件。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 examples/android、examples/desktop 留盘不进构建。
桥接稳定单个 C ABI + UTF-8 JSON + 会话表,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律引擎零第三方依赖(只有 Kotlin stdlib),无需 kotlinx 制品线;上游 explicitApi() 严格模式保留。
UI 完整三 Tab 覆盖全部 13 个桥接入口:Swipe 拖拽 + settle ticker + 程序化开合、Static 展开收起 + 宽度钳制、About 版本握手。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙线没有 Compose 场景宿主,ArkTS Demo 拿引擎输出的数值帧自绘同款交互——手势判定与动画决策全部在 Kotlin 引擎里,ArkTS 只做平移与绘制。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、判定「场景宿主」缺失、确定抽引擎路线
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.0→8.14.1
第 3 阶段:桥协议建模     ── 会话表 + 13 op JSON 契约、vp 单位约定、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── PanGesture 喂入、16ms settle ticker、程序化开合、宽度钳制自检
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
panels/                               上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
panels/src/commonMain/                新增:SwipePanelsValue / SwipePanelsEngine / StaticPanelsEngine(引擎三件)
panels/src/composeMain/               上游 Compose 源码归档(git mv,不编译,不许删)
panels/src/androidMain/               上游空 AndroidManifest,随树保留
examples/android / examples/desktop   上游示例(AGP / 旧 Compose 线),留盘不进 settings
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson + copyKnSo 任务)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 三 Tab 页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md / *.upstream       上游原文与构建文件留档

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :panels 与 :example:nativeApp,rootProject.name = "ohos_panels"。上游的 org.jetbrains.compose / AGP / maven-publish / signing 插件链全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Eclipse Temurin jdk-21.0.12)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 8.0 升级;2.2.21 线社区先例统一 8.14.1)
三方依赖无(引擎零依赖,只有 Kotlin stdlib)
DevEco product5.1.0(18) 兼容
DevEco Studio6.0.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.panels
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块commonMain 文件依赖形态处置
panels2100% androidx.compose.* 导入,零 expect/actual、零平台源码集引擎抽取(commonMain 新增 3 文件)+ 上游源码 git mv 归档 composeMain
examples/android1AGP + activity-compose + material3留盘不进构建
examples/desktop1compose.desktop.currentOs留盘不进构建

全库 import 清一色 androidx.compose.animation.core / foundation.gestures / foundation.layout / runtime / ui,没有一行 android.*、skiko 或 UIKit——这是语义可以完整复刻的关键:枚举、状态、判定树、弹簧数学可下沉为纯 Kotlin,@Composable / Modifier 链条留档不编译。expect/actual 全库为零,ohos 侧因此一个 actual 都不用补;代价是引擎必须自证没有漂移(见 6.2 的 26 项用例)。

1.4 页面边界
Tab 切换        Swipe / Static / About(底部 TabsController)
Swipe Tab       顶栏 ☰ / ✕ / 👥 三按钮(openStart / close / openEnd)
                三面板 Stack:start 服务器栏(startVisible 条件渲染)
                            center 频道+聊天(恒渲染,translate(offset))
                            end 成员栏(endVisible 条件渲染)
                状态行        value / target / isDragging / offset 实时读数
Static Tab      控制栏        Collapse start / Expand end 两组开关
                并排三栏      start 频道栏(72..256 钳制) + center 聊天(layoutWeight(1)) + end 成员栏
                分段控件      Narrow 56 / Medium 180 / Wide 320 vp → static.width 钳制自检行
About Tab       ping 版本握手行、桥说明、仓库与社区链接

页面首次布局经 onAreaChange 拿到容器宽度后创建 swipe 会话,旋转 / resize 走 swipe.config 重下发。

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

2.1 路线验证(本适配的成败手)

先回答「Compose 能不能直接编上 ohos」:社区 Maven(https://maven.eazytec-cloud.com)上有 HarmonyOS Kotlin 插件线(2.2.21-1.0.0,cmp_qrose / Swipeable-KMP 用的同款),但 Compose 侧只有 1.9.x 时代的 runtime / foundation klibs——没有场景宿主,BoxWithConstraints / draggable / Animatable 的组合执行环境在鸿蒙线上不存在。把 Compose 1.3.1 的两个 Composable 硬接到这条线上,等于自带一套 Compose 发行版,与 Swipeable-KMP 适配上游 Swipeable 时的判例一致。因此路线定为:抽引擎——上游文件归档留档,语义在 commonMain 复刻,ArkTS 拿数值帧自绘。

第二个验证是依赖面:panels 引擎抽取后零第三方依赖(连 kotlinx-coroutines 都不需要,弹簧是解析解不是协程),工具链因此可以钉社区最成熟的 2.2.21-1.0.0 线,不必追随 KStore 那次因 kotlinx 制品反推的 2.4.20-0.1.0-07。

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

上游 wrapper 是 Gradle 8.0(2023 年),HarmonyOS Kotlin 2.2.21-1.0.0 插件在它上面跑不起来——社区先例(cmp_qrose / Swipeable-KMP / wavy-slider)统一升到 8.14.1,本仓照做(gradle-wrapper.properties 只改 distributionUrl,wrapper jar 与 gradlew 脚本对齐同线已审仓库)。JDK 21 经 org.gradle.java.home 钉死。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_panels"

include(":panels")
include(":example:nativeApp")

版本目录只有一条插件:

[versions]
kotlin = "2.2.21-1.0.0"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
2.3 编译真实引擎

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

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

kotlin {
    // Upstream ships with explicit API mode; the engine keeps it.
    explicitApi()

    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}

        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

实测:

./gradlew :panels:compileKotlinJvm :panels:compileKotlinOhosArm64 :panels:compileKotlinOhosX64   # BUILD SUCCESSFUL
./gradlew :panels:jvmTest                                                                       # 26 项全绿

引擎三件(SwipePanelsValue.kt / SwipePanelsEngine.kt / StaticPanelsEngine.kt)是全仓唯一新增的库源码;上游两个文件在 composeMain 一个字节没动。

2.4 测试的处置:ohos 测试任务全局禁用

引擎测试在 commonTest,对 ohos 测试编译同样可见,而 ohos 侧没有测试运行环境。处置与社区同款:测试统一在 JVM 跑(jvmTest),根构建全局禁用 ohos 测试任务:

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

第 3 阶段:桥协议建模

3.1 JSON 契约
io.github.materiiapps.panels/**(纯 Kotlin 引擎,逐位复刻上游)
        ↓ SwipePanelsEngine / StaticPanelsEngine / 会话表
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API panelsBridge()
        ↓
ArkUI Index.ets(三 Tab)

请求与响应:

请求 : {"op":"swipe.create","id":"swipe","width":360,"maxPanelWidth":0.9,
        "changeThreshold":0.1,"inBetweenPadding":0,"initial":"Center"}
       {"op":"swipe.drag","id":"swipe","delta":-12.5}
       {"op":"swipe.dragEnd","id":"swipe","velocity":-820}
       {"op":"swipe.frame","id":"swipe","dtMs":16}
       {"op":"swipe.setValue","id":"swipe","value":"Start"}
       {"op":"static.set","id":"static","startExpanded":false}
       {"op":"static.width","id":"static","side":"start","contentWidth":56}
响应 : {"ok":true,"op":"swipe.dragEnd","id":"swipe","offset":-95.0,"value":"Center",
        "target":"End","isDragging":false,"startVisible":false,"endVisible":true,
        "settled":false,"maxWidthSynthetic":324.0}
       {"ok":false,"error":"unknown id"}

三个约定:其一,会话表——每个布局实例用 id 注册一个引擎(swipes / statics 两张 HashMap),调用只来自 ArkTS UI 线程,无锁;其二,单位统一 vp——容器宽、增量、速度、偏移全部 vp,桥不做任何换算;其三,帧驱动——settle 期间 ArkTS 每 16ms 调一次 swipe.frame {dtMs:16},settled:true 停表。

3.2 弹簧为什么用闭式解

上游 settle 是 Animatable(offset).animateTo(target, spring(stiffness = Spring.StiffnessMediumLow))——临界阻尼(dampingRatio 1.0)、刚度 400、零初速。引擎不复刻 Animatable 的帧循环,直接解微分方程:

val omega = sqrt(SPRING_STIFFNESS)            // ω = 20 rad/s
val decay = exp(-omega * elapsed)
offset = animTarget + (animFrom - animTarget) * (1f + omega * elapsed) * decay
if (abs(offset - animTarget) < 0.01f) {       // Animatable 同款 settle 阈值
    offset = animTarget
    currentValue = targetValue                // 上游 animateTo 完成后的提交
}

解析解的好处是帧率无关:16ms ticker 与 120Hz 屏幕的取值落在同一条曲线上,没有数值积分的累积漂移——6.2 节专门有用例钉住这一点。settle 时长对 900vp 全程位移约 0.7s,与 Compose 手感一致。

3.3 错误通道

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

@CName("PanelsBridge")
fun panelsBridge(request: String): String = try {
    /* op 分发 */
} catch (t: Throwable) {
    "{\"ok\":false,\"error\":\"${t.message?.escapeJson() ?: t::class.simpleName}\"}"
}

非法 JSON、缺 op / id、未知 op、未知会话 id,全部走同一个 ok:false 通道,ArkTS 侧统一渲染 bridge error: 行。

第 4 阶段:原生桥接(技术难点)

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

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制判定树,所以选择 C ABI + JSON。ArkTS 不能直接 import libohospanels.so——@CName 符号对 N-API 不可见,CAdapter 也不导出业务函数,libpanels_napi.so 才是真正的 NAPI 面。

4.2 Kotlin/Native 导出函数
@CName("PanelsBridge")
fun panelsBridge(request: String): String   // 13 op 一口进
C ABI 符号ArkTS 方法覆盖内核
PanelsBridgepanelsBridge(req)会话表 + swipe.* 8 op(create / config / dragStart / drag / dragEnd / setValue / frame / dispose)+ static.* 4 op(create / set / width / dispose)+ ping 版本握手
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"panelsBridge", nullptr, NapiPanelsBridge, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(panels_napi SHARED napi_init.cpp)
target_link_libraries(panels_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohospanels)

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

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

第 5 阶段:ArkUI 宿主

5.1 会话创建与手势喂入
private call(req: BridgeRequest): BridgeResp {
  return JSON.parse(panelsBridge(JSON.stringify(req))) as BridgeResp;   // 唯一桥入口
}

onAreaChange(() => {                    // 首布局建会话,旋转/resize 重下发
  if (!this.created && w > 0) {
    this.created = true;
    this.apply(this.call({ op: 'swipe.create', id: 'swipe', width: w,
      maxPanelWidth: 0.9, changeThreshold: 0.1, inBetweenPadding: 0 }));
  } else if (this.created && w !== this.containerWidth) {
    this.apply(this.call({ op: 'swipe.config', id: 'swipe', width: w }));
  }
})

PanGesture({ fingers: 1, direction: PanDirection.Horizontal, distance: 5 })
  .onActionStart(() => this.apply(this.call({ op: 'swipe.dragStart', id: 'swipe' })))
  .onActionUpdate((e) => {              // 增量喂引擎;速度按 (Δx, Δt) EMA 平滑估 vp/s
    this.apply(this.call({ op: 'swipe.drag', id: 'swipe', delta: e.offsetX - this.lastX }));
  })
  .onActionEnd(() => this.startSettle(this.call(
    { op: 'swipe.dragEnd', id: 'swipe', velocity: this.estimateVelocity() })))

apply(frame) 只做三件事:translate(frame.offset) 平移中心面板、按 startVisible / endVisible 条件渲染侧栏、刷新状态行。settle 期间起 16ms ticker:

private startSettle(f: BridgeResp): void {
  this.apply(f);
  if (f.settled) { return; }
  this.tickerId = setInterval(() => {
    const frame = this.call({ op: 'swipe.frame', id: 'swipe', dtMs: 16 });
    this.apply(frame);
    if (frame.settled) { clearInterval(this.tickerId); this.tickerId = -1; }
  }, 16);
}
5.2 页面交互

Swipe Tab 是 Discord 移动端造型:start 侧是服务器图标栏(72vp 竖排彩色圆标),center 是频道列表 + 聊天流(恒渲染、随 offset 平移),end 侧是成员列表(头像 + 昵称)。顶栏 ☰ / ✕ / 👥 三按钮走 swipe.setValue 程序化开合,与手势共用同一条 settle 链。Static Tab 是 Discord 桌面造型:并排三栏,控制栏两组开关即时翻转(上游无动画),分段控件 Narrow 56 / Medium 180 / Wide 320 vp 喂 static.width,把 widthIn 钳制(56→72、320→256)用自检行直接画在页面上。About Tab 展示 ping 版本握手行、桥说明与仓库 / 社区链接。

5.3 对比度和错误边界

Discord 三阶深色背景(#1e1f22 服务器栏 / #2b2d31 频道栏 / #313338 聊天区)区分层级;ok:false 时错误原文进状态行,Native 调用本身抛错(理论不该发生)同样被 try/catch 收进显示。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohospanels)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/io/github/materiiapps/panels/bridge/
│       └── Bridge.kt                         @CName PanelsBridge + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.panels、versionName 1.0.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/libohospanels_api.h
        ├── src/main/cpp/types/libpanels_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
./gradlew :panels:jvmTest                                                    # 26 项交叉验证
./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 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。命令行打包有两个本机实测的环境要点:DevEco 装在带空格路径下时用 node "<DevEco>/tools/hvigor/bin/hvigorw.js" assembleHap ... 而不是 hvigorw.bat;PackageHap 阶段需要 java(把 DevEco 的 jbr/bin 加进 PATH,否则 spawn java ENOENT)。

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

四、完整代码对照

4.1 整体架构

io.github.materiiapps.panels/**(纯 Kotlin 引擎,逐位复刻上游)
    │ SwipePanelsEngine(判定树 / velocity÷26f / 闭式弹簧)
    │ StaticPanelsEngine(展开布尔 / widthIn 钳制)
Bridge.kt + MiniJson
    │ PanelsBridge(会话表 × 2、13 op)
libpanels_napi.so
    │ panelsBridge
Index.ets
    │ JSON parse + translate(offset) + @State 更新
    ▼
ArkUI 真机页面(三 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_panels、上游示例移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单插件钉版(上游无版本目录,坐标在 gradle.properties)
build.gradle.kts(根)版本 1.0.1-ohos.1、全局禁用 Ohos*Test* 任务
panels/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,explicitApi() 保留
panels/src/commonMain/.../SwipePanelsEngine.kt 等 3 文件全部新增的库源码(引擎三件)
panels/src/composeMain/上游两文件 git mv 归档,不编译、不许删
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鸿蒙适配主文档(19 节)

4.3 关键 API 对照

层次API作用
上游SwipePanelsState.setValue / openStart / openEnd / close程序化开合
上游SwipePanels(maxPanelWidth, changeThreshold, inBetweenPadding)拖拽三面板布局
上游StaticPanelsState / StaticPanelsMetrics展开布尔 / 72…256 宽度区间
引擎SwipePanelsEngine.onDragStart / onDrag / onDragEnd(velocity)clamp、判定树、velocity / 26f 投影
引擎SwipePanelsEngine.advance(dt)闭式弹簧积分,settle 提交 currentValue
引擎SwipePanelsEngine.updateConfig(containerWidth)旋转 / resize 重钳 + 按目标重开(唯一记录在案的差异)
引擎StaticPanelsEngine.clampStartPanelWidth(w)widthIn(min, max) 同构钳制
NativePanelsBridgeC ABI JSON 导出(13 op)
N-APIpanelsBridge(req)向 ArkTS 暴露能力
ArkTScall(op) / apply(frame)触发操作并按数值帧刷新 UI

4.4 ArkTS 与 Kotlin 的边界

const frame = JSON.parse(panelsBridge(JSON.stringify({
  op: 'swipe.dragEnd', id: 'swipe', velocity: -820
})));
// frame.ok === true;frame.target === 'End'、frame.settled === false → 起 16ms ticker
val offsetWithVelocity = offset + velocity / VELOCITY_DIVISOR      // 26f,上游魔数
val decided = when {
    targetValue != SwipePanelsValue.Start &&
        offsetWithVelocity >= maxWidthSynthetic * changeThreshold -> SwipePanelsValue.Start
    /* ……上游判定树逐分支复刻…… */
    else -> currentValue
}

两者之间只传输扁平 JSON 字符串与数值帧,不传输 Kotlin 对象;判定与动画决策只发生在引擎一侧。

五、关键决策说明

决策 1:抽引擎而不是硬编 Compose

panels 的全部价值是 Composable,而鸿蒙线没有 Compose 场景宿主——BoxWithConstraints 拿不到约束、draggable 收不到手势、Animatable 无处调度。硬把 Compose 1.3.1 源码接到社区 1.9.x klib 上等于自带一套 Compose 发行版,与 Swipeable-KMP 适配上游 Swipeable 时的判例一致。抽引擎让判定树、速度投影、弹簧这些「数值语义」以纯 Kotlin 存续并可用测试钉死,ArkTS 只负责绘制。

决策 2:工具链钉 2.2.21-1.0.0,Gradle 升 8.14.1

引擎零第三方依赖,不需要为 kotlinx 制品反推版本,直接钉社区最成熟的 HarmonyOS Kotlin 2.2.21-1.0.0(cmp_qrose / Swipeable-KMP / wavy-slider 同线)。该线对应 Gradle 8.14.1——上游 wrapper 8.0 必须升级,这是与 KStore(2.4.20 线沿用 9.x)相反方向的另一次「工具链线经验不能互套」:2.2.21 旧线配 8.14.1,2.4.20 新线配 9.x。

决策 3:根构建替换(cmp_qrose 模式),上游示例留盘

上游根构建(compose 插件 / AGP / maven-publish / signing)对鸿蒙没有保留价值,整个替换为极简 ohos 构建,原文件留档 *.upstream。examples/android 依赖 AGP + 旧 Compose 线、examples/desktop 依赖 compose.desktop,鸿蒙构建一个都解析不了——留盘不进 settings.gradle.kts,永不与鸿蒙线同步。

决策 4:单函数 13 op 会话桥 + 全 vp 单位

一个 @CName("PanelsBridge") 覆盖两套引擎全部入口:swipe.* 8 个、static.* 4 个、ping 1 个。会话表让一个 Demo 页面同时持有多个布局实例互不干扰;调用只来自 ArkTS UI 线程,HashMap 无锁;长度单位统一 vp,桥不做换算,dtMs 由调用方上报——引擎对宿主的帧率零假设。

决策 5:26 项 JVM 交叉验证 + explicitApi 保留

上游没有测试,复刻必须自证:26 项用例逐位钉住 maxWidthSynthetic 公式、对称钳制、判定树全部分支(含「已开侧不可重开」门控)、velocity / 26f 投影、反阈值关闭、闭式弹簧无过冲与 settle 时长、可见性符号规则、widthIn 区间。上游 explicitApi() 也保留——引擎作为库的公开面,可见性必须显式。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Eclipse Temurin jdk-21.0.12)、DevEco Studio 6.0、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :panels:jvmTest

26 项用例全部通过(上游无测试,用例为本次新增):

测试类用例数钉住什么
SwipePanelsEngineTest21maxWidthSynthetic 公式;对称钳制;判定树(开 Start/End 阈值、低于阈值回中、Start 小拖保持、反阈值关闭 ×2、重开门控);velocity/26f 投影 ×2;setValue 弹簧提交;临界阻尼无过冲 ×2;settle 时长 0.35–1.4s;拖拽取消动画;可见性符号;openStart/openEnd/close 全链;config 重开 / 拖拽中重钳 / 晚配置动画;16ms 与 8ms tick 同曲线
StaticPanelsEngineTest5默认指标 72…256;即时翻转;两侧独立;widthIn 钳制三段;自定义指标独立钳制

其中「tick 率无关」用例值得单独一提:解析解让 18 tick × 1/60s 与 36 tick × 1/120s 落在同一 offset(容差 0.01vp)——数值积分做不到这一点。

6.3 HAP 验证

:panels:compileKotlinJvm / OhosArm64 / OhosX64: BUILD SUCCESSFUL
:panels:jvmTest: 26 项全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: finished(三件套回填 entry/libs/<abi>/)
BuildNativeWithCmake: finished(双 ABI 编出 libpanels_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 6.9 MB,内含双 ABI 各三件套:HAP 内 strip 后 libohospanels.so arm64 约 1.9MB、x86_64 约 1.8MB(debugShared 未 strip 时分别约 3.5MB / 3.2MB,含 Kotlin/Native 运行时),libpanels_napi.so 约 64KB,外加 ArkTS 字节码 modules.abc 约 124KB。

6.4 功能用例

  1. 启动后 About Tab 自检行出现 ping → 1.0.1-ohos.1(来自真实 Native 调用,非静态文字);
  2. Swipe Tab 从中心向右拖,start 服务器栏随 startVisible 出现,中心面板同步右移;
  3. 松手越过 changeThreshold 门控,面板以弹簧吸附到 Start,全程无过冲、无回弹,约 0.7s 落定,状态行 value 变为 Start;
  4. Start 展开时向左拖回越过 1 − changeThreshold,松手自动关闭回 Center;
  5. 顶栏 ☰ / ✕ / 👥 三按钮程序化开合,与手势共用同一条 settle 链;
  6. 快速轻扫(速度主导)时 velocity/26f 投影参与判定,位移不足阈值也能开门;
  7. Static Tab 两组开关即时翻转(无动画,同上游),两侧独立互不影响;
  8. 分段控件选 Narrow 56vp,自检行显示 static.width(start, 56) → 72(钳到 startMin);选 Wide 320vp 显示 → 256(钳到 startMax)。

6.5 验证结论

布局引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的布局会话:手势拖拽、松手判定、弹簧吸附、程序化开合与静态面板宽度钳制(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/panels.git
cd panels
export JAVA_HOME="/path/to/jdk-21"
./gradlew :panels:compileKotlinOhosArm64 :panels:compileKotlinOhosX64             # klib
./gradlew :panels:jvmTest                                                        # 26 项交叉验证
./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.panels -m entry
hdc shell hilog | grep Panels

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :panels 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 上游 Gradle 8.0 wrapper 跑不了 HarmonyOS Kotlin 2.2.21 插件——2.2.21 线必须配 8.14.1(社区先例统一),wrapper jar 与 gradlew 脚本一并对齐同线已审仓库;
  4. explicitApi() 模式下表达式函数体必须显式写返回类型(public fun openStart(): Unit = ...),否则编译期报 Return type must be specified in explicit API mode;
  5. settle 弹簧必须用闭式解而不是数值积分:16ms ticker 的累积漂移会让不同帧率设备手感不一致,「tick 率无关」要用例钉死;顺带一提,测试自己先用 while (t < 0.4f) 累加 1/120f 踩过 Float 累计的坑(48 次累加落在 0.4f 之下多跑一帧),帧数取整对比才稳;
  6. PackageHap 阶段报 spawn java ENOENT:hvigor 打包器要起 java 子进程,把 DevEco 自带 jbr/bin 加进 PATH 即解;
  7. DevEco 装在带空格的路径下时 hvigorw.bat 解析出错,改用 node "<DevEco>/tools/hvigor/bin/hvigorw.js" 直调;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可(模拟器是 x86_64,真机是 arm64);
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. entry/libs/、.hvigor/、.cxx/、oh_modules/ 与 *.so 要单独追加 gitignore,根构建的 /build/ 盖不住嵌套目录;
  11. 签名材料由 DevEco 自动写入 build-profile.json5,仓库内保持 signingConfigs: [],证书绝不进入 AtomGit;
  12. ArkTS 侧只 import libpanels_napi.so:直接 import libohospanels.so 只能看到 CAdapter 符号,报 does not provide an export named 'panelsBridge'。

8.2 已知问题

  • Compose Composable 不在 ohos 运行:composeMain 归档仅为上游 API 留档,鸿蒙侧交互由 ArkUI 自绘;
  • 引擎与上游的唯一记录在案差异是 updateConfig:旋转 / resize 时引擎重钳偏移并按当前目标重开(上游把陈旧像素偏移留到下一次拖拽),Demo 转屏需要这个行为;
  • 上游 examples/android / examples/desktop 留盘不进构建,永不与鸿蒙线同步;
  • StaticPanels 侧栏内容宽度由 Demo 决定后送 static.width 钳制(上游是子组合测量出的宽度),语义同构但来源不同;
  • 桥是同步一来一回:isDragging 等中间状态由手势事件与帧驱动轮询获得,不是推送;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.panels 匹配的签名。

8.3 未来优化方向

  • 社区 CMP 出 ohos 场景宿主后,评估 Pattern A——composeMain 归档源码直编回库;
  • 用 uitest 补自动化截图与验收矩阵(Swipe 拖拽曲线、settle 落点、Static 钳制三段);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵。

九、总结

9.1 核心难点回顾

上游 panels(2 个纯 Compose 文件)
    → 判定「场景宿主」缺失(BoxWithConstraints / draggable / Animatable 无处运行)
    → 路线 B:抽引擎(cmp_qrose / Swipeable-KMP 同一判例)
    → 工具链对齐 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(wrapper 从 8.0 升级)
    → 根构建替换 + 上游源码 git mv 归档 composeMain
    → SwipePanelsEngine / StaticPanelsEngine(判定树 / velocity÷26f / 闭式弹簧 / widthIn)
    → C ABI(@CName PanelsBridge)+ 会话表 + vp 单位约定
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析 + translate(offset)
    → ArkUI 真机三 Tab 验收(拖拽 / 吸附 / 程序化开合 / 宽度钳制)

9.2 封装层次

  • 引擎层:SwipePanelsValue、SwipePanelsEngine(钳制、判定树、速度投影、闭式弹簧、可见性)、StaticPanelsEngine(展开布尔、widthIn 钳制);
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、手势增量与速度估计、16ms ticker、程序化开合与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. UI 库适配先判「场景宿主」存在与否,再定路线:宿主在(KStore 的引擎无 UI)就走整库真编译,宿主不在(panels 的价值全是 Composable)就抽引擎——路线必须基于当天生态的制品可达性,而不是把上一个仓库的结论平移过来;
  2. 复刻必须自证:上游没有测试,26 项 JVM 交叉验证就是「引擎没漂」的唯一闸门——velocity/26f 这种魔数、判定树的分支次序、弹簧的 settle 阈值,少钉一个都会在真机上变成「手感不对」这种查不出根因的缺陷;
  3. 数值帧桥让 ArkTS 只做绘制不做决策:判定与动画全部收敛在引擎一侧,ArkTS 拿到 offset 只 translate——两端不会各算一套手势语义,行为一致性是结构保证的,不是测试保证的。

9.4 适配成果

当前适配已完成:

  • panels 1.0.1 手势与布局语义的双 ABI Kotlin/Native 编译(上游两文件 git mv 归档零改动,引擎三件为唯一新增库源码);
  • 1 个 C ABI 符号和 C++ N-API 包装层(13 op 会话桥,双 ABI 三件套 so);
  • DevEco Stage ArkUI 三 Tab 验收页面(手势拖拽、松手判定、弹簧吸附、程序化开合、静态面板展开收起与宽度钳制、版本握手);
  • 26 项 JVM 交叉验证全绿 + HAP 构建(约 6.9MB)、签名、设备安装和真机验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐