本文记录 ProgramGuide 接入 OpenHarmony 的完整过程,覆盖 EPG 布局库盘点、Compose 源集隔离、vendored MinaBox 引擎、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI / N-API 桥接、ArkUI 节目单页面、HAP 构建和真机验收。

ProgramGuide 是基于 Compose Multiplatform 与 MinaBox 的电子节目单(EPG)懒加载布局库:节目、频道列、时间轴行、当前时间线与角格五类条目注册到一个可双向滚动的二维平面上,频道列钉在左侧、时间轴行钉在顶部,小时用 Float 表示(9.5f = 09:30),并提供 animateToProgram / snapToChannel 等八个状态滚动入口。本次适配没有提取"零依赖内核",也没有用 ArkTS 重写一遍 EPG——上游的坐标换算 ProgramGuideItem.toMinaBoxItem 与索引映射 ProgramGuideIndexMapper 原样编译成 OpenHarmony KLIB,平面几何复用同作者 minabox 已过审适配的 MinaBoxSolver(vendored 三文件),一条"上游数学真实运行在鸿蒙 Native 层"的节目单摆在真机上,ArkUI 页面作为验收宿主。

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

上游仓库: oleksandrbalan/programguide

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

ProgramGuide 为 Compose Multiplatform 应用提供"节目单布局"这一件事的完整答案:ProgramGuide { programs(...) channels(...) timeline(...) } 一段 DSL 注册全部条目,ProgramGuideDimensions 控制五类条目尺寸(隐藏区域官方手法就是尺寸归零),ProgramGuideState 暴露八个 animateTo / snapTo 滚动入口并支持 rememberSaveable 状态恢复。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Wasm 共享。OpenHarmony 应用同样需要节目单布局,但上游构建(Kotlin 2.1.10 / AGP 8.8.1 / Compose 1.7.3)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示格子,却无法证明公共的 EPG 几何在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证时间→坐标换算、section 索引映射、锁定行列的平面几何、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.1.10 / Compose 1.7.3 与鸿蒙工具链无法共存于一个 Gradle 构建。
依赖矩阵库 api 依赖 Compose runtime/foundation 与 io.github.oleksandrbalan:minabox:1.10.0——前者鸿蒙侧无对应 CMP,后者 Maven Central 制品无 ohos 变体。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
整库皆 UI上游 7 个源文件里 5 个长在 Compose 生命周期上,"抽内核"无内核可抽。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:上游几何原件的 ohos 编译、minabox 引擎 vendored、根构建替换(cmp_qrose 模式)和 C ABI / N-API 桥接层。EPG 语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和渲染。

1.2 库提供的能力

Demo 用一个真实的 EPG 会话覆盖桥的全部入口,对应上游 demo 的四个示例:

  • Simple:20 频道 × 8…22 时间轴,频道列钉左、时间轴行钉顶,节目区双向拖动,视口外条目不存在于节点树;
  • Config:显示开关四连——频道列 / 时间轴行隐藏时尺寸归零(channelWidth = 0 语义)、当前时间线与角格增删;
  • State:初始偏移对齐 12:30 当前时间线;点击节目 / 频道 / 时间轴格子做 300ms 居中动画(animateToProgram / Channel / Timeline 的桥等价);平移贴边出现浮动按钮,点击重新随机生成节目单;
  • Size:字号 1…3 联动行高 / 列宽 / 时间轴高,对应上游"预计算尺寸支持动态字号"。

每 Tab 底部状态行实时回报 可视 N 项 · tx a/maxX · ty b/maxY · 平面 W×H px,任何桥异常走红色错误行与 hilog,不崩溃。

1.3 实现适配

