开源鸿蒙平台 CMP 三方库「compose-table」适配全流程
本文记录
compose-table接入 OpenHarmony 的完整过程,覆盖表格引擎源码集盘点、纯 Kotlin 内核抽取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
compose-table是基于 Compose Multiplatform 的数据表格库:DataTable用columns {}/row {}/cell {}三层 DSL 描述表格,SubcomposeLayout先把每个单元格量一遍(列宽取各列内容最大值、行高取各行最大值),再按容器宽度整体等比伸缩(拉伸与收缩同一公式),配合minTableWidth最小表宽与横向滚动;BasicPaginatedDataTable/PaginatedDataTable在其上叠一层 1 起始页码的分页状态机,m2 / m3 双 Material 封装默认页脚是Paginator("$start-$end of $totalCount"+ 首/上/下/末四按钮)。本次适配与 KStore 的"真编译整库"路线有本质区别:这是一个纯 Compose UI 库,每个源文件都 importandroidx.compose.*,社区鸿蒙工具链(HarmonyOS Kotlin 2.2.21-1.0.0)解析不了上游的 Compose 1.9.3 / Kotlin 2.2.20 / AGP 9 组合,SubcomposeLayout编不进 ohos。因此把表格引擎的全部决策数学(列宽伸缩、y 轴放置、宽度决策、分页状态机、对齐、令牌)镜像成纯 Kotlin 内核编成 KLIB,ArkTS Demo 测量文本、上报视口事实并渲染内核响应——qrose / Table-KMP / compose-data-table 一脉的内核抽取路线。
项目地址: AtomGit/oh-tpc/compose-table.git
上游仓库: windedge/compose-table
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
compose-table 为 Compose Multiplatform 应用提供"数据表格"这一件事的完整答案:静态表 DataTable、分页表 PaginatedDataTable(Material 与 Material3 双封装)、页脚翻页条 Paginator,列宽按内容自动测量并按比例缩放(列窄的自动拉伸、列宽的自动收缩,同一公式两个分支),表头背景、行分隔线、footer 全部内建,0.3.0 起支持 minTableWidth 最小表宽与 scrollable 横向滚动。KMP 项目把这些能力放在 commonMain,由 Android、桌面、iOS、WasmJS、JS 共享。OpenHarmony 应用同样需要这套表格能力,但上游构建(Kotlin 2.2.20 / Compose 1.9.3 / AGP 9 / Gradle 9.1.0)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以画出表格,却无法证明表格引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行——列宽怎么求、行高怎么取、页码怎么翻这些决策必须来自同一份代码,否则 ArkTS 页面和上游行为必然漂移。适配需要同时验证布局内核、分页状态机、对齐引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.2.20 / Compose 1.9.3 / AGP 9(com.android.kotlin.multiplatform.library)/ Gradle 9.1.0 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 全库 Compose 化 | common 模块没有一行纯 Kotlin:DataTable 是 SubcomposeLayout,DSL 是 @Composable 类型,PaginationState 包着 mutableStateOf + Saver;连平台 actual(五个平台的滚动条)都是 Compose 的。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 测量在 Compose 里 | 列宽来自 Compose placeable 的测量尺寸,ohos 侧必须有人替它量文本。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:表格引擎决策内核的抽取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。布局与分页决策完全由镜像自上游的内核负责,ArkTS 只负责测量文本、调用 Native、解析 JSON 和渲染结果。
1.2 库提供的能力
Demo 提供一个真实的表格会话,对应桥接的全部入口:
- 静态表(Static 页签):上游 README 的 “Static Data Table”——Name / Age / Email 三列、18 行数据,内核返回拉伸后的列宽与每行 y 坐标,状态行实时显示
列宽 [78, 54, 201] vp · 表宽 333 vp · scale 1.29; - 分页表(Paginated 页签):上游 sample 应用的完整镜像——勾选列 + Name(表头钉 50dp)/ Email / Age / 操作列五列、
minTableWidth = 960dp横向滚动、Add User 追加数据、页脚16-18 of 18式翻页条; - 行选择:点行或勾选框切换选中(内核持选中集),表头勾选框对当前页范围整体全选/全不选;
- 翻页语义:next / previous / goto 全部钳制在
[1, pageCount],翻页清空选择(上游onPageChanged语义); - 版本握手:
op: version返回0.3.0-ohos.1,来自真实 Native 调用而非静态文字。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
columnWidths / columnX / rowHeights / rowY / dividerY | layout 返回的全部几何(缩放列宽、列 x 前缀和、行高、行 y、分隔线 y)。 |
tableWidth / tableHeight / scale | 表格总尺寸与伸缩系数。 |
fromIndex / toIndex / footerStart / footerEnd / canBackwards / canForwards | 分页窗口切片与页脚按钮使能。 |
x / y | align 返回的单元格内容偏移。 |
引擎侧完整能力(两遍测量、表头背景分隔线怪癖、minTableWidth 三分支、1 起始分页、BiasAlignment 九向对齐、dp 令牌)全部在内核里由 29 项 jvmTest 交叉验证用例钉住,证明 ohos .so 里执行的表格逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游全部 Compose 源码 git mv 进 src/composeMain(100% rename 保留历史,不编译、不修改),决策数学 1:1 镜像成 4 个纯 Kotlin 内核文件;不发明上游没有的列宽策略。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,common 出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);m2 / m3 Material 封装与 sample 示例树留盘不进构建。 |
| 桥接稳定 | 1 个 C ABI 入口、扁平 JSON、13 个 op;不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 内核零第三方依赖(纯 Kotlin),版本钉 HarmonyOS Kotlin 2.2.21-1.0.0,无 kotlinx 制品线问题。 |
| UI 完整 | 三页签覆盖全部桥接入口:静态表、分页表全交互、关于页版本握手与会话重置。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 UI 层(
SubcomposeLayout渲染)留在composeMain等未来工具链回接;ArkTS Demo 的角色是"测量 + 渲染",决策全在内核。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与源码集边界、确认工具链线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 降 8.14.1
第 3 阶段:内核抽取与桥协议 ── 4 文件镜像决策数学、13 op JSON 契约、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 文本测量上报、三页签、页脚翻页条和版本握手
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
common/ 上游核心库(源码隔离 + 内核抽取的主战场)
common/src/commonMain/…/core/ 新增:纯 Kotlin 内核(4 文件,唯一编译的库源码)
common/src/composeMain/ 上游 Compose 源码 + composeResources + 五平台 actual(git mv,不编译)
m2/ / m3/ 上游 Material2/3 封装,commonMain 挪入各自 composeMain,留盘不进 settings
sample/ 上游示例工程树(独立 Gradle 构建),留盘不进构建
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :common 与 :example:nativeApp,rootProject.name = "ohos_composetable"。上游根构建用 subprojects 向所有模块注入 ios / js / wasmJs target + AGP 9 + vanniktech 发布链 + buildconfig 插件——任何一处都拉不动鸿蒙工具链,整体替换剥离。本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向本机 JDK 21.0.12) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从上游 9.1.0 降级;2.2.21 线只支持 Gradle 8.14 线) |
| 内核依赖 | 零(纯 Kotlin,无 kotlinx / 无 Compose) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.composetable |
export JAVA_HOME="F:/cmp/.tools/jdk-21.0.12.1+1"
java -version
1.3 源码依赖度盘点
| 模块 | commonMain 文件 | 依赖形态 | 处置 |
|---|---|---|---|
common | 4 + 资源 + 5 平台 actual | androidx.compose.* 全覆盖,唯一 expect 是桌面滚动条 | Compose 文件全部 git mv 进 composeMain;决策数学镜像成 core/ 4 文件 |
m2 | 2 | compose.material + 上游 common | commonMain 挪 composeMain,模块留盘 |
m3 | 2 | compose.material3 + 分页图标资源 | 同上 |
sample | 多模块独立构建 | Compose 应用五平台入口 | 留盘不进构建,Demo 由 harmonyApp 接替 |
expect 声明全库只有 1 处:internal expect fun HorizontalTableScrollbar(...)——五个平台各一个 actual,桌面是真实滚动条、其余四个是空实现。它们随 Compose 文件一起进 composeMain;ohos 侧不存在滚动条 expect,横向滚动由 ArkTS Scroll 承担。
上游 DataTable 的测量数学值得逐行读一遍再动手抽内核:
// 第一遍:列宽/行高取最大值
contentPlaceables.chunked(headers.size).forEachIndexed { rowIndex, row ->
row.mapIndexed { columnIndex, placeable ->
placeable.measure(constraints).also { measured ->
columnWidths[columnIndex] = max(columnWidths[columnIndex], measured.width)
rowHeights[rowIndex] = max(rowHeights[rowIndex], measured.height)
}
}
}
// 伸缩系数:< 与 > 两个分支是同一个公式
val scale = when {
constraints.maxWidth == Constraints.Infinity -> 1f
totalWidth < constraints.maxWidth -> constraints.maxWidth.toFloat() / totalWidth // 拉伸
totalWidth > constraints.maxWidth -> constraints.maxWidth.toFloat() / totalWidth // 收缩
else -> 1f
}
// 第二遍:每个单元格钉死到 (缩放列宽, 行高),再沿 y 轴放置
两个必须原样保留的上游怪癖:其一,有表头背景时表头分隔线不放置、y 轴不前进,但 tableHeight = rowHeights.sum() + dividierHeights.sum() + footerHeight 仍计入它的高度(表尾前多一段"幽灵空隙");其二,PaginationState.changePageSize() 只赋值不重钳页码,越界要到下一次翻页才被 coerceIn 收回。镜像内核把它们钉进测试,而不是"顺手修正"。
1.4 页面边界
标题区 COMPOSE-TABLE · OPENHARMONY / 版本徽章 0.3.0-ohos.1
Tab 切换 Static / Paginated / About
Static 页签 Name/Age/Email 三列静态表(拉伸填满视口)、LightGray 分隔线、状态行
Paginated 页签 Add User 按钮 + 勾选列/Name/Email/Age/操作列五列 + 960dp 横向滚动
+ 页脚翻页条(start-end of total + |< < > >|)
About 页签 版本握手、架构链路、AtomGit 仓库与社区链接、重置会话
页面启动先 op: version 握手、op: tokens 拉真实密度换算的令牌,再 op: dataset 取数据集并初始化分页。
第 2 阶段:目标与依赖打通
2.1 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
与 KStore 不同:compose-table 的内核零第三方依赖,不存在"kotlinx 制品线把 stdlib 元数据顶到 2.4"的约束,社区最早批准的钉法 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / compose-data-table 同款)即可用。代价是这条线只支持 Gradle 8.14 线——上游 wrapper 9.1.0 必须降级到 8.14.1,连同 gradle-wrapper.jar / gradlew 脚本一起替换。上游源码是 Kotlin 2.2.20 编的,2.2.21 与之同代,内核源码级兼容成本为零。
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_composetable"
include(":common")
include(":example:nativeApp")
gradle.properties 钉 JDK 与版本:
org.gradle.java.home=F:/cmp/.tools/jdk-21.0.12.1+1
version=0.3.0-ohos.1
kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true
2.2 编译内核
common/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {} // 零依赖
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
根构建全局禁用 ohos 测试任务(ohos 单测没有 runner,交叉验证统一在 JVM):
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
一个隐蔽的坑:jvm() target 会编译 src/jvmMain,而上游 jvmMain/Scrollbars.jvm.kt import 的是 compose.desktop——不把五个平台 actual 目录一并挪进 composeMain,内核的 JVM 交叉验证第一步就会炸在别人的文件上。
2.3 源码集隔离
上游 Compose 文件全部 git mv(保留 100% rename 历史):
common/src/commonMain/kotlin/.../DataTable.kt → common/src/composeMain/...
common/src/commonMain/kotlin/.../BasicPaginatedDataTable.kt → 同上
common/src/commonMain/kotlin/.../TableDslBuilder.kt → 同上
common/src/commonMain/kotlin/.../components/Divider.kt → 同上
common/src/commonMain/composeResources/ → common/src/composeMain/composeResources/
common/src/{androidMain,iosMain,jsMain,jvmMain,wasmJsMain}/ → common/src/composeMain/ 各同名目录
m2/src/commonMain/...、m3/src/commonMain/... → 各自 src/composeMain/...
composeMain 不是 source set、不进构建,是未来 Compose-ohos 工具链对齐后的回接基线——永远不删不改。
第 3 阶段:内核抽取与桥协议建模
3.1 内核:4 个文件镜像全部决策数学
io.github.windedge.table/**(上游 Compose 源码,隔离在 composeMain)
↓ 逐行镜像
io.github.windedge.table.core/**(纯 Kotlin 内核,jvm + 双 ohos 编译)
↓ KLIB → libohostable.so
Bridge.kt(@CName,13 op 扁平 JSON 进出)
↓ UTF-8 JSON
N-API tableBridge()
↓
ArkUI Index.ets(三页签)
| 内核文件 | 镜象的上游 | 钉住的关键公式 |
|---|---|---|
TableLayoutEngine.kt | DataTable 的 SubcomposeLayout | columnWidths[c]=max、rowHeights[r]=max;scale:</> 同为 maxWidth/totalWidth,Infinity/相等 → 1f;columnX 前缀和;y 轴:表头 →(无表头背景才放 divider[0])→ 行+分隔线交替 → footer;tableHeight 恒计全部分隔线 |
TableLayoutEngine.effectiveConstraintWidth | BoxWithConstraints 宽度包装 | 有限宽度 max(w, minTableWidth);无限 + min>0 → min;否则 UNBOUNDED |
PaginationEngine.kt | PaginationState + m2/m3 Paginator | pageCount=ceil(n/size);next/prev/goto 全 coerceIn(1, ensurePageCountPositive);窗口 (idx-1)*size until min(idx*size, n);页脚 start=min((idx-1)*size+1, n)、end=min(start+size-1, n);changePageSize 不重钳 |
TableAlignment.kt | Compose BiasAlignment.align | centered=(space-size)/2 整除;偏置分支 (bias*(space-size)/2+centered).roundToInt()(tie 向 +∞),bias=0 走整除;RTL 翻转水平偏置 |
TableTokens.kt + TableDensity | 各默认参数 | roundToPx=(dp*density).roundToInt();8/5dp 单元格内边距、1dp LightGray 分隔线、默认每页 10 行、sample 的 960dp / 5 行 / 18 用户 / 50dp Name 列高 |
3.2 JSON 契约(13 个 op)
请求 : {"op":"layout","viewportWidth":1080,"minTableWidth":2880,"dividerHeight":3,
"footerHeight":144,"hasHeaderBackground":true,
"widths":"48|48|…;168|96|…;…","heights":"84|60|…;150|60|…;…"}
{"op":"pagingInit","count":18,"pageIndex":1,"pageSize":5}
{"op":"pagingNext"} / {"op":"pagingPrev"} / {"op":"pagingGoto","pageIndex":4}
{"op":"dataset"} / {"op":"addUser"} / {"op":"toggleRow","id":3}
{"op":"togglePage","check":true} / {"op":"align",…} / {"op":"tokens","density":3.0}
{"op":"version"} / {"op":"reset"}
响应 : {"ok":true,…} / {"ok":false,"error":"…"}
两个关键设计:其一,widths / heights 按列间 ;、行间 | 拼接(行 0 = 表头),值是 ArkTS measureText 实测文本宽高加上内边距——正好对应上游第一遍测量得到的 placeable 尺寸,谁测量、谁求解、谁渲染三段责任清晰;其二,会话状态(18 行数据集、选中集、分页引擎)全部住 Kotlin 侧 TableSession,ArkTS 不自持状态,翻页/选择/加人之后回推整窗响应。
数据集镜像上游 sample 的 randomUser():姓名/邮箱/年龄三个数组随机组合;差异只有一处——初始 18 行用固定种子 Random(0x5EED5) 生成(Add User 仍真随机),让真机验收数字可复现。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("TableBridge")
fun tableBridge(request: String): String = try {
val json = MiniJson.parse(request)
when (json.str("op")) { /* 13 op 分发 */ }
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 JSON、未知 op、分页未初始化、测量串行列数不齐、pageSize 非法,全部走同一个 ok:false 通道,ArkTS 侧状态行统一渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libtable_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohostable.so)
│ TableBridge
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制布局数学,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("TableBridge")
fun tableBridge(request: String): String // 13 op 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
TableBridge | tableBridge(req) | layout / align / tokens / paging* / dataset / addUser / toggle* / version / reset 全部分发 |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"tableBridge", nullptr, NapiTableBridge, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohostable_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。
4.4 CMake 和类型声明
add_library(ohostable SHARED IMPORTED)
set_target_properties(ohostable PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(table_napi SHARED napi_init.cpp)
target_link_libraries(table_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohostable)
OHOS_ARCH 为空或 libohostable.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。libohostable_api.h 直接采用 KN 生成的头(linkDebugSharedOhos* 产物),不手写。
export const tableBridge: (request: string) => string;
example/nativeApp/build.gradle.kts 的 linkDebugSharedOhosArm64/X64 分别 finalizedBy 一个 Copy 任务,把 libohostable.so 连同 Konan 依赖里对应 triple(aarch64-linux-ohos / x86_64-linux-ohos)的 libc++_shared.so 拷进 harmonyApp/entry/libs/<abi>/。
第 5 阶段:ArkUI 宿主
5.1 测量 → 求解 → 渲染
aboutToAppear(): void {
this.loadDensity(); // display.getDefaultDisplaySync().densityPixels
this.loadVersion(); // op: version 握手
this.loadTokens(); // op: tokens 拉真实密度换算的 8/5/1dp 令牌
this.loadDataset(); // op: dataset + pagingInit + 两个 layout
}
private doPagedLayout(): void {
const widths = [/* 五列:勾选框 20vp、Name/Email/Age 文本、Delete 按钮 */]
.map(col => col.join('|')).join(';'); // 列;行 px 实测
const req: LayoutReq = { op: 'layout',
viewportWidth: Math.round(this.viewportWidthPx),
minTableWidth: this.minTableWidthPx, // sample: 960dp × density
dividerHeight: this.dividerPx, footerHeight: Math.round(this.pagerBarHeightPx),
hasHeaderBackground: true, widths, heights };
const res = JSON.parse(this.bridge(JSON.stringify(req))) as LayoutResp;
this.pWidths = res.columnWidths ?? []; // 内核几何直接驱动渲染
}
文本测量走 getUIContext().getMeasureUtils().measureText()(失败回落字宽估算并打日志);单元格内容偏移再经 op: align 由内核按列对齐方式计算——连"文本在格子里怎么摆"都是上游 BiasAlignment 公式的输出。
5.2 页面交互
Static 页签:三列静态表纵向滚动,列宽拉伸填满视口,状态行实时回显内核几何。Paginated 页签:Add User 追加数据并按上游语义清空选择;点行 / 勾选框切换选中(toggleRow);表头勾选框整页全选(togglePage,内核按 (pageIndex-1)*pageSize until pageIndex*pageSize 计算);页脚四按钮对应 pagingGoto(1) / pagingPrev / pagingNext / pagingGoto(pageCount),使能态由响应的 canBackwards / canForwards 驱动。About 页签:版本握手、ArkTS → NAPI → C ABI → Kotlin core 架构链路、AtomGit 仓库与社区链接、op: reset 会话重置。
5.3 对比度和错误边界
页脚四按钮可用态主色实底白字、禁用态浅灰底深灰字;ok:false 时错误原文进状态行;Native 调用本身抛错同样被 try/catch 收进状态行。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohostable)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName + TableSession + MiniJson(扁平 JSON,cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.composetable、versionName 0.3.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/libohostable_api.h
├── src/main/cpp/types/libtable_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 三页签验收页面
6.2 构建、签名和安装
./gradlew :common:jvmTest # 29 项交叉验证
./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 保持空数组)。
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.composetable -m entry
四、完整代码对照
4.1 整体架构
io.github.windedge.table.core/**(纯 Kotlin 内核)
│ TableLayoutEngine(两遍测量 + 伸缩 + 放置)
│ PaginationEngine(1 起始分页 + 页脚区间)
│ TableAlignment(BiasAlignment 九向)
│ TableTokens / TableDensity(dp 令牌)
Bridge.kt + TableSession + MiniJson
│ TableBridge(13 op)
libtable_napi.so
│ tableBridge
Index.ets
│ measureText + JSON parse + @State 更新
▼
ArkUI 真机页面(Static / Paginated / About 三页签)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_composetable、m2/m3/sample 移出 |
gradle/libs.versions.toml | 只留 Kotlin 2.2.21-1.0.0 |
build.gradle.kts(根) | 版本 0.3.0-ohos.1、全局禁用 Ohos*Test* 任务 |
common/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,零依赖 |
common/src/commonMain/…/core/(4 文件) | 内核:全部决策数学的 1:1 镜像 |
common/src/jvmTest/…/core/(4 文件) | 29 项交叉验证 |
common/src/composeMain/ | 上游 Compose 源码隔离区(回接基线) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、13 op、TableSession、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 三页签验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(23 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 内核 | TableLayoutEngine.solve(...) | 两遍测量:列宽/行高最大值 → 伸缩 → 列 x / 行 y / 分隔线 y / 表宽高 |
| 内核 | TableLayoutEngine.effectiveConstraintWidth(...) | minTableWidth 三分支(有限取 max / 无限取 min / 否则不约束) |
| 内核 | PaginationEngine.next/previous/goto | 1 起始页码钳制 [1, ensurePageCountPositive()] |
| 内核 | PaginationEngine.contentWindow / footerRange | 页窗口切片 + "$start-$end of $total" 页脚区间 |
| 内核 | TableAlignment.align(...) | 九向对齐偏移(tie 向 +∞ 取整、RTL 翻转) |
| 内核 | TableDensity.roundToPx(dp) | (dp × density).roundToInt() |
| Native | TableBridge | C ABI JSON 导出(13 op) |
| N-API | tableBridge(req) | 向 ArkTS 暴露能力 |
| ArkTS | doStaticLayout() / doPagedLayout() | 测量拼串 → layout op → 渲染内核几何 |
4.4 ArkTS 与 Kotlin 的边界
const layout = JSON.parse(tableBridge(JSON.stringify({
op: 'layout',
viewportWidth: 1080, minTableWidth: 2880, // sample: 960dp × 3
dividerHeight: 3, footerHeight: 144, hasHeaderBackground: true,
widths: '60|60|…;204|96|…;…', // 列;行 px 实测
heights: '84|60|…;150|60|…;…'
}))) as LayoutResp;
// layout.columnWidths[1] == Name 列宽、layout.rowY[slot] == 行 y、layout.scale == 伸缩系数
val constraint = TableLayoutEngine.effectiveConstraintWidth(viewportWidth, minTableWidth)
val result = TableLayoutEngine.solve(cellWidths, cellHeights, constraint,
dividerHeights, footerHeight, hasHeaderBackground)
// ArkTS 拿到的每一个坐标都来自这段镜像代码,页面上没有一行布局数学
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:内核抽取而不是真编译整库
KStore 能真编译整库,前提是它的引擎是纯 Kotlin 且 kotlinx ohos 制品线齐了。compose-table 正相反:全库 androidx.compose.*,"引擎"与"渲染"长在同一个 SubcomposeLayout 里。硬接 Compose 1.9.3 到鸿蒙工具链不可行;ArkTS 全新实现会让决策逻辑与上游漂移。所以把决策数学(列宽/行高/伸缩/放置/分页/对齐)镜像成纯 Kotlin 内核,渲染交给 ArkTS——上游每个行为变化(如 0.3.0 的横向滚动特性)只需改内核一处加一条测试。
决策 2:工具链钉 2.2.21-1.0.0,Gradle 降 8.14.1
内核零依赖,没有制品线反推版本的问题,社区最早批准的钉法直接可用。这条线的代价是 Gradle 只支持 8.14 线,上游 wrapper 9.1.0 整体替换(含 wrapper jar 与脚本)。与 KStore 的 2.4.20 线(Gradle 9.x)是两条独立经验,不能互相套用。
决策 3:composeMain 隔离,m2 / m3 / sample 留盘
上游源码的价值在未来 Compose-ohos 工具链对齐后整体回接,因此 git mv(100% rename)而不是删除或重写;m2 / m3 是纯 Material 封装(Paginator 的行为已由 PaginationEngine.footerRange 镜像),sample 是独立 Gradle 构建的示例树,一并留盘不进 settings.gradle.kts——它们的存在方式只有这一种。
决策 4:13 op 单入口 + 会话全在 Kotlin 侧
数据集、选中集、分页引擎住 TableSession 单例,ArkTS 是纯渲染器:翻页/加人/选择的每一步都回推完整窗口响应,两侧永远不会出现双份状态机。表头勾选框的"整页全选"直接在内核里按上游公式 (pageIndex-1)*pageSize until pageIndex*pageSize 计算,ArkTS 不复刻任何分页数学。
决策 5:jvmTest 交叉验证 + 固定种子数据集
内核逻辑在 commonMain,JVM 与 ohos 跑同一份代码;29 项测试把每个公式(含两个上游怪癖)钉死,改内核必须先改测试。Demo 数据集初始 18 行用固定种子生成——真机上状态行的 列宽 [78, 54, 201] vp · 表宽 333 vp · scale 1.29 这类验收数字逐台设备可复现。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21(21.0.12)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与 HarmonyOS 真机(arm64)。
6.2 JVM 交叉验证
./gradlew :common:jvmTest
29 项用例全部通过(全部为本 fork 新增的公式钉子):
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
TableLayoutEngineTest | 10 | 拉伸(300→600 scale 2f)/ 收缩(300→150 scale 0.5f)/ Infinity / 精确相等;列 x 前缀和;有/无表头背景的 y 轴推进;表高恒计被吞分隔线;空表零尺寸;非法输入拒绝;effectiveConstraintWidth 三分支;minTableWidth 驱动横向滚动 |
PaginationEngineTest | 9 | ceil 页数、空表 1 页、两端钳制、goto 越界钳制、窗口切片(含部分末页)、页脚区间、改 totalCount/pageSize(含不重钳怪癖)、pageSize 非法拒绝 |
TableAlignmentTest | 6 | 偶/奇空间九向、CenterStart 默认、tie 向 +∞(-0.5f→0、4.5f→5)、RTL 翻转、溢出空间、fromName 回落 |
TableTokensTest | 3 | 上游默认值、sample 令牌、密度换算(50dp × 2.75 = 137.5 → 138) |
6.3 HAP 验证
:common:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: TableBridge / libohostable_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libtable_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 6.7 MB(7,020,540 字节),内含双 ABI 各三件套(libtable_napi.so / libohostable.so / libc++_shared.so);KN so debugShared 未 strip 时 arm64 约 3.7 MB(3,839,184 字节)、x86_64 约 3.4 MB(3,536,160 字节)。
6.4 功能用例
- 启动 About 页签版本握手显示
0.3.0-ohos.1/ 上游0.3.0-SNAPSHOT/ Kotlin2.2.21-1.0.0(来自真实 Native 调用); - Static 页签 18 行静态表渲染,列宽随内容(Name 中、Age 最窄、Email 最宽),表宽拉伸填满视口;
- 真机状态行实测
Static · 列宽 [78, 54, 201] vp · 表宽 333 vp · scale 1.29——拉伸系数 > 1,"列窄自动拉伸"语义在设备上成立; - Paginated 页签 18 行分 4 页(每页 5),页脚
1-5 of 18,|<<禁用、>>|可用; - 点行 / 勾选框切换选中,表头勾选框整页全选/清空;
- 翻到末页页脚显示
16-18 of 18(部分页),>>|禁用; - 翻页后选择清空(上游
onPageChanged语义); - Add User 后共 19 行、页数变 4(ceil(19/5)),选择清空;
- 表宽按
minTableWidth = 960dp决策,超出视口横向可滚动; - About 页签重置会话恢复初始 18 行(固定种子,逐台设备可复现)。
6.5 验证结论
布局内核、分页状态机、对齐引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,Static 页签展示了真实的表格会话:版本握手、内容驱动的列宽、拉伸填满的表宽与逐行分隔线(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/compose-table.git
cd compose-table
export JAVA_HOME="/path/to/jdk-21"
./gradlew :common:jvmTest # 29 项交叉验证
./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.composetable -m entry
hdc shell hilog | grep TableNapi
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:common真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- wrapper 启动器先于
org.gradle.java.home生效——不先export JAVA_HOME会报JAVA_HOME is not set,仓库内的 JDK 路径只管守护进程; jvm()交叉验证会编译src/jvmMain,上游平台 actual 里的compose.desktopimport 会炸掉内核 JVM 编译——平台 actual 必须随 Compose 源码一起进composeMain;kotlin.math.max不 import 时会命中集合层废弃的max()重载并给出误导性报错,内核统一用maxOf一劳永逸;- 鸿蒙 KN CAdapter 不把
@CName业务符号导出给 ArkTS,import 'libohostable.so'不可行,必须走 CMake NAPI 包装层; - CMake IMPORTED 库不带
IMPORTED_NO_SONAME TRUE会把 Windows 绝对路径写进 RUNPATH,设备上 dlopen 失败——配合BUILD_RPATH/INSTALL_RPATH "$ORIGIN"; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); napi_get_value_string_utf8先传nullptr量长、再std::vector<char>取内容,一步到位会在新 SDK 头文件上翻车;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; build-profile.json5的signingConfigs仓库内留空数组,勾选 DevEco 自动签名后由 IDE 回填完整配置,证书绝不进入仓库;- 根
.gitignore的build/盖不住 DevEco 生成的example/harmonyApp/entry/build等嵌套产物,需单独追加example/harmonyApp/*系列条目与*.so/*.hap。
8.2 已知问题
- 单元格内容测量是 ArkTS
measureText的单行近似;上游 Compose 第一遍测量允许文本换行抬高行高,桥协议目前每格一个 w×h; - m2 / m3 的 Material 样式(主题色、
Paginator分页图标资源)不复刻,Demo 用近似配色 + 文本按钮代替四枚图标; - 上游
Divider(color=)可定制,内核令牌只钉默认 1dp LightGray; PaginationState.Saver(进程恢复)无桥对应物——会话状态在 so 内,进程死亡即丢;BasicPaginatedDataTable的onPageChanged是 suspend 拉数据;桥是同步窗口计算,数据集预载在 Kotlin 侧;- 上游
headerBackground/ 行modifier可放任意 Composable 背景;Demo 只渲染纯色背景; - ohos 单测任务无 runner 全部禁用,内核正确性由 JVM 交叉验证保证;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.composetable匹配的签名。
8.3 未来优化方向
- 桥协议升级为每格多段测量(支持文本换行抬行高),对齐上游第一遍测量的完整语义;
- 补充 Paginated 页签真机截图与自动化截图脚本(uitest + capture),纳入 CI;
- 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 关注社区 Compose-ohos 工具链,对齐后把
composeMain挂回 source set、删除桥接层。
九、总结
9.1 核心难点回顾
上游 common / m2 / m3(全库 Compose 化)
→ 判定路线:UI 库无法真编译 → 内核抽取(qrose / compose-data-table 一脉)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 降 8.14.1,零依赖内核无需制品线)
→ 根构建替换(cmp_qrose 模式)+ 源码 git mv 进 composeMain
→ 4 文件镜像全部决策数学(含两个上游怪癖)
→ 29 项 jvmTest 钉公式
→ ohosArm64 / ohosX64 KLIB + 双 ABI 动态库
→ C ABI(@CName TableBridge,13 op JSON)+ TableSession
→ C++ N-API(DisposeString 纪律)
→ ArkTS 测量 + 渲染
→ ArkUI 真机三页签验收(Static / Paginated / About)
9.2 封装层次
- 内核层:
TableLayoutEngine(两遍测量 + 伸缩 + 放置 + 宽度决策)、PaginationEngine、TableAlignment、TableTokens; - Native 层:双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:文本测量、视口上报、页面状态、JSON 解析与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 适配路线由库的形态决定,不由历史结论决定:KStore 是纯 Kotlin 引擎 + 制品线齐备,答案是"真编译整库";compose-table 是纯 Compose UI 库,答案是"决策数学抽内核 + ArkTS 渲染"。同一个社区、两条路线、各自成立——先盘点源码集的依赖形态,再定路线;
- 镜像上游时要连怪癖一起镜像:表头背景吞分隔线的 y 前进但表高照计、
changePageSize不重钳页码——这些"看起来像 bug"的行为是上游语义的一部分,"顺手修正"就是制造漂移,测试钉住它们才叫 1:1 镜像; - 让真机数字可复现再谈验收:Demo 数据集用固定种子,真机状态行的列宽/表宽/scale 每台设备一致,效果图里的数字才经得起对照——随机数据集的截图什么也证明不了。
9.4 适配成果
当前适配已完成:
- compose-table
0.3.0-SNAPSHOT表格引擎决策内核的双 ABI Kotlin/Native 编译(4 个纯 Kotlin 内核文件,零第三方依赖); - 上游全部 Compose 源码
git mv隔离进composeMain(100% rename,回接基线完整保留); - 1 个 C ABI 符号、13 op JSON 桥和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 三页签验收页面(静态表、分页表全交互、关于页版本握手与会话重置);
- 29 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 6.7 MB)、签名、设备安装和真机验证(实测
列宽 [78, 54, 201] vp · 表宽 333 vp · scale 1.29); - 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)