本文记录
tanstack-table-kmp接入 OpenHarmony 的完整过程,覆盖表格库盘点、鸿蒙工具链钉版、真实引擎编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
tanstack-table-kmp是 TanStack Table v8 的 Kotlin Multiplatform 移植:无头表格引擎:table-core(排序、筛选、分组、分页、选择、固定、可见性、列序、列宽的完整状态机)加 Compose 适配层:table-compose(rememberTable/TableGrid/flexRender)。本次适配与社区两条既有路线都不同:qrose / haze 走"零依赖内核提取",KStore 走"kotlinx 制品线真编译"------而table-core的 44 个 commonMain 文件本身就是零依赖纯 Kotlin (逐文件核对 import,全库只出现kotlin.math与kotlin.time),既不需要提取内核也不需要验证制品线,真实引擎(Table、ColumnDef、九大特性、行模型管线)被原样编译成 OpenHarmony 动态库 ,一个完整的 TanStack v8 状态机运行在真机上,ArkUI 六 Tab 页面作为验收宿主。真正的难点不在编译,在表格语义(交互状态机)怎么跨语言边界------答案是无状态 JSON 桥 + 状态往返 + 动作走引擎自身 accessor。
项目地址: AtomGit/oh-tpc/tanstack-table-kmp.git
上游仓库: praveenshharma/tanstack-table-kmp
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
tanstack-table-kmp 为 Kotlin Multiplatform 应用提供"数据 ↔ 表格"的完整答案:TableOptions(data, columns) 一行建表,getCoreRowModel / getSortedRowModel / getFilteredRowModel / getGroupedRowModel / getExpandedRowModel / getPaginationRowModel 六级行模型管线按需接线,column.toggleSorting() / row.toggleSelected() / table.nextPage() 等语义化 accessor 驱动内部状态机,onStateChange 把每次状态迁移回调给宿主。上游把它拆成两层::table-core 是 100% common 的无头引擎,:table-compose 把引擎桥进 Compose 重组。KMP 项目由 Android、iOS 共享这套逻辑。OpenHarmony 应用同样需要这套表格能力,但上游构建(Kotlin 2.1.0 / Gradle 8.10 / AGP 8.7.3 / CMP 1.7.3)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示表格,却无法证明公共表格引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证引擎状态机、行模型管线、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 产物。 |
| 工具链不一致 | 上游 Kotlin 2.1.0 / Gradle 8.10 / AGP 8.7.3 与鸿蒙工具链无法共存于一个 Gradle 构建;HarmonyOS Kotlin 2.2.21-1.0.0 旧线必须配 Gradle 8.14.1。 |
| Compose 适配层编不过 | :table-compose 全部站在 androidx.compose.* 上;社区鸿蒙 CMP 是 1.9.2,与上游 CMP 1.7.3 / Kotlin 2.1 的编译器插件与依赖图对不齐。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 表格语义跨不了桥 | 排序三态循环、子行级联选择、分组展开是引擎状态机语义,C ABI 只能同步一来一回------不能在 ArkTS 里重写一份状态机,否则必然漂移。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:无头引擎的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。表格语义完全由上游原版实现负责,ArkTS 只负责发起动作、解析 JSON 和渲染视图模型。
1.2 库提供的能力
Demo 用六个 Tab 覆盖引擎六条特性管线,对应桥接的全部入口:
- 基础(basic) :
getCoreRowModel()直出 10 行 6 列,无任何特性接线; - 排序(sorting) :8 列,点表头循环"无 → 升 → 降 → 无";
status列走自定义排序函数(按 single → complicated → relationship 业务优先级而非字典序);lastName/visits列每 10 行制造 null 值钉住sortUndefined = "last";rank列invertSorting = true(小者优先); - 筛选(filters) :
getFilteredRowModel()+ 全局筛选(输入框实时过滤)+ firstName 列筛选 +status面值统计 chip(getFacetedUniqueValues()带计数,点击即过滤); - 分页(pagination) :
getPaginationRowModel(),首页/上一页/下一页/末页 + 8/10/20 页大小切换,页数与总数来自引擎; - 分组(grouping) :
getGroupedRowModel()+getExpandedRowModel(),三级嵌套表头(Name / Info / More Info),默认按status分组,表头 ▢/▣ 按钮换分组列,组行展开收起,聚合格橙色------age = median、visits = sum、progress = mean; - 选择(selection) :
enableRowSelection = true,行首勾选、全选本页、已选计数;rowSelection按 id 存活,翻页后选择保持。
响应包含:
| 字段 | 作用 |
|---|---|
ok |
操作是否成功,失败时 error 带原文。 |
headerGroups |
多级表头(分组 Tab 三级),每个 header 带 label / colSpan / canSort / sortDir / canGroup / isGrouped。 |
rows |
最终行模型的每一行:id / depth / isGrouped / subRowCount / canExpand / isSelected 与逐格 cells(kind = grouped / aggregated / placeholder / plain)。 |
pagination / selectedCount / faceted |
分页视图、选择计数、面值表。 |
state / status |
完整引擎状态回显(下一轮请求原样回传)与引擎侧状态栏摘要。 |
引擎侧完整能力(列可见性、列顺序、列/行固定、列宽、footer)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 29 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的表格逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | table-core 的全部 44 个 commonMain 文件原样编译,不提取、不重写、零新增 Kotlin 源文件(引擎侧一行不改)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块纯 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);table-compose / sample / iosApp(纯 CMP/AGP)留盘不进构建。 |
| 桥接稳定 | 单个 C ABI 入口 TableRender + UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 语义不漂移 | 桥无状态,动作经引擎自身 accessor 执行;排序循环、级联选择、分组展开的语义只有上游一份实现。 |
| UI 完整 | 单页六 Tab 覆盖全部桥接入口:表头点击排序、筛选输入、面值 chip、翻页、分组展开、行选择。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。表格是数据引擎而非图像:ArkTS 拿到的不是 qrose 那样的 PNG 位图,而是引擎算好的结构化 行/列模型,用原生 ArkUI 组件渲染并继续可交互------点击表头直接回到引擎的
toggleSorting。
二、实现路线图
text
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、确认引擎纯度、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 降 8.14.1
第 3 阶段:桥协议建模 ── 无状态 JSON 契约、状态往返、动作表、MiniJson
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 六 Tab、表头/行渲染、控件区、状态栏
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
text
table-core/ 上游无头引擎(进 ohos 构建,jvm + ohosArm64 + ohosX64,零源码改动)
table-compose/ 上游 Compose 适配层(2 文件,纯 androidx.compose,留盘不进 settings)
sample/ iosApp/ 上游 Android/iOS 演示(纯 CMP/AGP/Xcode,留盘不进构建)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
docs/ 上游文档与本篇文章 + img/ 效果图
构建拓扑走 cmp_qrose 模式 (根构建即 ohos 构建):settings.gradle.kts 只 include :table-core 与 :example:nativeApp,rootProject.name = "ohos_tanstack_table"。上游的 dokka / vanniktech 发布插件、AGP / Compose 插件链、foojay resolver、iOS framework 构建段全部随根构建替换剥离------本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Temurin jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 8.10 升级;2.2.21 旧线必须配 8.14.1,qrose 先例) |
| 引擎依赖 | 零(见 1.3 源码依赖度盘点------这是本批适配库里唯一的零依赖引擎) |
| DevEco product | 5.1.0(18) 兼容(hvigor modelVersion 5.1.0) |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.table |
powershell
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version # gradlew 启动器自身也需要 JAVA_HOME,org.gradle.java.home 只管 daemon
1.3 源码依赖度盘点
| 模块 | 文件数 | 依赖形态 | 处置 |
|---|---|---|---|
table-core commonMain |
44 | 全库 import 只有 kotlin.math.* / kotlin.time.*,零第三方、零 UI、零 expect/actual、零协程 |
整体编译(jvm + 双 ohos,零源码改动) |
table-core commonTest |
8 | kotlin("test") |
保留,JVM 跑交叉验证 |
table-compose |
2 | androidx.compose.runtime/foundation/ui |
留盘不进构建 |
sample / iosApp |
--- | AGP / CMP / material3 / XcodeGen | 留盘不进构建 |
table-core 的纯度值得单独说明:对 44 个文件全量 grep ^import,除自身包外只出现 kotlin.math.floor/max/min/round 与 kotlin.time.TimeMark/TimeSource 六个符号------没有协程、没有序列化、没有 expect/actual。这意味着 KStore 适配里的"制品线验证"步骤在本库整体不存在 :不存在"某个 kotlinx 依赖没有 ohos 变体"的可能,引擎直接编。全库唯一的接缝在 :table-compose(rememberTable 把 Table 桥进 Compose 重组),而它恰好是 Compose 依赖最重、当前最编不过 ohos 的部分------上游的引擎/渲染分层设计让鸿蒙适配面天然最小。
1.4 页面边界
text
标题区 TANSTACK-TABLE-KMP · OPENHARMONY / 副标题(table-core 无头引擎 · Kotlin/Native ohosArm64/ohosX64)
状态行 引擎侧 status 摘要(如 "grouping: status · 3 groups on page")
Tab 切换 基础 / 排序 / 筛选 / 分页 / 分组 / 选择
控件区 随 Tab 变化:重置排序、筛选输入框、面值 chip、翻页钮、分组钮、选择钮、刷新数据
表头区 多级表头,列头可点击排序(▲/▼ 指示),分组 Tab 带 ▢/▣ 分组切换徽标
行区 展开箭头 / 勾选框前导列 + 逐格渲染(绿=分组键、橙=聚合、灰=占位)
页脚 仓库与许可证行
页面启动时 aboutToAppear 即发起首次 tableRender 调用,基础 Tab 数据直达。
第 2 阶段:目标与依赖打通
2.1 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
本库引擎零依赖,没有 KStore 那种"coroutines 制品线反推 stdlib 版本"的约束,直接沿用社区早期批准的钉法:HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 同款,唯一带 ohosArm64() / ohosX64() 的发行版)。这条旧线的代价是 Gradle 必须配 8.14.1(上游 wrapper 8.10 升一级),并保留 kotlin.native.ignoreDisabledTargets=true 等三个 KN 开关。
settings.gradle.kts 把 eazytec 仓库写在最前:
kotlin
pluginManagement {
repositories {
// HarmonyOS Kotlin fork (2.2.21-1.0.0) --- must resolve before the portals.
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
mavenCentral()
}
}
rootProject.name = "ohos_tanstack_table"
include(":table-core")
include(":example:nativeApp")
gradle/libs.versions.toml 从 34 行裁到 4 行------整个插件链只剩一个 kotlinMultiplatform 别名;AGP / Compose / dokka / vanniktech 全部随根构建替换消失。
2.2 编译真实引擎
table-core/build.gradle.kts 从 92 行(含 AGP namespace、androidTarget、三个 iOS 目标、Maven Central 发布块)收敛为纯 KMP 模块:
kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证 + 跑上游 29 项单测
ohosArm64()
ohosX64()
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
text
:table-core:compileKotlinJvm BUILD SUCCESSFUL
:table-core:compileKotlinOhosArm64 BUILD SUCCESSFUL
:table-core:jvmTest 29 项全绿
引擎的 44 个 commonMain 文件一个没动------本 fork 引擎侧的新增源文件数是 0。
2.3 测试依赖的处置
commonTest 对 ohos 测试编译可见,但 ohos 测试执行器未接线。处置与 KStore 相同:测试统一在 JVM 跑,根构建全局禁用 ohos 测试任务:
kotlin
subprojects {
// The ohos test runners are not wired up in this fork; only JVM tests run.
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
上游测试文件零改动保留,ohos 侧只承担编译与链接。
第 3 阶段:桥协议建模(本适配的核心设计)
3.1 JSON 契约
text
io.github.tanstacktable.core/**(上游引擎,原样编译)
↓ Table / ColumnDef / 九大特性 / 行模型管线
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API tableRender()
↓
ArkUI Index.ets(六 Tab)
请求与响应:
text
请求 : {"tab":"sorting","state":{...},"action":{"type":"toggleSort","columnId":"age"},"dataSeed":0}
state = 上一次响应回显的 state,原样回传;首次为 null
action = 可选,每请求至多一个(20 种,见下表)
响应 : {"ok":true,"tab":"...","engine":"...","headerGroups":[...],"rows":[...],
"pagination":{...},"selectedCount":N,"faceted":{...},"state":{...},"status":"..."}
{"ok":false,"error":"..."}
3.2 无状态桥 + "两阶段表"(为什么这么设计)
表格是有状态引擎,但桥不持有任何 Table 实例。每个请求走两阶段:
kotlin
// 阶段一:变体表------state = 请求携带态,onStateChange 捕获引擎算出的新状态
var captured: TableState? = null
val mutationTable = buildTable(tab, data, state,
onStateChange = { updater -> captured = functionalUpdate(updater, state) })
applyAction(mutationTable, action) // column.toggleSorting / table.nextPage / row.toggleSelected ...
captured?.let { state = it }
// 阶段二:干净表------用新状态重建,序列化视图模型
val table = buildTable(tab, data, state, onStateChange = null)
return render(tab, table, state)
这么做的依据和收益:引擎 setter 走 makeStateUpdater → setState → options.onStateChange,并不回写 options.state;所有行模型 memo 的 deps 读 table.getState()(即 options.state)。两阶段重建(100 行数据成本微秒级)换来三件事------零隐藏状态 (ArkTS 崩了重来、热重载、多实例互不影响)、不依赖 memo 失效细节 、状态即数据往返 (响应里的 state 就是下一轮请求的 state,ArkTS 不需要理解内部结构)。
3.3 动作表:语义留在引擎侧
20 种动作全部是引擎自身 accessor 的薄封装:
| type | 引擎调用 |
|---|---|
toggleSort / resetSorting |
column.toggleSorting(null, false) / table.setSorting([]) |
firstPage / previousPage / nextPage / lastPage |
同名 accessor |
setPageIndex / setPageSize |
同名 setter |
toggleGroup / clearGrouping |
column.toggleGrouping() / table.setGrouping([]) |
toggleExpand / toggleExpandAll / collapseAllGroups |
row.toggleExpanded(null) / toggleAllRowsExpanded(null) / setExpanded({}) |
toggleRowSelected / toggleAllPageRows / clearSelection |
row.toggleSelected(null, null) / toggleAllPageRowsSelected(null) / setRowSelection({}) |
setGlobalFilter / clearGlobalFilter |
table.setGlobalFilter(v / null) |
setColumnFilter / clearColumnFilters |
column.setFilterValue(v) / setColumnFilters([]) |
排序的"无 → 升 → 降 → 无"循环、sortDescFirst、子行级联选择、toggleAllPageRows 的半选态,这些语义上游已实现且有单测------在 ArkTS 重写一份必然漂移,所以一个都不重写。
3.4 视图模型序列化
行模型逐行序列化,cells[].kind 四态对齐上游 Grouping 示例的着色语义:
text
grouped 分组键格(绿) ← cell.getIsGrouped(),值取 row.groupingValue
aggregated 聚合格(橙) ← cell.getIsAggregated(),值取 aggregationFn 计算结果
placeholder 占位格(灰) ← cell.getIsPlaceholder(),值恒为空串
plain 普通格 ← flexRender 等价解析(cell 模板是 lambda 则调用,否则原样)
JSON 解析/序列化用手写 MiniJson(递归下降,~230 行),不引 kotlinx.serialization------与 qrose 同款取舍,.so 保持零第三方依赖。
3.5 错误通道
导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:
kotlin
@CName("TableRender")
fun tableRender(request: String): String = try {
val req = MiniJson.parse(request) as? Map<*, *> ?: emptyMap<String, Any?>()
MiniJson.write(handleRequest(req))
} catch (t: Throwable) {
MiniJson.write(mapOf("ok" to false, "error" to (t.message ?: t.toString()), "engine" to ENGINE_VERSION))
}
非法 JSON、未知 tab、未知 action、getRow 找不到行(分组重算后旧 id 消失),全部走同一个 ok:false 通道,ArkTS 侧统一渲染进状态行。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
text
ArkTS
│ JSON string
▼
C++ N-API entry(libtanstacktable_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libtanstacktable.so)
│ TableRender / libtanstacktable_symbols
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;表格引擎的输出本来是结构化模型,JSON 是它的自然序列化形式,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
kotlin
@CName("TableRender")
fun tableRender(request: String): String // tab 分发 + action + render 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
TableRender |
tableRender(req) |
建表(options.state)+ 20 种动作 + 六级行模型序列化 |
libtanstacktable_symbols |
(N-API 内部) | KN 运行时字符串释放接口 |
llvm-nm -D 实测双符号均为全局 T。
4.3 C++ N-API 方法分发
cpp
napi_property_descriptor desc[] = {
{"tableRender", nullptr, NapiTableRender, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr}};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libtanstacktable_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libtanstacktable_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
4.4 CMake 和类型声明
cmake
add_library(tanstacktable SHARED IMPORTED)
set_target_properties(tanstacktable PROPERTIES
IMPORTED_LOCATION ${KN_SO} # entry/libs/<OHOS_ARCH>/libtanstacktable.so
IMPORTED_NO_SONAME TRUE)
add_library(tanstacktable_napi SHARED napi_init.cpp)
target_link_libraries(tanstacktable_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
tanstacktable)
OHOS_ARCH 为空或 KN so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
typescript
export const tableRender: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 发起调用与状态往返
typescript
private call(action?: Action): void {
const body: string = JSON.stringify({
tab: this.tab,
state: this.tabState[this.tab] ?? null, // 上一轮响应的 state,原样回传
action: action ?? null,
dataSeed: this.dataSeed
});
const raw: string = tableRender(body);
const parsed: Resp = JSON.parse(raw) as Resp;
if (!parsed.ok) { this.statusText = `失败: ${parsed.error ?? 'unknown'}`; return; }
this.tabState[this.tab] = parsed.state; // 存下新一轮回显
this.headerGroups = parsed.headerGroups;
this.rows = parsed.rows;
this.pagination = parsed.pagination;
this.statusText = parsed.status;
}
每个 Tab 一份独立 tabState,切 Tab 不串状态;dataSeed 驱动确定性数据重生成("刷新数据"按钮)。
5.2 页面交互
六 Tab 共用一套表格渲染器(多级表头 + 行 + 前导列),差异只在控件区:排序 Tab 是重置钮 + 表头点击;筛选 Tab 是全局/列两个输入框 + 面值 chip(带计数,激活变黑);分页 Tab 是四向翻页钮 + 页大小钮;分组 Tab 是全部展开/全部收起/清除分组 + 表头 ▢/▣ 徽标;选择 Tab 是勾选框前导列 + 全选本页 + 已选计数。列宽固定 118vp、横向滚动,与上游 sample 的 horizontalScroll 行为一致;格子按 kind 四色着色,分组键格显示 值 (叶子数)。
5.3 对比度和错误边界
表头深灰底、分组行灰底、分组键绿、聚合橙、占位灰字;ok:false 时错误原文进状态行,hilog 同步留痕;Native 调用本身抛错同样被 try/catch 收进状态行。
第 6 阶段:示例与验证
6.1 工程结构
text
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=tanstacktable)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/io/github/tanstacktable/ohos/
│ ├── Bridge.kt @CName("TableRender") + 两阶段表 + 20 动作
│ └── MiniJson.kt 手写 JSON(继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.table、versionName 0.1.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/libtanstacktable_api.h
├── src/main/cpp/types/libtanstacktable_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 六 Tab 验收页面
6.2 构建、签名和安装
powershell
.\gradlew.bat :table-core:jvmTest # 29 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp(不要打开仓库根目录)
# File → Project Structure → Signing Configs 勾选自动生成,然后:
hdc install -r entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.table -m entry
签名材料由 DevEco 写入本机,仓库内 build-profile.json5 的 signingConfigs 保持空数组,证书绝不进入 AtomGit。
四、完整代码对照
4.1 整体架构
text
io.github.tanstacktable.core/**(上游引擎原样编译)
│ Table<TData> / ColumnDef / TableState / 九大 TableFeature
│ 行模型管线:core → filtered → sorted → grouped → expanded → pagination
Bridge.kt + MiniJson
│ TableRender(无状态,状态往返,动作走引擎 accessor)
libtanstacktable_napi.so
│ tableRender
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 真机页面(六 Tab:基础/排序/筛选/分页/分组/选择)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts |
极简 ohos 构建、eazytec Maven 置前、ohos_tanstack_table、Compose 模块移出 |
gradle/libs.versions.toml |
只剩 HarmonyOS Kotlin 2.2.21-1.0.0 一个版本号 |
build.gradle.kts(根) |
版本 0.1.0-ohos.1、全局禁用 Ohos*Test* 任务 |
table-core/build.gradle.kts |
纯 KMP:jvm + ohosArm64 + ohosX64(引擎源码零改动) |
example/nativeApp/build.gradle.kts |
双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt |
1 个 C ABI、两阶段表、20 动作、MiniJson、错误通道 |
nativeApp/.../MiniJson.kt |
手写递归下降 JSON 解析/序列化 |
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 六 Tab 验收页面 |
README.OpenHarmony_CN.md |
鸿蒙适配主文档(24 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | createTable(TableOptions) |
建表;options.state 直接驱动行模型(getState() = options.state) |
| 引擎 | getCoreRowModel() 等 6 个 |
行模型管线,按 Tab 差异化接线 |
| 引擎 | column.toggleSorting / getIsSorted |
排序循环与指示(响应 sortDir) |
| 引擎 | row.toggleSelected / getIsSelected / getIsSomeSelected |
选择与半选态 |
| 引擎 | row.getIsGrouped / groupingValue / subRows |
分组行模型 |
| 引擎 | cell.getIsGrouped / getIsAggregated / getIsPlaceholder |
聚合格三态(响应 kind) |
| 引擎 | column.getFacetedUniqueValues |
面值统计(响应 faceted) |
| 引擎 | functionalUpdate + onStateChange |
两阶段表的状态捕获通道 |
| Native | TableRender |
C ABI JSON 导出 |
| N-API | tableRender(req) |
向 ArkTS 暴露能力 |
| ArkTS | call(action) |
发起动作并把响应灌回 @State |
4.4 ArkTS 与 Kotlin 的边界
typescript
// 点 Age 表头:回传 res.state + 动作,引擎完成"无→升→降→无"循环
const raw = tableRender(JSON.stringify({
tab: 'sorting',
state: res.state,
action: { type: 'toggleSort', columnId: 'age' }
}));
res = JSON.parse(raw);
// res.headerGroups[0].headers[2].sortDir === 'asc',res.rows 已按新序重排
kotlin
// 桥侧:动作经引擎 accessor,桥不理解排序规则
"toggleSort" -> table.getColumn(str(action["columnId"]) ?: "")?.toggleSorting(null, false)
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:引擎零改动整体编译
table-core 的 44 个文件是本批适配库里唯一的零依赖引擎------没有 kotlinx 制品线问题,没有 expect/actual 要补,不需要 qrose 式内核提取,也不需要 KStore 式换线验证。上游的引擎/渲染分层(table-core / table-compose)让鸿蒙适配面天然最小:编不过 ohos 的部分恰好全部在渲染层,而渲染层恰好可以整体留盘。
决策 2:无状态桥 + 状态往返
表格引擎有状态,但状态可以是数据 而不是会话 :每个响应回显完整 state,下个请求原样回传。跨语言的生命周期管理、线程归属、崩溃恢复问题全部消失,代价只是每请求重建一张表(100 行数据微秒级)。这比在桥里维护 Table 实例注册表(按 id 持有、失效、淘汰)简单一个数量级,而且 ArkTS 侧始终持有唯一 UI 真源。
决策 3:动作走引擎自身 accessor
排序三态循环含 sortDescFirst / invertSorting / sortUndefined 的分支、toggleAllPageRowsSelected 的半选计算、分组行的展开拍平------这些语义上游已实现且被 29 项单测钉住。桥里 20 种动作全是单行薄封装,一行语义都不重写。
决策 4:jvm() 保留 + 交叉验证
引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;jvmTest 跑的就是上游原测试文件,是"内核没漂"的闸门。怀疑鸿蒙侧行为异常时,先用相同数据在 JVM 复现------数据生成规则(makeData)与上游 sample 同型,可平移成单测。
决策 5:根构建替换(cmp_qrose 模式),Compose 模块留盘
上游根构建(AGP / Compose / dokka / vanniktech / iOS framework)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。table-compose / sample / iosApp 留盘不进 settings.gradle.kts:源码零改动地等社区 CMP 工具链与 HarmonyOS Kotlin 对齐后接回,届时桥降级为无 Compose 场景的永久通路。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(Git Bash / PowerShell)、JDK 21(Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
powershell
.\gradlew.bat :table-core:jvmTest
29 项用例全部通过(上游原测试文件,零改动):
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
RowSelectionTest |
13 | 选择状态机:单选/级联/半选/过滤后保持 |
RowPinningTest |
7 | 行固定:top/bottom 拍平与 id 稳定 |
RowSelectionReactTest |
4 | React 式状态更新语义 |
CoreEngineTest |
3 | 核心行模型与列解析 |
GetGroupedRowModelTest |
1 | 分组/聚合行模型 |
ColumnVisibilityTest |
1 | 列可见性 |
6.3 HAP 验证
text
:table-core:compileKotlinJvm / jvmTest: BUILD SUCCESSFUL(29 项全绿)
:table-core:compileKotlinOhosArm64: BUILD SUCCESSFUL
:example:nativeApp:compileKotlinOhosArm64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: TableRender / libtanstacktable_symbols 均为全局 T 符号
copyKnSoArm64 / copyKnSoX64: entry/libs 双 ABI 三件套回填
BuildNativeWithCmake + CompileArkTS + PackageHap: BUILD SUCCESSFUL
install bundle successfully / start ability successfully 真机 arm64
libtanstacktable.so debugShared 未 strip 时 arm64 约 7.2MB、x86_64 约 6.6MB(含 Kotlin/Native 运行时与完整引擎);libc++_shared.so arm64 约 1.85MB。
6.4 功能用例
- 启动进入基础 Tab,10 行 6 列直出,状态栏
10 rows · core row model; - 排序 Tab 点
Age表头三次:无 →▲→▼→ 无,状态栏同步sort: age asc/desc/none;点Status列按 single → complicated → relationship 排(自定义排序函数生效);每 10 行的 null lastName 始终沉底(sortUndefined = "last"); - 筛选 Tab 全局框输入
ta,行数实时收敛,状态栏N/100 rows pass · global='ta';status面值 chip 显示三个值的计数,点击激活变黑即列过滤,再点取消; - 分页 Tab 四向翻页 + 8/10/20 页大小切换,状态栏
page k/N · pageSize n · 100 rows,首页在第一页时置灰; - 分组 Tab 默认
grouping: status,三个组行(绿键格 +(N)叶子数);展开后子行出现;聚合格橙色------age = median、visits = sum、progress = mean;点其他表头 ▣ 换分组列,清除分组回到平铺; - 选择 Tab 行首勾选,状态栏
N selected;翻页后选择保持;全选本页只作用于当前页; - 每步交互状态栏都显示引擎侧
status摘要------数据来自真实 Native 调用而非静态文字。
6.5 验证结论
表格引擎、行模型管线、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,六个 Tab 的排序循环、筛选面值、分页、分组聚合与行选择全部由真机上的上游原版引擎驱动(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
powershell
git clone https://atomgit.com/oh-tpc/tanstack-table-kmp.git
cd tanstack-table-kmp
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
.\gradlew.bat :table-core:compileKotlinOhosArm64 :table-core:compileKotlinOhosX64 # klib
.\gradlew.bat :table-core:jvmTest # 29 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco 打开 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.table -m entry
hdc shell hilog | findstr TableNapi
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:table-core真正编译成ohosArm64/ohosX64产物; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;org.gradle.java.home只管 Gradle daemon,gradlew 启动器自己也要JAVA_HOME------Windows 下不设环境变量,wrapper 第一行就报JAVA_HOME is not set;- 2.2.21-1.0.0 旧线必须配 Gradle 8.14.1(上游 8.10 会升一级),与 KStore 的 2.4.20 线留 9.x 是两条经验,不能互相套用;
- ArkTS 的
Text组件没有.alignItems()------垂直居中用固定height+lineHeight同值实现,直接链式调用会在CompileArkTS报Property 'alignItems' does not exist on type 'TextAttribute'; - ArkTS 接口必须把用到的响应字段全部显式声明 :漏一个(
facets)就是两条编译错误------arkts-no-any-unknown与Property does not exist连锁,补上类型即消; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - DevEco 打开工程后会生成
.clangd/.clang-tidy/.idea/.cxx,根.gitignore的/build/盖不住嵌套目录------entry/libs/、.hvigor/、oh_modules/要单独追加,否则混进提交; build-profile.json5的signingConfigs仓库内保持空数组,签名配置由 DevEco 勾选自动生成后回填本机,证书绝不进入 AtomGit;- CAdapter 生成的
api.cpp会有non-void function does not return类 clang 警告,qrose 同款,无害忽略; - ArkTS 里
Record的字段访问用['status']而不是.status,严格模式不认动态属性名。
8.2 已知问题
- 鸿蒙侧没有
:table-compose,UI 由 ArkTS 原生渲染;等社区 CMP 工具链与 HarmonyOS Kotlin 对齐后接回(源码留盘零改动); - 引擎的列可见性、列顺序、列/行固定、列宽、footer 未做 Demo 界面------桥的
state已含这些切片,扩展只加 ArkTS 控件即可; - 上游 13 个示例屏取 6 个特性面做鸿蒙验收,其余依赖 Compose 交互或与已覆盖面重复;
createdAt的日期格式化用纯公历算法手写(common 无java.time,不引 kotlinx-datetime);libtanstacktable.sodebugShared 约 7.2MB(含 KN 运行时 + 完整引擎),无按需裁剪;- ohos 测试执行器未接线,
*Ohos*Test*任务禁用,引擎正确性靠 29 项 JVM 单测兜底。
8.3 未来优化方向
- 桥协议扩展:列可见性 / 列序 / 固定 / footer 的 ArkTS 控件(state 切片已就绪);
- 按需出 so:只链接用到的特性管线,压缩产物体积;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)与其他设备 ABI 的验收矩阵和 uitest 自动化截图脚本。
九、总结
9.1 核心难点回顾
text
上游 table-core(44 文件零依赖纯 common)
→ 引擎纯度盘点(零 expect / 零协程 / 零 Compose,无需制品线验证)
→ 工具链对齐 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1
→ 根构建替换(cmp_qrose 模式,Compose 模块留盘)
→ ohosArm64 / ohosX64 动态库(引擎源码零改动)
→ 无状态桥(状态往返 + 两阶段表)+ 20 动作走引擎 accessor
→ C ABI(@CName)+ C++ N-API(DisposeString 纪律)
→ ArkTS JSON 解析
→ ArkUI 真机六 Tab 验收(排序/筛选/分页/分组聚合/行选择)
9.2 封装层次
- 引擎层:上游原版
Table、ColumnDef、九大特性与六级行模型管线; - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理六 Tab 页面状态、动作分发、JSON 解析与视图渲染;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 引擎/UI 分层的库,适配面由分层质量决定 :TanStack v8 移植把全部数据逻辑收在零依赖的
table-core,渲染收在table-compose------前者零改动直接编,后者整体留盘,边界一开始就是清楚的。适配前先做源码依赖度盘点(import 全量 grep),比适配中试错便宜得多; - 有状态引擎跨桥,把状态当数据而不是会话:无状态桥 + 状态往返让跨语言生命周期管理归零;两阶段表(先变体捕获、后干净渲染)绕开 memo 失效细节,代价可忽略;
- 语义只在引擎一份:排序循环、级联选择、分组展开全部经引擎 accessor 触发,ArkTS 不重写任何一行状态机------29 项 JVM 单测钉住的语义,就是真机上跑的语义。
9.4 适配成果
当前适配已完成:
- tanstack-table-kmp
0.1.0真实引擎的双 ABI Kotlin/Native 编译(上游commonMain44 文件零重写、零新增引擎源码); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 六 Tab 验收页面(基础、排序、筛选面值、分页、分组聚合、行选择);
- 29 项 JVM 交叉验证全绿(上游原测试文件)+ HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
- AtomGit 项目文档、OpenHarmony README(中英双份、24 节主文档)、本篇文章和本地效果图。
适配过程 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。