维度要求
代码复用ProgramGuideItem.kt(含 toMinaBoxItem 坐标换算)与 ProgramGuideIndexMapper.kt 原样编译,零改动;Compose 文件 git mv 进 composeMain 隔离(rename 保留历史)。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 demo / androiddemo / desktopdemo / wasmdemo / iosdemo / build-logic 留盘不进构建。
依赖纪律不引入任何 kotlinx 第三方制品;minabox 依赖以 vendored 三文件(其已过审 ohos 适配的 commonMain)进入本仓,包名保持 eu.wewox.minabox 便于对照。
桥接稳定单函数 C ABI + 扁平 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
UI 完整单页四 Tab 覆盖全部桥入口:拖动 / 惯性 / 对齐动画 / 尺寸联动 / 状态回报。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。EPG 的可视性、边界、钉行列几何在 Kotlin 侧求解,ArkTS Demo 只把桥暴露的 cells 画出来。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点 7 个源文件的依赖边界、确认 minabox 依赖无 ohos 制品、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、vendored 引擎
第 3 阶段:桥协议建模     ── EPG 语义载荷、扁平三元组节目数组、target 对齐契约、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── EpgEngine 手势/惯性/对齐动画、四 Tab 页面、差量渲染
第 6 阶段:示例与验证     ── JVM 交叉验证、so 符号检查、HAP、签名、设备安装和真机运行

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
programguide/                        上游 EPG 语义层(进 ohos 构建,jvm + ohosArm64 + ohosX64)
programguide/src/commonMain/
    eu/wewox/programguide/           上游原件:ProgramGuideItem(含 toMinaBoxItem)+ IndexMapper
                                     新增:ProgramGuideSolver / ProgramGuideModel(Compose-free 门面)
    eu/wewox/minabox/                vendored:MinaBoxItem / MinaBoxModel / MinaBoxSolver
programguide/src/composeMain/        上游 Composable / Scope / State / PositionProvider(不编译)
programguide/src/androidMain/        上游遗留空 AndroidManifest(惰性存在)
example/nativeApp/                   Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                  DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md       鸿蒙适配说明(中英双份)
README.upstream.md                   上游原文保存
demo/ sample 等上游目录               留盘不进 settings

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :programguide 与 :example:nativeApp,rootProject.name = "ohos_programguide"。上游的 AGP / compose / cocoapods / detekt / spotless / maven-publish / includeBuild("build-logic") / foojay resolver 全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin jdk-21.0.12.101-hotspot,钉在 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(上游 8.11.1 → 跟随社区模板升级,wrapper 只改 distributionUrl)
kotlinx 制品零——上游 commonMain 只依赖 compose 与 minabox,前者隔离、后者 vendored
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.programguide
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点

上游 commonMain 共 7 个文件、约 930 行,没有 expect/actual、没有平台源码集(androidMain 只有空 manifest),逐文件分类:

文件行数依赖形态处置
ProgramGuideIndexMapper.kt38零 import 纯 Kotlin原样编译
ProgramGuideItem.kt118仅依赖 minabox 的 MinaBoxItem原样编译(vendored 后可解析)
ProgramGuideDimensions.kt50Dp / Densitygit mv → composeMain
ProgramGuide.kt92@Composable / MinaBoxgit mv → composeMain
ProgramGuideScope.kt309@Composable lambda / MinaBoxScopegit mv → composeMain
ProgramGuideState.kt208rememberSaveable / MinaBoxStategit mv → composeMain
ProgramGuidePositionProvider.kt113Alignment / Offset / minaboxgit mv → composeMain

盘点结论与 minabox 适配时一致:库的全部几何逻辑要么长在 Compose 文件内部,要么依赖 minabox 类型。解法是双管齐下——上游两件纯 Kotlin 原件留下,其余新增门面与 vendored 引擎补齐,而不是"顺手挪一点"。

1.4 页面边界
标题区          PROGRAMGUIDE · EPG 懒加载布局 · OPENHARMONY 演示
Tab 切换        Simple / Config / State / Size(scrollable(false),避免与拖动手势冲突)
Simple          20 频道 × 8..22 时间轴,节目 / 频道列 / 时间轴行,双向拖动
Config          显示开关四连(频道列 / 时间轴行 / 当前时间线 / 角格)
State           初始对齐 12:30,点击格子居中动画,贴边浮动按钮换节目单
Size            字号滑杆 1x..3x,行高 / 列宽 / 时间轴高联动
状态行          可视条目数 · tx/maxX · ty/maxY · 平面尺寸(实时)
页脚            AtomGit 仓库与社区链接

页面启动即调 programGuideLayout 拉取第一帧 cells,onAreaChange 修正真实视口后重算。

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

