本文记录 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 库,每个源文件都 import androidx.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 / dividerYlayout 返回的全部几何(缩放列宽、列 x 前缀和、行高、行 y、分隔线 y)。
tableWidth / tableHeight / scale表格总尺寸与伸缩系数。
fromIndex / toIndex / footerStart / footerEnd / canBackwards / canForwards分页窗口切片与页脚按钮使能。
x / yalign 返回的单元格内容偏移。

引擎侧完整能力(两遍测量、表头背景分隔线怪癖、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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向本机 JDK 21.0.12)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 从上游 9.1.0 降级;2.2.21 线只支持 Gradle 8.14 线)
内核依赖零(纯 Kotlin,无 kotlinx / 无 Compose)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.composetable
export JAVA_HOME="F:/cmp/.tools/jdk-21.0.12.1+1"
java -version
1.3 源码依赖度盘点
模块commonMain 文件依赖形态处置
common4 + 资源 + 5 平台 actualandroidx.compose.* 全覆盖,唯一 expect 是桌面滚动条Compose 文件全部 git mv 进 composeMain;决策数学镜像成 core/ 4 文件
m22compose.material + 上游 commoncommonMain 挪 composeMain,模块留盘
m32compose.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.ktDataTable 的 SubcomposeLayoutcolumnWidths[c]=max、rowHeights[r]=max;scale:</> 同为 maxWidth/totalWidth,Infinity/相等 → 1f;columnX 前缀和;y 轴:表头 →(无表头背景才放 divider[0])→ 行+分隔线交替 → footer;tableHeight 恒计全部分隔线
TableLayoutEngine.effectiveConstraintWidthBoxWithConstraints 宽度包装有限宽度 max(w, minTableWidth);无限 + min>0 → min;否则 UNBOUNDED
PaginationEngine.ktPaginationState + m2/m3 PaginatorpageCount=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.ktCompose BiasAlignment.aligncentered=(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 方法覆盖内核
TableBridgetableBridge(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.kt1 个 C ABI、13 op、TableSession、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三页签验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(23 节)

4.3 关键 API 对照

层次API作用
内核TableLayoutEngine.solve(...)两遍测量:列宽/行高最大值 → 伸缩 → 列 x / 行 y / 分隔线 y / 表宽高
内核TableLayoutEngine.effectiveConstraintWidth(...)minTableWidth 三分支(有限取 max / 无限取 min / 否则不约束)
内核PaginationEngine.next/previous/goto1 起始页码钳制 [1, ensurePageCountPositive()]
内核PaginationEngine.contentWindow / footerRange页窗口切片 + "$start-$end of $total" 页脚区间
内核TableAlignment.align(...)九向对齐偏移(tie 向 +∞ 取整、RTL 翻转)
内核TableDensity.roundToPx(dp)(dp × density).roundToInt()
NativeTableBridgeC ABI JSON 导出(13 op)
N-APItableBridge(req)向 ArkTS 暴露能力
ArkTSdoStaticLayout() / 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 新增的公式钉子):

测试类用例数钉住什么
TableLayoutEngineTest10拉伸(300→600 scale 2f)/ 收缩(300→150 scale 0.5f)/ Infinity / 精确相等;列 x 前缀和;有/无表头背景的 y 轴推进;表高恒计被吞分隔线;空表零尺寸;非法输入拒绝;effectiveConstraintWidth 三分支;minTableWidth 驱动横向滚动
PaginationEngineTest9ceil 页数、空表 1 页、两端钳制、goto 越界钳制、窗口切片(含部分末页)、页脚区间、改 totalCount/pageSize(含不重钳怪癖)、pageSize 非法拒绝
TableAlignmentTest6偶/奇空间九向、CenterStart 默认、tie 向 +∞(-0.5f→0、4.5f→5)、RTL 翻转、溢出空间、fromName 回落
TableTokensTest3上游默认值、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 功能用例

  1. 启动 About 页签版本握手显示 0.3.0-ohos.1 / 上游 0.3.0-SNAPSHOT / Kotlin 2.2.21-1.0.0(来自真实 Native 调用);
  2. Static 页签 18 行静态表渲染,列宽随内容(Name 中、Age 最窄、Email 最宽),表宽拉伸填满视口;
  3. 真机状态行实测 Static · 列宽 [78, 54, 201] vp · 表宽 333 vp · scale 1.29——拉伸系数 > 1,"列窄自动拉伸"语义在设备上成立;
  4. Paginated 页签 18 行分 4 页(每页 5),页脚 1-5 of 18,|< < 禁用、> >| 可用;
  5. 点行 / 勾选框切换选中,表头勾选框整页全选/清空;
  6. 翻到末页页脚显示 16-18 of 18(部分页),> >| 禁用;
  7. 翻页后选择清空(上游 onPageChanged 语义);
  8. Add User 后共 19 行、页数变 4(ceil(19/5)),选择清空;
  9. 表宽按 minTableWidth = 960dp 决策,超出视口横向可滚动;
  10. 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :common 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. wrapper 启动器先于 org.gradle.java.home 生效——不先 export JAVA_HOME 会报 JAVA_HOME is not set,仓库内的 JDK 路径只管守护进程;
  4. jvm() 交叉验证会编译 src/jvmMain,上游平台 actual 里的 compose.desktop import 会炸掉内核 JVM 编译——平台 actual 必须随 Compose 源码一起进 composeMain;
  5. kotlin.math.max 不 import 时会命中集合层废弃的 max() 重载并给出误导性报错,内核统一用 maxOf 一劳永逸;
  6. 鸿蒙 KN CAdapter 不把 @CName 业务符号导出给 ArkTS,import 'libohostable.so' 不可行,必须走 CMake NAPI 包装层;
  7. CMake IMPORTED 库不带 IMPORTED_NO_SONAME TRUE 会把 Windows 绝对路径写进 RUNPATH,设备上 dlopen 失败——配合 BUILD_RPATH/INSTALL_RPATH "$ORIGIN";
  8. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  9. napi_get_value_string_utf8 先传 nullptr 量长、再 std::vector<char> 取内容,一步到位会在新 SDK 头文件上翻车;
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  11. build-profile.json5 的 signingConfigs 仓库内留空数组,勾选 DevEco 自动签名后由 IDE 回填完整配置,证书绝不进入仓库;
  12. 根 .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 三条经验

  1. 适配路线由库的形态决定,不由历史结论决定:KStore 是纯 Kotlin 引擎 + 制品线齐备,答案是"真编译整库";compose-table 是纯 Compose UI 库,答案是"决策数学抽内核 + ArkTS 渲染"。同一个社区、两条路线、各自成立——先盘点源码集的依赖形态,再定路线;
  2. 镜像上游时要连怪癖一起镜像:表头背景吞分隔线的 y 前进但表高照计、changePageSize 不重钳页码——这些"看起来像 bug"的行为是上游语义的一部分,"顺手修正"就是制造漂移,测试钉住它们才叫 1:1 镜像;
  3. 让真机数字可复现再谈验收: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。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