开源鸿蒙平台 KMP 三方库「ProgramGuide」适配全流程
本文记录
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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin jdk-21.0.12.101-hotspot,钉在 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(上游 8.11.1 → 跟随社区模板升级,wrapper 只改 distributionUrl) |
| kotlinx 制品 | 零——上游 commonMain 只依赖 compose 与 minabox,前者隔离、后者 vendored |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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.kt | 38 | 零 import 纯 Kotlin | 原样编译 |
ProgramGuideItem.kt | 118 | 仅依赖 minabox 的 MinaBoxItem | 原样编译(vendored 后可解析) |
ProgramGuideDimensions.kt | 50 | Dp / Density | git mv → composeMain |
ProgramGuide.kt | 92 | @Composable / MinaBox | git mv → composeMain |
ProgramGuideScope.kt | 309 | @Composable lambda / MinaBoxScope | git mv → composeMain |
ProgramGuideState.kt | 208 | rememberSaveable / MinaBoxState | git mv → composeMain |
ProgramGuidePositionProvider.kt | 113 | Alignment / Offset / minabox | git 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 方法 | 覆盖内核 |
|---|---|---|
ProgramGuideLayout | programGuideLayout(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/*.kt | vendored 引擎三文件(原包名保留) |
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.kt | 1 个 C ABI、EPG 语义协议、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 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) | 五类条目 → 平面坐标(上游原件) |
| 引擎 | ProgramGuideIndexMapper | programs/current/channels/timelines 局部索引 → 全局索引(上游原件) |
| 引擎 | MinaBoxSolver.visibleItems | 视口矩形重叠判定 + 锁定项单轴判定(vendored) |
| 引擎 | MinaBoxSolver.itemOffset | 9 种对齐偏移,锁定轴返回当前平移(vendored) |
| 门面 | ProgramGuideSolver.layout | 平面尺寸 / 边界 / 夹紧 / 可视 cells(带 kind 标注) |
| 门面 | ProgramGuideSolver.targetOffset | animateToProgram/Channel/Timeline/CurrentTime 的目标平移 |
| Native | ProgramGuideLayout | C ABI JSON 导出 |
| N-API | programGuideLayout(req) | 向 ArkTS 暴露能力 |
| ArkTS | EpgEngine.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 / height | 1926.0 / 288.0 | 平面 = 各条目右缘 / 下缘最大值;MatchParent 当前时间线不撑高平面(上游 quirk) |
maxX / maxY | 846.0 / 0.0 | 边界 = (平面 − 视口) 下限 0 |
cells | PROGRAM(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 | 不在 cells | x=1920 超出 1080 视口,懒加载正确排除 |
program0 目标 | ox=36.0, oy=-936.0 | 居中对齐(未夹紧原值,ArkTS 每帧夹紧——与上游 Animatable 边界行为一致) |
channel0 目标 | ox=0.0, oy=-936.0 | ox = 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 功能用例
- 启动 Simple Tab:频道列钉左、时间轴行钉顶,节目区双向拖动流畅,状态行"可视 N 项"随滚动在 40 上下波动——视口外条目不渲染;
- 拖到平面边界被夹稳,惯性在边界停摆;
- Config Tab 依次关闭四开关:关频道列后节目左移贴边(cw=0)、关时间轴后顶行消失(th=0)、当前时间线与角格增删即所见;
- State Tab 启动即横跨 12:30 当前时间线;
- 点击节目格:该节目 300ms 居中(双轴);点击频道格:该行垂直居中且横向不动;点击时间轴格:该小时水平居中且纵向不动——三种目标的锁定轴行为与上游一致;
- 平移贴左 / 右边出现 ‹ › 浮动按钮,点击后节目单重新生成,格子与坐标全量刷新;
- Size Tab 拖字号 1x→3x:行高 / 列宽 / 时间轴高与文字同步放大,平面尺寸按比例增长;
- 全程无崩溃;
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 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:programguide真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 上游
api(minabox)在 Maven Central 无 ohos 变体——vendored 三文件是唯一自包含解法,minabox 适配文档第 10 节已预留此路线; ProgramGuidePxDimensions原定义在随文件进 composeMain 的ProgramGuideDimensions.kt里,必须在 commonMain 重新声明(同包同名,composeMain 不编译故无冲突)——这类"类型卡在要隔离的文件里"的问题要在拆源集时逐个排查;- section 的 kind/index 标注是按位置映射的(与上游
IndexMapper一致):验证程序最初把条目排成 program/channel/timeline/current/corner,标注立刻错位——条目必须按注册顺序传入,桥内固定规范顺序,求解器 KDoc 已写明该约束; gradlew.bat启动阶段读不到gradle.properties的org.gradle.java.home,必须先设 Windows 风格JAVA_HOME环境变量;- KMP 的 jvm 目标默认无
jar任务,交叉验证直接用build/classes/kotlin/jvm/main目录作 classpath;Java 调 Kotlin 默认参数需显式传全; - 鸿蒙侧无 Compose:
rememberSaveable状态恢复、Animatable惯性都不存在,惯性(EMA 速度估计 + 0.95/帧衰减)与 300ms 对齐动画是 ArkTS 侧逐帧近似,边界停摆靠"请求平移 vs 夹紧回传偏差"检测; - ArkUI 的 ForEach 键值不变时不重建组件:重新生成节目单后格子文字不刷新,键值必须带代号
${gen}-${id}强制重建;悬浮按钮定位不能用.align()(那是内容对齐),要按视口尺寸算.position(); - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;ArkTS 只 import NAPI so; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 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.uiohos 制品与 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 三条经验
- 依赖是 CMP 库时,先看它的鸿蒙适配仓:minabox 已过审的 ohos 适配把引擎做成了纯 Kotlin 三文件,programguide 的依赖问题由此变成"整文件 vendor + 保持包名"——比起抽内核或重写,这是唯一既有对照性又自包含的解法;依赖可达性决定适配路线,但"可达"包括姊妹仓的已审成果;
- 桥的抽象层级要贴合库的价值层:minabox 桥收发坐标,本桥收发小时与频道数——坐标换算必须留在 Kotlin 侧,否则适配的就不再是 ProgramGuide 而是一块普通画布;协议设计先问"这个库区别于底层的部分是什么";
- 交叉验证的断言要钉到锁定语义:平面尺寸谁都算得对,真正区分实现的是
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。
参考文档
更多推荐

所有评论(0)