2.1 依赖可达性验证(本适配的成败手)

上游 api 依赖三件:compose.runtime、compose.foundation、minabox 1.10.0。前两件鸿蒙侧无对应 CMP 制品——它们承载的正是被隔离的 Compose 文件;第三件是纯算法库但 Maven Central 没有 ohos 变体。处置不是抽内核重写,而是把 minabox 已过审适配(1.10.0-ohos.1)中进入其 commonMain 的三个纯 Kotlin 文件原样 vendored 进本仓:

MinaBoxItem.kt     上游原件:item 几何模型(x/y/width/height + Absolute/MatchParent + lockH/lockV)
MinaBoxModel.kt    minabox 适配新增:Size/Point/Padding/Alignment/VisibleItem 纯 Kotlin 类型
MinaBoxSolver.kt   minabox 适配新增:平面求解器(planeSize/bounds/clamp/visibleItems/itemOffset)

这不是抄近路——minabox 适配文档第 10 节明文预留了这条路线:"LazyTable 和 ProgramGuide 都是在 minabox 的二维平面上盖的楼……将来适配这两个库,ArkTS UI 各写各的,几何与桥原样复用。"两库同作者、同为 Apache-2.0,vendored 文件保持原包名 eu.wewox.minabox,与 minabox 仓逐文件可比对。

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

社区批准的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / minabox 用的同款)。本库零 kotlinx 依赖,不存在 KStore 那种"coroutines 制品线反推 2.4.20"的压力,直接落社区标准钉法。Gradle 上游 wrapper 8.11.1 → 8.14.1(与 HarmonyOS Kotlin 插件配套的社区模板值),gradlew 脚本与 wrapper jar 通用,只改 distributionUrl。

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

include(":programguide")
include(":example:nativeApp")
2.3 编译真实引擎

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

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

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()            // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}   // 零第三方依赖:minabox 已 vendored 进本模块
    }
}

实测:

.\gradlew.bat :programguide:compileKotlinJvm :programguide:compileKotlinOhosArm64 :programguide:compileKotlinOhosX64   # BUILD SUCCESSFUL

上游 toMinaBoxItem 与 IndexMapper 一个没动,commonMain 里新增的只有门面两文件与 vendored 三文件。

2.4 验证的处置:上游无测试源集,交叉验证自己搭

上游库模块不带 commonTest(src 下只有 commonMain 与 androidMain),KStore 那套"上游测试零改动全绿"没有对应物。处置是两条纪律的组合:根构建全局禁用 ohos 测试任务(HarmonyOS Kotlin 会生成跑不了的 OhosTest 任务),验证改由 JVM 交叉对照脚本承担(见 6.2 节)——同一份 commonMain 编进 JVM 与 so,两端摆出同一张节目单即为"内核没漂"。

第 3 阶段:桥协议建模

3.1 JSON 契约
eu.wewox.programguide/**(上游原件 + ProgramGuideSolver)
        ↓ 时间段→坐标 / IndexMapper / 对齐公式
eu.wewox.minabox/**(vendored MinaBoxSolver)
        ↓ 边界 / 夹紧 / 可视集 / itemOffset
Bridge.kt(@CName × 1,EPG 语义协议)
        ↓ UTF-8 JSON
N-API programGuideLayout(request)
        ↓
ArkUI Index.ets(四 Tab)

请求与响应:

请求 : {"vw":1080,"vh":2160,"tx":0,"ty":0,
        "guideStart":8,"hw":384,"th":96,"cw":192,"ch":192,"ct":6,
        "channels":20,"ts":8,"te":22,"corner":"true","currentTime":12.5,
        "programs":[0,8.5,10, 0,10,11.25, ...],
        "target":{"kind":"program","index":5,"halign":"center","valign":"center"}}
响应 : {"ok":true,"width":...,"height":...,"maxX":...,"maxY":...,"tx":...,"ty":...,
        "count":...,"cells":[kind,index,left,top,w,h, ...],"ox":...,"oy":...}
       {"ok":false,"error":"..."}

三个设计要点:programs 是扁平三元组数组 [channelIndex, startHour, endHour, ...],比对象数组省一半 JSON 体积;cells 步长 6 且带 kind 标注(0 节目 / 1 当前时间 / 2 频道 / 3 时间轴 / 4 角格),ArkTS 免去按索引区间反推种类;target 一次请求同时取回布局与对齐目标,省一次往返。

3.2 载荷为什么走 EPG 语义而不是原始 items

minabox 的桥收发原始 items[] 坐标。本桥刻意升到 EPG 语义(hours / channels / timeline range):"时间段→平面坐标"的换算必须发生在 Kotlin 侧——这正是 ProgramGuide 区别于 minabox 的价值所在。桥内按上游注册顺序(programs → current time → channels → timelines → corner)构建 List<ProgramGuideItem>,让上游 toMinaBoxItem 逐条换算。

suspend 状态 API 不跨 FFI:animateToProgram 家族折算成一次 target 请求,取回 ox/oy 后由 ArkTS 逐帧逼近;锁定轴语义由引擎自动满足(频道目标 x = translateX、时间轴目标 y = translateY,见 6.2 实测)。

3.3 错误通道

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

@CName("ProgramGuideLayout")
fun programGuideLayout(request: String): String = try {
    /* 解析 → 建模 → 求解 → 响应 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法视口(vw/vh <= 0)、节目三元组缺数、未知 target kind,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进错误行。

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

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

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;HarmonyOS Kotlin 的 CAdapter 又不会把 @CName 业务符号挂到 ArkTS——import 'libohosprogramguide.so' 只能看到 stdlib 类型。所以必须 CMake 再包一层 NAPI so。

4.2 Kotlin/Native 导出函数
@CName("ProgramGuideLayout")
fun programGuideLayout(request: String): String   // 布局 + 可选对齐目标,一口进
C ABI 符号ArkTS 方法覆盖内核
ProgramGuideLayoutprogramGuideLayout(req)toMinaBoxItem 坐标换算 + IndexMapper + MinaBoxSolver 全链 + targetOffset 对齐目标
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"programGuideLayout", nullptr, NapiProgramGuideLayout, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(programguide_napi SHARED napi_init.cpp)
target_link_libraries(programguide_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosprogramguide)

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

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

第 5 阶段:ArkUI 宿主

5.1 EpgEngine:手势、惯性与对齐动画

每个 Tab 持有一个 EpgEngine(px 坐标制):

updateDrag(dxPx: number, dyPx: number): void {
  /* EMA 平滑速度估计(v = v*0.55 + Δ/Δt*1000*0.45,dt∈(0,200) 才采样)*/
  this.tx -= dxPx;
  this.ty -= dyPx;
  this.layout();        // 每帧调桥
}

endDrag(): void {
  /* setInterval 16ms 惯性衰减 v *= 0.95/帧;
     用"请求 tx vs 桥夹紧回的 tx 偏差>0.5"检测顶边停摆 */
}

animateTo(kind: string, index: number): void {
  const res = this.callBridge(kind, index);   // 带 target 请求
  this.animateTranslateTo(res.ox, res.oy, 300);  // 300ms 逐帧线性插值
}

渲染范式:Stack + ForEach + .position()/.width()/.height() 绝对定位渲染可视 cells;@Observed cell 差量更新——id 已存在的只改坐标属性,新进视口的才创建对象,"视口外条目不存在于节点树"由此成立;桥走 px、ArkUI 渲染除以 density 转 vp。

5.2 页面交互

四 Tab 与上游 demo 一一对应(见 1.2 节)。State Tab 的初始偏移用 initFromCurrentTime() 复刻上游 initialOffset = { Offset(getCurrentTimePosition(), 0f) }:以平移量 (0,0) 请求 current 目标取 ox 作为初始 tx。贴边 48vp 显示 ‹ › 浮动按钮、点击重新生成节目单,随机数用带种子的 LCG(可复现,与上游 createPrograms 同规则:每频道从 ts 起连续铺 0.5~2.5 小时节目块)。

5.3 错误边界

callBridge 全程 try/catch,异常进错误行 + hilog 不炸 UI;ok:false 时错误原文进状态行。节目数据生成在 ArkTS 侧(上游 demo 的领域对象 Program(channel, start, end, title) 同构),桥保持无状态纯函数——与上游"布局库不管数据"的边界一致。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosprogramguide)+ copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       ├── Bridge.kt                         @CName("ProgramGuideLayout") + EPG 语义协议
│       └── Json.kt                           MiniJson(minabox 同款 + 标量数组 values() 扩展)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.programguide、versionName 1.6.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/libohosprogramguide_api.h
        ├── src/main/cpp/types/libprogramguide_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      四 Tab EPG 验收页面
6.2 构建、签名和安装
.\gradlew.bat :programguide:compileKotlinJvm :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

# DevEco Studio 打开 example/harmonyApp → Signing Configs 勾选自动生成 → 构建 HAP
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.programguide -m entry

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置;提交前已核对无签名材料混入)。

四、完整代码对照

4.1 整体架构

eu.wewox.programguide(上游原件原样编译)
    │ ProgramGuideItem.toMinaBoxItem:时间段 → 平面坐标(含 lockH/lockV/MatchParent)
    │ ProgramGuideIndexMapper:section 局部索引 ↔ 平面全局索引
eu.wewox.minabox(vendored 引擎)
    │ MinaBoxSolver:平面尺寸 / 边界 / 夹紧 / 可视集 / 9 种对齐偏移
ProgramGuideSolver(新增门面)
    │ layout() 一发全算 + targetOffset() 对齐目标
Bridge.kt + MiniJson
    │ ProgramGuideLayout
libprogramguide_napi.so
    │ programGuideLayout
Index.ets
    │ JSON parse + @State 差量刷新
    ▼
ArkUI 真机页面(四 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_programguide
gradle/libs.versions.toml只钉 HarmonyOS Kotlin 2.2.21-1.0.0
build.gradle.kts(根)版本 1.6.0-ohos.1、全局禁用 Ohos*Test* 任务
programguide/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零第三方依赖
programguide/src/commonMain/.../ProgramGuideItem.kt上游原件(含坐标换算),零改动
programguide/src/commonMain/.../ProgramGuideIndexMapper.kt上游原件,零改动
programguide/src/commonMain/.../eu/wewox/minabox/*.ktvendored 引擎三文件(原包名保留)
programguide/src/commonMain/.../ProgramGuideModel.kt新增:px 维度 / 种类枚举 / 结果类型
programguide/src/commonMain/.../ProgramGuideSolver.kt新增:Compose-free 求解器门面
programguide/src/composeMain/...上游 5 个 Compose 文件原样隔离(等 CMP 对齐接回)
example/nativeApp/build.gradle.kts双 ABI sharedLib、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、EPG 语义协议、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.ets四 Tab EPG 验收页面(EpgEngine + 差量渲染)
README.OpenHarmony_CN.md鸿蒙适配主文档(24 节)

4.3 关键 API 对照

层次API作用
引擎toMinaBoxItem(guideStartHour, dims)五类条目 → 平面坐标(上游原件)
引擎ProgramGuideIndexMapperprograms/current/channels/timelines 局部索引 → 全局索引(上游原件)
引擎MinaBoxSolver.visibleItems视口矩形重叠判定 + 锁定项单轴判定(vendored)
引擎MinaBoxSolver.itemOffset9 种对齐偏移,锁定轴返回当前平移(vendored)
门面ProgramGuideSolver.layout平面尺寸 / 边界 / 夹紧 / 可视 cells(带 kind 标注)
门面ProgramGuideSolver.targetOffsetanimateToProgram/Channel/Timeline/CurrentTime 的目标平移
NativeProgramGuideLayoutC ABI JSON 导出
N-APIprogramGuideLayout(req)向 ArkTS 暴露能力
ArkTSEpgEngine.animateTo(kind, index)触发对齐动画并刷新 UI

坐标公式(上游原件,供对照):

Program:      x = (startHour − guideStart) * hw + cw ; y = channelIndex * ch + th   (不锁定)
CurrentTime:  x = (hour − guideStart) * hw + cw ; y = 0 ; h = MatchParent(1f)       (lockV)
Channel:      x = 0 ; y = index * ch + th                                           (lockH)
Timeline:     x = (startHour − guideStart) * hw + cw ; y = 0                        (lockV)
TopCorner:    x = 0 ; y = 0 ; w = cw ; h = th                                       (lockH + lockV)

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(programGuideLayout(JSON.stringify({
  vw: 1080, vh: 2160, tx: 0, ty: 0, guideStart: 8,
  hw: 384, th: 96, cw: 192, ch: 192, ct: 6,
  channels: 20, ts: 8, te: 22, corner: 'true', currentTime: 12.5,
  programs: [0, 8.5, 10, 0, 10, 11.25],
  target: { kind: 'program', index: 0, halign: 'center', valign: 'center' },
})));
// res.cells 里 CHANNEL 的 left 恒为 0(钉左),TIMELINE 的 top 恒为 0(钉顶)
val items = buildList { /* programs → current → channels → timelines → corner */ }
val result = ProgramGuideSolver.layout(items, guideStart, dims, vw, vh, tx, ty)
val target = ProgramGuideSolver.targetOffset(items, guideStart, dims, vw, vh, kind, index, tx = tx, ty = ty)

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:真编译上游几何原件,而不是 ArkTS 重写

toMinaBoxItem 与 IndexMapper 是全库仅有的两件纯 Kotlin。让它们原样编进 ohos so,"上游代码在鸿蒙 Native 层运行"才有证据;ArkTS 重写无对照性,坐标公式抄一遍即引入漂移风险。

决策 2:vendored minabox 引擎,而不是抽内核或 includeBuild

上游对 minabox 是 Maven 依赖,ohos 侧无法解析;minabox 自己的已过审适配恰好把引擎做成了三个纯 Kotlin 文件。AtomGit fork 必须自包含,includeBuild 外部仓不成立;整文件 vendor 保持逐文件可比对,后续引擎更新可整文件替换。

决策 3:桥走 EPG 语义协议,而不是 minabox 的原始 items

minabox 桥收发坐标数组;本桥收发小时 / 频道数 / 时间轴范围,坐标换算留在 Kotlin 侧——这是 ProgramGuide 之所以是 ProgramGuide 的部分。附带收益:ArkTS 请求体小(节目数据是三元组数字),且 cells 自带 kind 标注免二次推断。

决策 4:composeMain 隔离 + 求解器进库模块、桥只进 example

Compose 文件 git mv 进未注册源集,等社区 CMP 对齐后原样接回;ProgramGuideSolver 是库的 public API(长期有价值),Bridge / MiniJson 只活在 example/nativeApp——桥是过渡形态,与 minabox 决策一致。

决策 5:JVM 交叉验证自建脚本

上游无测试源集,“上游测试零改动全绿"的 KStore 路径无对应物。改以 JVM 调用脚本对照关键数字(下节),两端同输入同输出即"内核没漂”。

六、测试与验证

6.1 测试环境

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

6.2 JVM 交叉验证

compileKotlinJvm 产物 + 一段 Java 调用(维度 128/32/64/64/2 vp × 3,条目按上游注册顺序):

断言实测钉住什么
width / height1926.0 / 288.0平面 = 各条目右缘 / 下缘最大值;MatchParent 当前时间线不撑高平面(上游 quirk)
maxX / maxY846.0 / 0.0边界 = (平面 − 视口) 下限 0
cellsPROGRAM(384,96,576,192)、CHANNEL(0,96,192,192)、TIMELINE(192,0,384,96)、CORNER(0,0,192,96)频道钉左(left=0)、时间轴钉顶(top=0)、坐标换算与上游公式一致
CURRENT_TIME不在 cellsx=1920 超出 1080 视口,懒加载正确排除
program0 目标ox=36.0, oy=-936.0居中对齐(未夹紧原值,ArkTS 每帧夹紧——与上游 Animatable 边界行为一致)
channel0 目标ox=0.0, oy=-936.0ox = translateX:锁定轴保持当前平移(上游 animateToChannel 语义)
越界夹紧请求 (−500, 99999) → (0, 0)clampTranslate 生效

同一组输入在鸿蒙侧 programGuideLayout 应得到相同数字——两端摆出同一张节目单。

6.3 so 与 HAP 验证

:programguide:compileKotlinJvm / OhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64: BUILD SUCCESSFUL in 31s
linkDebugSharedOhosX64:   BUILD SUCCESSFUL in 20s
llvm-nm -D: ProgramGuideLayout / libohosprogramguide_symbols 均为全局 T 符号
arm64-v8a: libohosprogramguide.so ≈3.6 MB + libc++_shared.so ≈1.8 MB
x86_64:    libohosprogramguide.so ≈3.3 MB + libc++_shared.so ≈1.7 MB
install bundle successfully / start ability successfully(真机 arm64)

CAdapter 的 api.cpp:448 clang 警告为社区已知无害项(qrose / minabox 相同)。

6.4 功能用例

  1. 启动 Simple Tab:频道列钉左、时间轴行钉顶,节目区双向拖动流畅,状态行"可视 N 项"随滚动在 40 上下波动——视口外条目不渲染;
  2. 拖到平面边界被夹稳,惯性在边界停摆;
  3. Config Tab 依次关闭四开关:关频道列后节目左移贴边(cw=0)、关时间轴后顶行消失(th=0)、当前时间线与角格增删即所见;
  4. State Tab 启动即横跨 12:30 当前时间线;
  5. 点击节目格:该节目 300ms 居中(双轴);点击频道格:该行垂直居中且横向不动;点击时间轴格:该小时水平居中且纵向不动——三种目标的锁定轴行为与上游一致;
  6. 平移贴左 / 右边出现 ‹ › 浮动按钮,点击后节目单重新生成,格子与坐标全量刷新;
  7. Size Tab 拖字号 1x→3x:行高 / 列宽 / 时间轴高与文字同步放大,平面尺寸按比例增长;
  8. 全程无崩溃;hdc shell hilog | grep ProgramGuide 可见请求字节日志。

6.5 验证结论

上游几何原件、vendored 平面引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的 EPG 会话:钉行列、双向懒加载、对齐动画、尺寸联动与状态回报(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/programguide.git
cd programguide
export JAVA_HOME="/path/to/jdk-21"
./gradlew :programguide:compileKotlinJvm                                    # JVM 交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco 打开 example/harmonyApp → 自动签名 → 构建 HAP
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.programguide -m entry
hdc shell hilog | grep ProgramGuide

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :programguide 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 上游 api(minabox) 在 Maven Central 无 ohos 变体——vendored 三文件是唯一自包含解法,minabox 适配文档第 10 节已预留此路线;
  4. ProgramGuidePxDimensions 原定义在随文件进 composeMain 的 ProgramGuideDimensions.kt 里,必须在 commonMain 重新声明(同包同名,composeMain 不编译故无冲突)——这类"类型卡在要隔离的文件里"的问题要在拆源集时逐个排查;
  5. section 的 kind/index 标注是按位置映射的(与上游 IndexMapper 一致):验证程序最初把条目排成 program/channel/timeline/current/corner,标注立刻错位——条目必须按注册顺序传入,桥内固定规范顺序,求解器 KDoc 已写明该约束;
  6. gradlew.bat 启动阶段读不到 gradle.properties 的 org.gradle.java.home,必须先设 Windows 风格 JAVA_HOME 环境变量;
  7. KMP 的 jvm 目标默认无 jar 任务,交叉验证直接用 build/classes/kotlin/jvm/main 目录作 classpath;Java 调 Kotlin 默认参数需显式传全;
  8. 鸿蒙侧无 Compose:rememberSaveable 状态恢复、Animatable 惯性都不存在,惯性(EMA 速度估计 + 0.95/帧衰减)与 300ms 对齐动画是 ArkTS 侧逐帧近似,边界停摆靠"请求平移 vs 夹紧回传偏差"检测;
  9. ArkUI 的 ForEach 键值不变时不重建组件:重新生成节目单后格子文字不刷新,键值必须带代号 ${gen}-${id} 强制重建;悬浮按钮定位不能用 .align()(那是内容对齐),要按视口尺寸算 .position();
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;ArkTS 只 import NAPI so;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. DevEco 打开工程后生成的 example/harmonyApp/.idea/、.clangd、.clang-tidy 与 clangd 索引数据库会混进提交,必须单独 gitignore——本次提交前实际剔除了一批,signingConfigs 空数组与证书不入库同样要逐项核对。

8.2 已知问题

  • 上游 contentPadding(PaddingValues)参数未桥接(demo 未使用;vendored 引擎本身支持,加 ps/pt/pe/pb 四个字段即可);
  • 时间轴条目按整点生成([ts, ts+1));上游允许非整点 start/end 的 Timeline,数据模型本身支持,需要时扩展请求字段;
  • 桥每次全量重算可视集,demo 量级(数百条目 / 每帧 ~10KB JSON)实测无压力;超大节目单建议 ArkTS 侧自行做空间索引或分段请求;
  • android / ios / wasm / js 目标剥离(源文件留在树里),发布、detekt、spotless 一并剥离,本 fork 不发 Maven Central;
  • composeMain 保留上游实现,等社区 compose.ui ohos 制品与 CMP 对齐后接回。

8.3 未来优化方向

  • 把 contentPadding 与非整点时间轴补进桥协议;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本;
  • 将 ProgramGuideSolver 反哺上游作为 commonTest 的伴随纯函数工具(先开 issue 讨论)。

九、总结

9.1 核心难点回顾

上游 programguide(7 文件,5 个是 Compose)
    → 盘点出仅有的两件纯 Kotlin(toMinaBoxItem / IndexMapper)
    → 5 个 Compose 文件 git mv 进 composeMain 隔离
    → minabox 依赖无 ohos 制品 → vendored 已过审引擎三文件
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1)
    → 根构建替换(cmp_qrose 模式)
    → ohosArm64 / ohosX64 KLIB + 门面 ProgramGuideSolver
    → C ABI(@CName × 1)+ EPG 语义 JSON 协议
    → C++ N-API(DisposeString 纪律)
    → ArkTS EpgEngine(手势 / 惯性 / 对齐动画 / 差量渲染)
    → ArkUI 真机四 Tab 验收

9.2 封装层次

  • 语义层:上游原版 ProgramGuideItem 五类模型与坐标换算、ProgramGuideIndexMapper 索引映射;
  • 引擎层:vendored MinaBoxSolver 平面尺寸、边界、夹紧、可视集与 9 种对齐偏移;
  • 门面层:ProgramGuideSolver.layout / targetOffset 把上游语义与引擎串联成纯函数;
  • Native 层:双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:EpgEngine 管理手势速度、惯性、动画与差量渲染,页面状态与错误文本;
  • DevEco 层:CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 依赖是 CMP 库时,先看它的鸿蒙适配仓:minabox 已过审的 ohos 适配把引擎做成了纯 Kotlin 三文件,programguide 的依赖问题由此变成"整文件 vendor + 保持包名"——比起抽内核或重写,这是唯一既有对照性又自包含的解法;依赖可达性决定适配路线,但"可达"包括姊妹仓的已审成果;
  2. 桥的抽象层级要贴合库的价值层:minabox 桥收发坐标,本桥收发小时与频道数——坐标换算必须留在 Kotlin 侧,否则适配的就不再是 ProgramGuide 而是一块普通画布;协议设计先问"这个库区别于底层的部分是什么";
  3. 交叉验证的断言要钉到锁定语义:平面尺寸谁都算得对,真正区分实现的是 channel0 目标 ox=translateX 这类锁定轴行为与"视口外当前时间线被排除"这类懒加载边界——断言选在语义分叉点上,两端一致才有说服力。

9.4 适配成果

当前适配已完成:

  • ProgramGuide 1.6.0 上游几何原件的双 ABI Kotlin/Native 编译(toMinaBoxItem 与 IndexMapper 零改动,git rename 历史保留);
  • vendored minabox 引擎落地(原包名 eu.wewox.minabox,与其已过审仓逐文件一致);
  • 1 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 四 Tab 验收页面(钉行列懒加载、显示开关、对齐动画、字号联动、状态回报);
  • JVM 交叉验证全绿(锁定轴 / 懒加载边界 / 夹紧逐项实测)+ 双 ABI 三件套 so(arm64 ≈3.6 MB / x64 ≈3.3 MB)、签名、设备安装和真机验证;
  • 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

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