本文记录 gridlayout-compose 接入 OpenHarmony 的完整过程,覆盖网格布局库盘点、鸿蒙工具链钉版、上游 Compose 源码集隔离、MeasurePolicy 数学逐行移植、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

gridlayout-compose 是 cheonjaeung 开发的 Compose Multiplatform 非懒加载网格布局库(上游 2.9.0,Apache-2.0):VerticalGrid / HorizontalGrid 提供顺序折行网格,BoxGrid 按行列自由摆放,SimpleGridCells.Fixed / Adaptive / FixedSize 与 ExtendedGridCells.Track / Responsive 管理单元格切分,外加 span 跨格、九宫格对齐与六种排列。本次适配与社区早期路线(Vico / Reorderable / qrose 的「逻辑/模型编译到 ohos + 桥 + ArkTS 渲染」)同路但载荷不同:布局库的产物本来就是几何坐标而不是像素,因此走的是「算法级移植 + 几何 JSON 桥」——上游 17 个 Compose 源文件 git mv 到 grid/src/composeMain 原样保留(不参与编译),MeasurePolicy 的测量/排布数学被逐行移植成 5 个纯 Kotlin 文件编进 libohosgrid.so,@CName("GridSolve") 以扁平 UTF-8 JSON 输出每个 item 的几何 frame,ArkTS 侧用 ArkUI Stack + position 着色块渲染。上游源码 100% 在 commonMain、只依赖 compose runtime / foundation / ui-util,但每一行都挂着 @Composable / Modifier / MeasurePolicy——这是它与 KStore「真编译整库」路线的本质区别:鸿蒙侧编不了 Compose,就移植 Compose 背后的数学,一个不跑 Compose UI 却与上游逐位一致的网格求解器运行在真机上,ArkUI 页面作为验收宿主。

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

上游仓库: cheonjaeung/gridlayout-compose

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

gridlayout-compose 为 Compose Multiplatform 应用提供"非懒加载网格"这一件事的完整答案:Jetpack Compose 只有 LazyGrid,简单场景下没有轻量网格;这个库补上 VerticalGrid / HorizontalGrid(顺序排布,列/行由单元格策略决定)和 BoxGrid(任意 row/column 槽位 + 双向 span),SimpleGridCells 三种基础策略加 ExtendedGridCells 的 Track(加权轨道)/ Responsive(断点切换)扩展策略,配合 GridItemSpan 跨格、每 item 的对齐与两个轴向的排列。上游基准显示非懒网格首帧比 LazyVerticalGrid 快 20-40%。Compose 1.11 出了官方实验性 non-lazy grid 之后,上游已宣布进入维护模式,但仍修 bug、仍保兼容。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套栅格数学,但上游构建(Gradle wrapper 9.5.0)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示格子,却无法证明上游的测量/排布算法在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证算法移植、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
源码整体依赖 Compose上游 17 个源文件全部挂在 @Composable / Modifier / MeasurePolicy / Arrangement / Alignment 上,ohos 编译器一个都编不过,"真编译整库"路线(KStore 模式)在此库不可行。
工具链不一致ohosArm64() / ohosX64() 只存在于 HarmonyOS Kotlin 2.2.21-1.0.0 插件(eazytec Maven);上游 Gradle wrapper 9.5.0 与 2.2.21 旧线不兼容,必须降到 8.14.1。
语言边界不同ArkTS 不能直接 import Kotlin/Native so,HarmonyOS CAdapter 也不把 @CName 业务符号挂到 ArkTS,需要 C ABI、C++ N-API 和 JSON 三层。
布局语义跨不了桥上游 Arrangement / Alignment 是可自定义 lambda,MeasurePolicy 吃的是 Constraints 对象,C ABI 只能同步一来回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:测量/排布数学的纯 Kotlin 移植、上游 Compose 源码集的隔离保留(cmp_qrose 审核模式)和几何 JSON 桥接层。栅格语义完全由逐行移植的上游公式负责,ArkTS 只负责调用 Native、解析 JSON 和按 frame 渲染。

1.2 库提供的能力

Demo 提供一次真实的网格求解会话,对应桥接的全部入口:

  • 版本握手(gridVersion):@CName("GridVersion") 返回 2.9.0-ohos.1,Demo 首屏蓝色徽标即来自这次真实 Native 调用而非静态文字——徽标一出就证明 libgrid_napi.so 与 libohosgrid.so 都活着;
  • 顺序网格求解(sequential):kind=sequential + orientation=vertical/horizontal,对应上游 VerticalGrid / HorizontalGrid;五种单元格策略(Fixed / Adaptive / FixedSize / Track / Responsive 嵌套)、主轴/交叉轴各 6 种排列、九宫格对齐、双向间距、fill 开关、四种 span 模式、itemCount 滑杆(0-100)与 varySizes 尺寸交替全部可组合触发;
  • 槽位网格求解(box):kind=box 对应上游 BoxGrid(rows=, columns=),12 个预设槽位覆盖普通格、rowSpan、columnSpan 与一个 row=99 越界项(专门演示 skipped 语义);
  • 求解回执:状态行实时刷新格数、容器尺寸、placed / skipped 计数与 solveMicros(TimeSource.Monotonic 计时,通常个位数微秒),原始响应折叠区可核对 JSON 原文。

响应包含:

字段作用
ok求解是否成功,失败时 error 带原文。
kind / orientation / version / density回显请求参数与 fork 版本(2.9.0-ohos.1)。
crossAxisCells[]交叉轴每格的 px 宽/高,可直接用于对账。
containerWidth / containerHeight求解出的容器尺寸 px。
itemCount / placedCount / skippedCount请求项数 / 摆放数 / 跳过数(span 超总格数或槽位越界)。
items[]每项 { index, row, column, x, y, width, height },全部整数 px。
solveMicros端到端求解耗时微秒数(解析 + 求解 + 序列化)。

全部尺寸 vp 进、px 出:请求里的尺寸乘 density 四舍五入成整数 px,响应里的坐标/尺寸全是整数 px,ArkTS 不再做单位换算(要 vp 就除回 density)。算法侧完整能力(余数前置分配、Adaptive 计数、Track 差值取整、span 折行、六排列、九宫格、BoxGrid 摆放)全部在 grid/src/commonMain/.../ohos/ 的移植模型里随编译进入 .so,JVM 侧保留 41 项交叉验证用例(期望值手算自上游公式),证明 ohos .so 里执行的栅格数学没有漂移。

1.3 实现适配

维度要求
代码复用上游 17 个 Compose 源文件 git mv 到 grid/src/composeMain 原样保留(上游快照,不参与编译,绝不删);鸿蒙侧新增 5 个纯 Kotlin 文件逐行移植单元格数学与折行摆放,不改任何上游算法语义(两处实现决策单列,见 5.5)。
平台目标:grid 声明 jvm() + ohosArm64() + ohosX64()(库出 KLIB,jvm 跑 41 项交叉验证),example/nativeApp 出双 ABI 动态库。
构建隔离settings.gradle.kts 只 include :grid 与 :example:nativeApp,rootProject.name = "ohos_gridlayout";上游 samples / benchmark / docs / .github 与 Paparazzi 快照测试留盘不进构建;根构建禁用所有 Ohos*Test* 任务。
桥接稳定使用 2 个 C ABI(GridSolve / GridVersion)和扁平 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律库模块零外部依赖(纯 Kotlin + stdlib),不上 kotlinx.serialization,桥的 MiniJson 是手写零依赖解析器——不给 .so 多添一个 KN 依赖面。
UI 完整单页覆盖全部桥接入口:版本徽标、kind/orientation/五种 cells 策略、四种 span 模式、itemCount 滑杆、双轴排列、九宫格对齐、fill/varySizes 开关、box 12 槽位、状态行与原始 JSON 折叠区。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit;提交作者 Everlasting,组织走 oh-tpc。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧不跑 Compose UI:@Composable API(VerticalGrid 等)仍在 composeMain 不参与编译;ArkTS 拿到几何 frame 后用 Stack + position 自行渲染。几何桥是布局库在"无 Compose 运行时"环境下的正确桥形态。

二、实现路线图

第 1 阶段:项目初始化     ── 查重、盘点 17 个源文件的 compose 依赖面、确认「算法级移植 + 几何 JSON 桥」路线与交付物
第 2 阶段:源码集隔离与目标打通 ── 根构建替换(cmp_qrose 模式)、eazytec Maven 置前、Gradle 降到 8.14.1、git mv 拆 composeMain、ohosArm64/ohosX64
第 3 阶段:算法移植与桥协议建模 ── ohos/ 5 文件逐行移植测量数学、请求/响应 JSON 契约、solveMicros 与错误通道
第 4 阶段:原生桥接       ── @CName C ABI × 2、C++ N-API(JsToUtf8 两步法、DisposeString)、CMake IMPORTED + $ORIGIN 三件套
第 5 阶段:ArkUI 宿主     ── 版本徽标、控制面板、Stack+position 着色块渲染、两阶段「首项跨满」、状态行与 JSON 折叠区
第 6 阶段:示例与验证     ── HAP、双 ABI、DevEco 自动签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
grid/                                 上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
grid/src/composeMain/                 上游 17 个 Compose 源文件(git mv,原样保留不编译)
grid/src/commonMain/.../grid/ohos/    新增:纯 Kotlin 网格求解模型(5 文件)
grid/src/commonTest/.../grid/ohos/    新增:41 个 JVM 交叉验证用例(3 文件)
grid/src/androidHostTest/             上游 Paparazzi 快照测试(留盘不进构建)
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)→ libohosgrid.so
example/harmonyApp/                   DevEco Stage、CMake NAPI 和 ArkTS 页面
samples/ benchmark/ docs/ .github/    上游遗留,留盘不进 settings.gradle.kts
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :grid 与 :example:nativeApp,rootProject.name = "ohos_gridlayout"。上游的 Android target、api/ 二进制兼容声明、Paparazzi 快照、samples 与 CI 面全部随根构建替换剥离——本 fork 无 Android target,不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Homebrew openjdk@21)
OpenHarmony 目标ohosArm64 + ohosX64(:grid 为 KLIB,example/nativeApp 为 sharedLib)
Gradle8.14.1(上游 wrapper 9.5.0 必须降,2.2.21 旧线撑不住 9.x)
外部依赖无(库模块纯 Kotlin;桥不引 kotlinx.serialization)
DevEco producthvigor modelVersion 5.1.0、compatibleSdkVersion 5.1.0(18)
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.gridlayout(vendor oh-tpc,versionName 2.9.0-ohos.1)
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
源码集文件数依赖形态处置
上游 commonMain17@Composable / Modifier / MeasurePolicy / Arrangement / Alignment,仅依赖 compose runtime / foundation / ui-utilgit mv 到 composeMain 隔离(不编译,绝不删)
上游 androidHostTest若干Paparazzi 快照 + Android 宿主留盘不进构建
新增 commonMain/.../ohos/5纯 Kotlin(kotlin-stdlib + kotlin.math + kotlin.time)整体编译(jvm + 双 ohos)
新增 commonTest/.../ohos/3kotlin-test整体编译(jvm 交叉验证)

这个盘点直接决定路线:上游没有一行"可脱离 Compose 编译"的代码,expect/actual 也没有——所以不可能像 KStore 那样"真编译整库 + 补一个 actual"。但盘点同时显示,上游源码里值钱的东西(格宽分配、折行、排列、对齐)全是整数/浮点数学,真正绑死 Compose 的只有 Constraints 包装和 @Composable 壳。把数学剥出来逐行移植、把 Compose 壳隔离保留,就是"算法级移植"路线的起点。

1.4 查重与路线确定

CPF-KMP-CMP 社区已落地 Vico、Reorderable、qrose、haze 等 CMP 库适配;网格布局方向没有 gridlayout-compose 的鸿蒙版本,上游是唯一的 CMP 非懒加载网格库,社区有真实诉求(跨端栅格一致性),查重通过。路线对齐社区六步共识(commonMain 保算法 → Compose UI 隔离 → ohos 出 KLIB/sharedLib → @CName C ABI → CMake NAPI so → ArkUI Demo),本库的特化点只有一处:过桥载荷是几何 frame 而不是位图。qrose 把 QR 码光栅化成 PNG 交给 ArkTS 解码显示;布局的产物本来就是坐标,直接过桥更小(微秒级求解)、更可交互(ArkTS 侧逐项定位、点击、动画都拿坐标直接算)。

1.5 页面边界
标题区          GridLayout · OpenHarmony / 版本徽标(GridVersion 真 Native 握手,蓝底白字)
副标题          CMP 网格布局库 · Kotlin/Native libohosgrid.so · NAPI 直调求解器
控制面板        kind(sequential/box)radio、orientation、cells 五种策略及参数
                (count/minSize/cellSize/threshold)、span 模式 4 种、itemCount 滑杆、
                主轴/交叉轴排列各 6 种、九宫格对齐 9 种、spacing 数字框、fill/varySizes 开关
渲染区          Stack 按 frame÷density 着色块渲染,8 色调色板 + 白字索引,
                标题注明「px / density 3.0 → vp」,onAreaChange 回写 availableWidth
状态行          cells 数 / 容器尺寸 / placed / skipped / solveMicros 五项实时刷新
JSON 折叠区     原始响应前 500 字符(monospace),排查字段拼写直接对照
box 模式        12 个预设槽位(含 row=99 越界项展示 skipped)

页面启动时先调 gridVersion() 做握手,再以默认参数(sequential / fixed(4) / spacing 8/8 / fill 开 / itemCount 12)触发第一次求解。

第 2 阶段:源码集隔离与目标打通

2.1 根构建替换(eazytec 双仓置前是成败手)

settings.gradle.kts 整体重写,两个 eazytec 仓库必须写在 pluginManagement 与 dependencyResolutionManagement 的最前,否则配置期直接 Unresolved reference: ohosArm64:

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()
    }
}

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")
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_gridlayout"

include(":grid")
include(":example:nativeApp")
2.2 Gradle 降 8.14.1 与 JDK 21

上游 wrapper 是 9.5.0,Kotlin 2.2.21-1.0.0 旧线撑不住,同步直接失败——wrapper 钉 8.14.1。gradle.properties 同时钉死 JDK 与 fork 版本:

org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home

version=2.9.0-ohos.1

kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true

kotlin.native.distribution.downloadFromMaven=true 让 Kotlin/Native 工具链从 Maven 仓拉取(鸿蒙线的 konan 依赖走 eazytec 镜像),CI 与新机器上不需要手工预装。

2.3 源码集隔离:composeMain(本适配的结构性动作)

ohos 编译一旦碰上游源码就爆满屏 Unresolved reference: Modifier / @Composable。处理是把上游 17 个源文件整体 git mv:

git mv grid/src/commonMain/kotlin/com/cheonjaeung/compose/grid \
       grid/src/composeMain/kotlin/com/cheonjaeung/compose/grid

整个 composeMain 源码集不进任何 target 的 sourceSet。绝不能删——它是上游 2.9.0 的原样快照(对账与回溯的锚点),也是未来社区 CMP 与 compose.ui ohos 制品对齐后接回 commonMain 的入口。用 git mv 而不是复制粘贴,保证 git log --follow 能穿透到上游历史。

2.4 :grid 目标与测试纪律

grid/build.gradle.kts 收敛为纯 KMP 模块:jvm() + ohosArm64() + ohosX64(),去掉一切 compose / android 依赖;jvm 目标保留就是为了跑第 22 道防线的 41 个交叉验证用例。根构建全局禁用 ohos 测试任务(kotlin-test 的 ohos 变体不在本次验收面内,测试统一 JVM 跑):

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

实测:

./gradlew :grid:build    # BUILD SUCCESSFUL:41 个 jvmTest 全绿 + 双 ohos KLIB 编译通过

第 3 阶段:算法移植与桥协议建模

3.1 ohos 模型五文件

grid/src/commonMain/kotlin/com/cheonjaeung/compose/grid/ohos/ 新写 5 个纯 Kotlin 文件,分工对齐上游源文件:

文件职责
OhosGridCells.kt枚举 OhosLayoutOrientation / OhosArrangementKind / OhosAlignment2d;OhosGridTrack.Fixed/Weight;OhosGridCells.Fixed/Adaptive/FixedSize/Track/Responsive——单元格数学逐行移植上游 SimpleGridCells.kt / ExtendedGridCells.kt
OhosGridModel.kt数据模型:OhosSequentialItem / OhosBoxItem / OhosItemFrame / OhosSolveResult
OhosArrangement.ktStart / End / Center / SpaceBetween / SpaceAround / SpaceEvenly 六种分布
OhosAlignment.kt九宫格对齐偏移计算
OhosGridSolver.ktsolveSequential(顺序折行)+ solveBox(槽位摆放)

上游用 Constraints 测量子项,这里接收"已测量的 item 尺寸"(OhosSequentialItem.mainAxisSizePx 等),只解几何——这是移植唯一的形态转换,公式本身不动。

3.2 逐行移植的算法数学
  • 余数前置分配(Fixed): 总格宽 = available − spacing × (count−1),均分取整后余数从第一格开始每格 +1。Fixed(3) / spacing 10 / available 1000 → 980/3 = 326 余 2 → [327, 327, 326]。
  • Adaptive 两步: 先解"最多放几格":最大 n 满足 n×minSize + (n−1)×spacing ≤ available;再把剩余空间均分给 n 格。Adaptive(240) / spacing 24 / 1080 → 4 格 → (1080−72)/4 = 252 → [252×4]。
  • FixedSize withSpacing: 定尺寸,格数随容器:n = (available + spacing) / (size + spacing)。
  • Track 累计权重差值取整: 定宽 track 直接扣;加权 track 按"累计权重 × 剩余宽 / 总权重"取整后相邻差值给宽。[288, W1, W2] / 1080 → 剩 792、权重和 3 → W1=264、W2=528 → [288, 264, 528]。差值法保证取整误差不累积、总宽精确贴住容器。
  • Responsive 阈值切换: threshold × density 与可用交叉轴宽比较后选 wide 或 narrow 策略,嵌套递归解析。
  • span 折行循环: 游标按 span 前进;span 超出当前行剩余格数则折到下一行行首;span 超出总格数(maxSpan)则跳过该项计 skipped(上游语义,Demo 的 skipped 计数即来源于此);行高 = 行内 item 主轴尺寸的最大值。
  • 排列公式分母: spaceBetween 自由空间除 n−1(首尾贴边)、spaceAround 除 n(每项两侧各一半)、spaceEvenly 除 n+1(两端也有)。分母写错一个,布局只在多行/多列时才露馅。
  • 九宫格对齐 bias 取整: 转成 horizontalBias / verticalBias(0 / 0.5 / 1),偏移 = round(bias × (cellSize − itemSize)),与 Compose Alignment 的取整方式对齐。
  • BoxGrid 摆放: 四项校验(row < rowCount、column < columnCount、rowSpan ≤ rowCount − row、columnSpan ≤ columnCount − column,不过则计 skipped);摆放尺寸 = 覆盖格宽之和 + 内部间距(span 合并尺寸);width/height = -1 填满槽位,正数则取 min(给定, 槽位);容器 = 全量 availableWidth × availableHeight(对齐上游 BoxGridMeasurePolicy 报告 constraints.maxWidth × maxHeight)。

折行循环的核心形态(对照上游 SequentialGridMeasureHelper):

while (spanSum < maxSpan && itemIndex < items.size) {
    val item = items[itemIndex]
    val span = item.span
    require(span > 0) { "span must be bigger than zero, $span is zero or negative" }

    if (span > maxSpan) {              // 超出总格数:跳过(上游语义)
        itemIndex++
        skippedCount++
        continue
    }
    val remainingSpan = maxSpan - spanSum
    if (span > remainingSpan) break    // 超出剩余:折行

    slots.add(Slot(index = itemIndex, firstCell = spanSum, span = span))
    spanSum += span
    lineMainSize = maxOf(lineMainSize, item.mainAxisSizePx)   // 行高 = max
    itemIndex++
}
3.3 JSON 契约
com.cheonjaeung.compose.grid.ohos/**(移植的求解模型,纯 Kotlin 编译)
        ↓ OhosGridSolver.solveSequential / solveBox
Bridge.kt(@CName × 2,扁平 JSON 进出,MiniJson 解析)
        ↓ UTF-8 JSON(vp 进 px 出)
N-API gridSolve() / gridVersion()
        ↓
ArkUI Index.ets(Stack + position 按 frame 渲染)

请求字段(sequential):

字段类型说明
kindstringsequential(默认)或 box
orientationstringvertical(默认)或 horizontal
cellsobject单元格策略,五种 type 见下;缺省 fixed(4, fill)
rowsobject仅 box:行策略(对应上游 BoxGrid(rows=))
mainAxisArrangement / crossAxisArrangementstring各 6 种:start/end/center/spaceBetween/spaceAround/spaceEvenly
mainAxisSpacing / crossAxisSpacingnumber间距,vp,默认 0
contentAlignmentstring九宫格(如 TopStart / Center / BottomEnd)
densitynumber屏幕密度,必须 > 0;所有 vp 尺寸乘它转 px
availableWidth / availableHeightnumbervp。主轴 ≤ 0 视为无约束(包裹内容)
itemsarraysequential 每项 { span, mainAxisSize, crossAxisSize, alignment },crossAxisSize: -1 填满槽位;box 每项 { row, column, rowSpan, columnSpan, width, height, alignment },越界计 skipped

cells 五种类型:

{"type": "fixed",     "count": 3, "fill": true}
{"type": "adaptive",  "minSize": 240, "fill": true}
{"type": "fixedSize", "size": 96, "fill": true}
{"type": "track",     "tracks": [{"fixed": 288}, {"weight": 1}, {"weight": 2}]}
{"type": "responsive", "threshold": 600,
 "wide":   {"type": "fixed", "count": 4},
 "narrow": {"type": "fixed", "count": 2}}

一个完整的请求/响应对(3 列固定格、10vp 间距、密度 2、可用宽 360vp、第三项跨 2 格):

请求 : {"kind":"sequential","orientation":"vertical",
        "cells":{"type":"fixed","count":3},
        "mainAxisSpacing":10,"crossAxisSpacing":10,
        "contentAlignment":"Center",
        "density":2.0,"availableWidth":360,"availableHeight":0,
        "items":[{"span":1,"mainAxisSize":48,"crossAxisSize":-1},
                 {"span":1,"mainAxisSize":48,"crossAxisSize":-1},
                 {"span":2,"mainAxisSize":48,"crossAxisSize":-1},
                 {"span":1,"mainAxisSize":48,"crossAxisSize":-1}]}
响应 : {"ok":true,"kind":"sequential","orientation":"vertical",
        "version":"2.9.0-ohos.1","density":2.0,
        "crossAxisCells":[227,227,226],
        "containerWidth":720,"containerHeight":212,
        "itemCount":4,"placedCount":4,"skippedCount":0,
        "items":[{"index":0,"row":0,"column":0,"x":0,"y":0,"width":227,"height":96},
                 {"index":1,"row":0,"column":1,"x":247,"y":0,"width":227,"height":96},
                 {"index":2,"row":1,"column":0,"x":0,"y":116,"width":474,"height":96},
                 {"index":3,"row":1,"column":2,"x":494,"y":116,"width":226,"height":96}],
        "solveMicros":42}

几个可对账的数:密度 2 下可用宽 720px、格间距 20px,(720 − 2×20) / 3 = 226.67,余数前置 → [227, 227, 226];第二列 x = 227 + 20(首格宽 + 格间距);跨 2 项宽 474 = 227 + 20 + 227(含中间间距);第二行 y = 96 + 20(行高 + 行间距);容器高 212 = 96 + 20 + 96(availableHeight 为 0,主轴包裹内容)。

3.4 错误通道与计时

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

@CName("GridSolve")
fun gridSolve(request: String): String {
    return try {
        solveRequest(request)
    } catch (t: Throwable) {
        "{\"ok\":false,\"error\":${jsonString(t.message ?: t.toString())}}"
    }
}

density ≤ 0、cells 对象畸形、span 非正数,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。求解计时用 TimeSource.Monotonic.markNow() 包住 solveSequential / solveBox 前后,solveMicros 进响应——量的是解析 + 求解 + 序列化的端到端微秒数,主要用来在 Demo 上确认"求解没有退化成 O(n²) 级异常"。

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

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

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复刻栅格数学,所以选择 C ABI + JSON。HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务符号挂到 ArkTS,所以必须再包一层 NAPI so。

4.2 Kotlin/Native 导出函数
@CName("GridSolve")
fun gridSolve(request: String): String   // sequential / box 一口进,vp 进 px 出

@CName("GridVersion")
fun gridVersion(): String = "2.9.0-ohos.1"  // 版本握手
C ABI 符号ArkTS 方法覆盖内核
GridSolvegridSolve(req)五种 cells 策略 + 折行 + span + 六排列 + 九宫格 + BoxGrid 摆放
GridVersiongridVersion()fork 版本握手(Demo 版本徽标的数据来源)

请求侧的 vp→px 收敛在桥内统一做((vp × density).roundToInt()),避免两端各自取整产生 1px 漂移;availableMain ≤ 0 映射为求解器的 INFINITY(Int.MAX_VALUE / 4,防中间加法溢出)。

4.3 C++ N-API 方法分发
static napi_value NapiGridSolve(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1] = {nullptr};
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    if (argc < 1) {
        napi_throw_error(env, nullptr, "gridSolve requires a JSON string");
        return nullptr;
    }

    napi_valuetype type = napi_undefined;
    napi_typeof(env, args[0], &type);
    if (type != napi_string) {
        napi_throw_type_error(env, nullptr, "gridSolve argument must be a string");
        return nullptr;
    }

    const std::string request = JsToUtf8(env, args[0]);
    OH_LOG_INFO(LOG_APP, "gridSolve request bytes=%{public}zu", request.size());

    const char *raw = GridSolve(request.c_str());
    if (raw == nullptr) {
        napi_throw_error(env, nullptr, "GridSolve returned null");
        return nullptr;
    }

    napi_value out = nullptr;
    napi_status st = napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
    DisposeKnString(raw);
    if (st != napi_ok) {
        napi_throw_error(env, nullptr, "failed to create JS string from GridSolve");
        return nullptr;
    }
    return out;
}

每个回调统一四步:napi_typeof 校验必须是 string、JsToUtf8 两步法取 UTF-8(先探长度再拷贝,不丢多字节字符)、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosgrid_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free():

static void DisposeKnString(const char *raw) {
    if (raw == nullptr) {
        return;
    }
    libohosgrid_ExportedSymbols *symbols = libohosgrid_symbols();
    if (symbols != nullptr && symbols->DisposeString != nullptr) {
        symbols->DisposeString(raw);
    }
}

模块注册 nm_modname = "grid_napi",libohosgrid_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。

4.4 CMake 和类型声明
if(NOT DEFINED OHOS_ARCH OR OHOS_ARCH STREQUAL "")
    message(FATAL_ERROR "OHOS_ARCH is empty; expected arm64-v8a or x86_64")
endif()

set(KN_SO_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/${OHOS_ARCH})
set(KN_SO ${KN_SO_DIR}/libohosgrid.so)
if(NOT EXISTS ${KN_SO})
    message(FATAL_ERROR "Missing ${KN_SO}. Run :example:nativeApp:linkDebugSharedOhosArm64 / linkDebugSharedOhosX64 first (they copy the .so into entry/libs).")
endif()

add_library(ohosgrid SHARED IMPORTED)
set_target_properties(ohosgrid PROPERTIES
    IMPORTED_LOCATION ${KN_SO}
    IMPORTED_NO_SONAME TRUE)

add_library(grid_napi SHARED napi_init.cpp)
target_link_options(grid_napi PRIVATE
    "-Wl,--no-undefined"
    "-Wl,-rpath,\$ORIGIN")
target_link_libraries(grid_napi PUBLIC
    libace_napi.z.so
    libhilog_ndk.z.so
    ohosgrid)

OHOS_ARCH 为空或 libohosgrid.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD copy_if_different 拷三件套(libgrid_napi.so、libohosgrid.so、libc++_shared.so 同目录),dlopen 时互相可见。ArkTS 侧只 file: 依赖类型包,导入面就两个函数:

export const gridSolve: (request: string) => string;
export const gridVersion: () => string;

第 5 阶段:ArkUI 宿主

5.1 版本握手和求解触发
aboutToAppear(): void {
  this.fillOptions(/* … 六组 Select 选项 … */);
  try {
    this.version = gridVersion();   // 版本握手:来自真实 Native 调用
  } catch (e) {
    this.version = 'n/a';
    hilog.error(0x0000, TAG, 'gridVersion failed: %{public}s', `${(e as Record<string, Object>)['message']}`);
  }
  this.solve();
}

private solve(): void {
  try {
    const raw: string = gridSolve(this.buildRequest());
    const parsed: RenderOk = JSON.parse(raw) as RenderOk;
    if (!parsed.ok) {
      this.status = `失败: ${parsed.error ?? 'unknown error'}`;
      return;
    }
    this.frames = parsed.items ?? [];
    this.containerWidth = parsed.containerWidth ?? 0;
    this.crossCells = (parsed.crossAxisCells ?? []).length;
    this.placed = parsed.placedCount ?? 0;
    this.skipped = parsed.skippedCount ?? 0;
    this.solveMicros = parsed.solveMicros ?? 0;
    this.status = `求解成功: ${this.placed} placed / ${this.skipped} skipped / ${this.solveMicros}µs`;
    /* 两阶段「首项跨满」见 5.3 */
  } catch (e) {
    this.status = `异常: code=… name=… message=…`;   // 理论不该发生,同样收进状态行
  }
}

buildRequest() 把页面全部控件状态组装成桥契约的 JSON:cells 按 cellsType 分派五种 type(track 模式首轨 fixed(cellSize)、其余 weight:1;responsive 模式 wide=fixed(count) / narrow=adaptive(minSize) 嵌套);items 按 span 模式生成(全 1 / 每 3 跨 2 / 首项跨满 / 交替 1-2-3)。所有参数 @State @Watch('onParamChange'),任一控件变化即整页重解。

5.2 渲染区:Stack + position 着色块
@Builder
RenderPanel() {
  Column() {
    Text('渲染区 (px / density 3.0 → vp)').fontSize(12).fontColor('#6B7280').margin({ bottom: 6 })
    if (this.frames.length > 0) {
      Stack({ alignContent: Alignment.TopStart }) {
        ForEach(this.frames, (f: GridFrame) => {
          Column() {
            Text(`${f.index}`).fontSize(12).fontColor('#FFFFFF').maxLines(1)
          }
          .width(f.width / this.density)      // px → vp:除回请求传入的 density
          .height(f.height / this.density)
          .position({ x: f.x / this.density, y: f.y / this.density })
          .backgroundColor(this.palette[this.safeIndex(f.index)])
          .borderRadius(6)
        }, (f: GridFrame) => `${f.index}:${f.x},${f.y},${f.width},${f.height}`)
      }
      .width(this.availableWidth)
      .height(Math.min(this.containerHeight / this.density, 2000))
      .clip(true)
    }
  }
  .onAreaChange((oldValue: Area, newValue: Area) => {
    const w: number = Number(newValue.width);
    if (!Number.isNaN(w) && w > 0 && Math.abs(w - this.availableWidth) > 0.5) {
      this.availableWidth = Math.round(w);    // 回写可用宽:拖窗/旋转自动重排
    }
  })
}

8 色调色板循环着色、白字索引标注 item 序号;onAreaChange 把渲染区实际宽度回写 availableWidth,旋转/拖窗后整页自动重解——这是"响应式栅格"在真机上的直接演示。

5.3 两阶段「首项跨满」与错误边界

span 模式「首项跨满」有个先有鸡还是先有蛋的问题:首项的 span 要等于整行格数,但格数要解一次才知道;直接给一个超大 span,求解器按上游语义会把它跳过(skipped)。Demo 的解法是两阶段求解:第一次求解拿到 crossAxisCells.length 记进 knownCellCount,第二次按该数量重解:

if (this.kind === 'sequential' && this.spanMode === 2 &&
  this.crossCells > 0 && this.crossCells !== this.knownCellCount) {
  this.knownCellCount = this.crossCells;
  this.solve();   // 第二阶段:首项 span = 已知格数
}

错误边界三层收口:ok:false 走状态行红字 + hilog;Native 调用本身抛错(理论不该发生)被 try/catch 收进状态行;box 模式的 row=99 越界项不渲染、skipped +1,是"上游语义在真机上活着"的可视证据。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosgrid)+ copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       ├── Bridge.kt                         @CName × 2 + vp→px + 响应拼装
│       └── MiniJson.kt                       零依赖手写 JSON 解析器
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.gridlayout、versionName 2.9.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、POST_BUILD 三件套
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohosgrid_api.h
        ├── src/main/cpp/types/libgrid_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面(约 690 行)
6.2 构建、签名和安装
./gradlew :grid:build                                                        # 41 项交叉验证 + 双 KLIB
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
~/ohos/command-line-tools/bin/hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组占位——hvigor schema 要求字段存在,勾选自动签名后由 IDE 回填带 material 的完整配置)。

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.gridlayout -m entry

注意用 DevEco 只打开 example/harmonyApp 子目录——仓库根目录是 Gradle KMP 工程,DevEco 认的是 hvigor。

四、完整代码对照

4.1 整体架构

grid/src/composeMain/**(上游 17 个 Compose 源文件,原样保留不编译)
    │ 上游快照 + 未来 CMP 接回入口
grid/src/commonMain/.../grid/ohos/**(5 文件,纯 Kotlin 移植)
    │ OhosGridCells(五种策略)/ OhosArrangement(六排列)/ OhosAlignment(九宫格)
    │ OhosGridModel / OhosGridSolver(solveSequential + solveBox)
example/nativeApp Bridge.kt + MiniJson.kt
    │ GridSolve / GridVersion
libgrid_napi.so
    │ gridSolve / gridVersion
Index.ets
    │ JSON parse + Stack/position 渲染
    ▼
ArkUI 真机页面(单页滚动)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_gridlayout、只 include :grid + :example:nativeApp
gradle.properties版本 2.9.0-ohos.1、JDK 21、kotlin.native.distribution.downloadFromMaven=true
build.gradle.kts(根)全局禁用 Ohos*Test* 任务
gradle/wrapper8.14.1(上游 9.5.0 必须降)
grid/build.gradle.kts纯 KMP:jvm() + ohosArm64() + ohosX64(),零外部依赖
grid/src/composeMain/(17 文件)上游 2.9.0 原样快照(git mv,不编译)
grid/src/commonMain/.../ohos/(5 文件)纯 Kotlin 网格求解模型(本适配的核心交付)
grid/src/commonTest/.../ohos/(3 文件 41 用例)JVM 交叉验证
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、vp→px、solveMicros、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面(约 690 行)
README.OpenHarmony_CN.md鸿蒙适配主文档(24 节)

4.3 关键 API 对照

层次API作用
上游VerticalGrid(columns = …) / HorizontalGrid(rows = …)顺序网格 Composable(composeMain 隔离保留)
上游BoxGrid(rows = …, columns = …)槽位网格 Composable(同上)
移植OhosGridCells.Fixed / Adaptive / FixedSize / Track / Responsive五种单元格切分数学(逐行对应上游)
移植OhosGridSolver.solveSequential折行 + span + 行高 max + 排列 + 对齐
移植OhosGridSolver.solveBox四项校验 + span 合并尺寸 + 槽位摆放
移植OhosArrangement.arrange六种分布(分母 n−1 / n / n+1)
移植OhosAlignment.align九宫格偏移(bias 取整)
NativeGridSolve / GridVersionC ABI JSON 导出
N-APIgridSolve(req) / gridVersion()向 ArkTS 暴露能力
ArkTSsolve()组装请求、触发求解、按 frame 刷新 UI

上游语义到桥字段的映射(节选):

上游(Compose)桥(JSON)
SimpleGridCells.Fixed(n){"type":"fixed","count":n}
SimpleGridCells.Adaptive(min) / FixedSize(s){"type":"adaptive","minSize":min} / {"type":"fixedSize","size":s}
ExtendedGridCells.Track(…) / Responsive(…){"type":"track","tracks":[…]} / {"type":"responsive","threshold","wide","narrow"}
GridItemSpan(span)items[].span(超总格数跳过,上游语义)
Arrangement.spacedBy(s)*AxisSpacing = s + *AxisArrangement = start
Modifier.fillMaxSize() / wrapContent()availableWidth/Height > 0 / 主轴 ≤ 0
BoxGrid 的 row/column/rowSpan/columnSpanitems[].row/column/rowSpan/columnSpan(越界计 skipped)
Modifier.fillMaxMainAxisSize—(未移植,见 8.2)

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(gridSolve(JSON.stringify({
  kind: 'sequential', orientation: 'vertical',
  cells: { type: 'adaptive', minSize: 160 },
  mainAxisSpacing: 8, crossAxisSpacing: 8,
  density: 3.0, availableWidth: 360, availableHeight: 0,
  items: Array.from({ length: 12 }, () =>
    ({ span: 1, mainAxisSize: 64, crossAxisSize: -1 }))
})));
// res.ok === true;res.items[].x/y/width/height 全部整数 px,除以 density 得 vp
val started = TimeSource.Monotonic.markNow()
val result = OhosGridSolver.solveSequential(
    orientation, cells, items,
    mainAxisArrangement, mainAxisSpacingPx,
    crossAxisArrangement, crossAxisSpacingPx,
    defaultAlignment, availableCrossAxisPx, availableMainAxisPx,
)
val solveMicros = started.elapsedNow().inWholeMicroseconds
// result.frames 逐项拼成 {"index":..,"row":..,"column":..,"x":..,"y":..,"width":..,"height":..}

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;密度是调用方的环境参数显式传进请求,求解器保持纯函数——JVM 侧测试也能用任意 density 复现同一套断言。

五、关键决策说明

决策 1:算法级移植而不是 PNG 光栅化

社区同路库里 qrose 过桥的是 PNG 光栅化图像。布局库不能走这条路:布局的产物不是像素而是几何坐标,光栅化一个网格等于把"可交互的 frame"降级成"一张死图"——点击、滚动、动画、item 级定位全部丢失。几何 JSON 桥天然更小(微秒级求解)更可交互(ArkTS 侧逐项定位直接拿坐标算)。反过来,Vico 把图表数据交给 ArkTS 画 Canvas、Reorderable 把列表状态交给 ArkTS 列表——载荷形态跟着库的产物走,这是"同一条路"里的正确分叉。

决策 2:composeMain 隔离而不是删改

上游 17 个源文件对 ohos 编译器一行都过不了, temptation 是删掉或重写。处置是 git mv 到 grid/src/composeMain 原样保留:它是上游 2.9.0 的快照(若某次改动让鸿蒙解出的 frame 与 JVM 上游公式手算值不一致,那是回归——对照快照就能定位),也是未来社区 CMP 与 compose.ui ohos 制品对齐后接回 commonMain 的入口,届时 VerticalGrid 可直接在 Compose 里跑,几何桥降级为非 Compose 场景的 fallback。配套纪律:桥不进库的 public API——GridSolve / MiniJson 只活在 example/nativeApp,协议字段要改只改 example,不动库。

决策 3:工具线选 2.2.21-1.0.0 + Gradle 8.14.1

KStore 那批库因为依赖 kotlinx 制品线被迫升到 Kotlin 2.4.20 线(Gradle 留 9.x);本库恰好相反——上游只依赖 compose,鸿蒙侧移植模型是纯 Kotlin,零 kotlinx 外部依赖,所以可以留在社区已审的 2.2.21-1.0.0 线(qrose 同款),代价是上游 wrapper 9.5.0 必须降到 8.14.1。两条工具链线的经验不能互相套用:版本跟着依赖约束走,没有全局最优解。

决策 4:桥设计——扁平 JSON、异常不跨 FFI、版本握手、MiniJson 零依赖

桥的入参出参永远是 const char* → const char*:请求是扁平 JSON(枚举字符串、数字、一层 items 数组、cells 嵌套一层),为此手写 MiniJson 而不上 kotlinx.serialization——为这点结构给 .so 多添一个 KN 依赖、还要和鸿蒙插件线的序列化版本对齐,每次升工具链都可能动 so 的 ABI 面。整体 try/catch 把一切异常转 {"ok":false,"error":...};GridVersion 独立导出,Demo 首屏即可验证 so 已加载且版本对齐。解析器可以随时换,GridSolve 签名不用动。

决策 5:两处实现决策(有意为之的差异)

其余全部逐行对齐上游,只有两处偏离并写进文档:① 主轴容器尺寸在 availableMain > 0 时取该值——等效上游 fillMaxSize 约束 min = max = available(SpaceBetween 这类分空间的排列需要有限总量),≤ 0 时 INFINITY 包裹内容,显式建模上游"fillMaxSize vs wrapContent"两种约束;② 上游折行循环在 cells 为空且有子项时不终止(它依赖非空 cells 的前置条件),移植改为全部计 skipped 返回——防御式差异,避免一个畸形请求拖死 Native 侧。这两处都有测试钉住,是有记录的偏离,不是漂移。

六、测试与验证

6.1 测试环境

本次验证使用 macOS(arm64)、JDK 21(Homebrew openjdk@21)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :grid:build

41 项用例全部通过(grid/src/commonTest/.../ohos/,3 个测试文件):

测试类用例数钉住什么
OhosGridCellsTest13五种策略的格宽数学:余数前置、Adaptive 定格数、FixedSize withSpacing、Track 差值取整、Responsive 断点
OhosArrangementTest14六种排列的间隙公式(分母 n−1 / n / n+1)与零项/单项退化
OhosGridSolverTest14sequential 折行 / box 摆放 / span 跳过 / 对齐偏移 / 容器语义 / 零格防御

三层验证法:公式手算层——期望值全部先按上游公式算出应然值再断言,不是"跑出来什么记什么":

@Test
fun fixedAssignsRemainderPixelsToLeadingCells() {
    // 手算:totalSpacing = 10×2 = 20;totalCellSize = 1000 − 20 = 980;
    // 980 / 3 = 326 余 2,余数前置 → [327, 327, 326]
    assertEquals(
        listOf(327, 327, 326),
        OhosGridCells.Fixed(3)
            .calculateCrossAxisCellSizes(availableSizePx = 1000, spacingPx = 10)
    )
}

行为用例层——span 序列 [1,2,1,2] 的折行断言成两行布局、BoxGrid 槽位越界计 skipped、-1 填满语义,对齐上游 SequentialGridMeasurePolicy / BoxGridMeasurePolicy 的行为。边界层——0 items、0 格(防御分支全计 skipped)、无约束主轴(INFINITY 包裹)等退化输入不崩不挂。41 个用例全绿才敢说"移植算法与上游数学一致"——只看 Demo 出图不算验收,图能看只证明没糊,不证明和上游逐位一致。

6.3 HAP 验证

:grid:build: BUILD SUCCESSFUL(41 个 jvmTest 全绿 + ohosArm64/ohosX64 KLIB)
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(三件套自动拷入 entry/libs)
nm -gU(双 ABI): T GridSolve / T GridVersion / libohosgrid_symbols 均为全局符号
BuildNativeWithCmake: finished(双 ABI 编出 libgrid_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 7.5 MB,内含双 ABI 各三件套(libgrid_napi.so / libohosgrid.so / libc++_shared.so,六 so);KN so debug 带符号 arm64 约 8.5MB、x86_64 约 7.7MB(含 Kotlin/Native 运行时)。SignHap 因 signingConfigs 留空而跳过是设计如此,DevEco 自动签名回填后产出 signed HAP,真机(arm64)安装运行成功。

6.4 功能用例

  1. 启动后版本徽标显示 2.9.0-ohos.1(来自 GridVersion() 真实 Native 调用,非静态文字);
  2. 默认参数(sequential / fixed(4) / spacing 8/8 / fill / itemCount 12)下渲染区出 4 列网格,状态行 cells=4;
  3. 切 orientation,折行方向旋转、行/列语义互换;
  4. cells 五种策略逐一切换:crossAxisCells 数组随之变化——fixed(3) 出 3 个数、adaptive 随宽度变格数、track 出 [定宽, 加权…]、responsive 拖动可用宽过阈值切换 wide/narrow;
  5. 排列切 spaceBetween / spaceAround / spaceEvenly:分别首尾贴边、每行两侧留半隙、两端也有隙;
  6. mainAxisSize 调小(varySizes 开)后切九宫格 contentAlignment,item 在槽位内按九宫格移动;
  7. spacing 数字框调整,行距/列距实时变化、容器尺寸同步涨;
  8. fill 关闭后格子不再铺满交叉轴、容器收缩;
  9. span 模式四档:全 1 顺序折行;每 3 跨 2 折行断点按 span 前进;首项跨满经两阶段求解横跨整行;交替 1/2/3 中超总格数的项进 skipped;
  10. itemCount 滑杆 0-100,placed/skipped 联动,0 项时容器仍出尺寸;
  11. box 模式 12 个预设槽位按 row/column/rowSpan/columnSpan 摆放,row=99 项不出现、skipped +1;
  12. onAreaChange 拖窗/旋转后 availableWidth 回写、整页自动重排;
  13. 每次求解状态行刷新 solveMicros(通常个位数微秒),原始 JSON 折叠区可核对请求/响应原文。

6.5 验证结论

栅格数学移植模型、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的求解会话:版本握手、五种策略、span 折行、六排列、九宫格对齐、skipped 语义与响应式重排全部联动(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/gridlayout-compose.git
cd gridlayout-compose
export JAVA_HOME="/path/to/jdk-21"
./gradlew :grid:build                                                            # 41 项交叉验证 + 双 KLIB
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
~/ohos/command-line-tools/bin/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.gridlayout -m entry
hdc shell hilog | grep grid_napi

八、遗留问题与改进方向

8.1 踩坑复盘

#现象原因处理
1Kotlin 2.2.21 线配上游 Gradle wrapper 9.5.0 同步直接失败旧线 Kotlin 插件与新版 Gradle 不兼容降 wrapper 到 8.14.1
2配置期 Unresolved reference: ohosArm64eazytec 双仓没置前,插件从官方仓库解析两个 eazytec 仓库写到 pluginManagement / dependencyResolutionManagement 最前
3ohos 编译爆满屏 Unresolved reference: Modifier / @Composable上游代码整体依赖 composegit mv 到 composeMain 隔离;绝不能删(上游快照 + 未来接回入口)
4ArkTS import 'libohosgrid.so' 拿不到 GridSolveKN so 没有 NAPI 注册,@CName 只是 C ABI必须经 CMake NAPI 封装层 libgrid_napi.so
5Native 侧内存持续增长 / 崩溃KN 返回字符串的内存归 KN 管必须经 libohosgrid_symbols()->DisposeString 归还,N-API 层不能 free()
6hvigor 打包报 00303038signingConfigs 缺 material 被 schema 拒绝(仓库不提交签名材料)留空数组占位,DevEco 自动签名回填
7安装失败 9568347HAP 缺当前 ABI 的 soabiFilters 双 ABI;每 ABI 三件套(libgrid_napi + libohosgrid + libc++_shared)缺一不可
8ArkTS 编译错误 10505001@State size 与组件内置属性冲突改名 cellSize
9span 模式「首项跨满」直接解出全 skipped求解器按上游语义跳过超出总格数的 spanDemo 两阶段求解:先解一次拿 crossAxisCells 数量,再按该数量重解

8.2 已知问题

  • 几何桥不跑 Compose UI:ArkTS 拿到 frame 后用 Stack + position 自行渲染;@Composable API(VerticalGrid 等)仍在 composeMain 不参与编译;
  • 排列 6 种 / 对齐九宫格为移植子集:上游 Arrangement / Alignment 可传任意自定义 lambda,桥只透出枚举命名的那部分;
  • RTL 未移植,按 LTR 求解:响应里的 x 坐标始终从左上角起算;
  • Modifier.fillMaxMainAxisSize 未移植:item 只支持「给定的主轴尺寸」或「填满交叉轴(-1)」;
  • ExtendedGridCells 的 Lazy 变体(GridCells / StaggeredGridCells)未移植:桥的 track / responsive 已覆盖上游非懒语义;
  • 上游 Paparazzi 快照测试留盘不运行:grid/src/androidHostTest/ 完整保留但不在本 fork 构建里;
  • 无 Android target,本 fork 不发 Maven Central:apiDump / 二进制兼容校验等上游 CI 面不适用;
  • 上游进维护模式仍收 bug 修复:fork 侧同步路径是「上游补丁 → 对照改 ohos 模型 → 41 用例跑绿 → 必要时加回归用例」。

8.3 未来优化方向

  • CMP 接回计划:等社区 CMP 与 compose.ui ohos 制品对齐到上游所需版本,把 composeMain 接回 commonMain,VerticalGrid 直接在 Compose 里跑,几何桥降级为非 Compose 场景的 fallback;
  • 评估 .har / ohpm 形态发布(libgrid_napi.so + 类型包发 ohpm 三方库、klib 发 har),让业务侧免于自建 CMake 桥;
  • 在 CI 中加入 ohos 编译、ELF 符号检查(nm -gU 断言 GridSolve / GridVersion)和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本(hdc shell snapshot_display);
  • 排列/对齐子集按需扩充(如带权重的自定义分布),字段演进只动 example 与 MiniJson,不动 GridSolve 签名。

九、总结

9.1 核心难点回顾

上游 gridlayout-compose 2.9.0(17 个源文件,100% 依赖 compose)
    → 查重 + 依赖面盘点(compose 壳 vs 整数数学的分离点)
    → 工具链对齐 HarmonyOS Kotlin 2.2.21-1.0.0(Gradle 降到 8.14.1,eazytec 双仓置前)
    → 根构建替换(cmp_qrose 模式,ohos_gridlayout)
    → git mv 拆 composeMain(上游快照保留不编译)
    → ohos/ 5 文件逐行移植 MeasurePolicy 数学(零外部依赖)
    → 41 项 JVM 交叉验证(期望值手算自上游公式)
    → C ABI(@CName × 2)+ 几何 JSON(vp 进 px 出)
    → C++ NAPI(DisposeString 纪律)+ CMake $ORIGIN 三件套
    → ArkTS Stack/position 渲染 + 两阶段首项跨满
    → ArkUI 真机单页验收(五种策略 / span / 排列 / 对齐 / skipped / 响应式重排)

9.2 封装层次

  • 算法层:上游格宽分配、折行、排列、对齐、BoxGrid 摆放的逐行移植(ohos/ 5 文件,纯 Kotlin);
  • 快照层:上游 17 个 Compose 源文件 git mv 隔离在 composeMain,不编译、绝不删;
  • Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、组装请求、按 frame 渲染色块、状态行与 JSON 折叠区;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 载荷形态跟着库的产物走:同一条"逻辑编译到 ohos + 桥 + ArkTS 渲染"的路上,qrose 过 PNG、Vico 过图表数据、Reorderable 过列表状态,布局库过几何 frame——布局的产物本来就是坐标,硬套光栅化等于丢掉可交互性;路线选型先问"这个库对外输出的本质是什么";
  2. 隔离优于删改:编不过的源码 git mv 进独立源码集保留快照,比删除干净得多——它既是回归对账的锚点,也是工具链对齐后的接回入口;配套纪律是"桥不进库的 public API",JSON 协议只活在 example,未来才拆得掉;
  3. 手算期望值是移植正确性的唯一可信证明:41 个用例的期望值全部先按上游公式算出再断言,“跑出来什么记什么"的测试只能证明没崩、不能证明没漂;配上"两处实现决策单列成文”,让偏离可审计、让一致可验证。

9.4 适配成果

当前适配已完成:

  • gridlayout-compose 2.9.0(上游维护模式)测量/排布算法的纯 Kotlin 移植(ohos/ 5 文件,五种单元格策略 + 折行 + span + 六排列 + 九宫格 + BoxGrid 摆放,上游算法语义零改动);
  • 上游 17 个 Compose 源文件的 composeMain 隔离保留(cmp_qrose 审核模式,git mv 快照);
  • 双 ABI Kotlin/Native 编译::grid 出 ohosArm64 / ohosX64 KLIB,example/nativeApp 出 libohosgrid.so(arm64 约 8.5MB / x86_64 约 7.7MB,debug 带符号)+ libc++_shared.so 自动随行;
  • 2 个 C ABI 符号和 C++ NAPI 包装层(nm -gU 双 ABI 验证 T GridSolve / T GridVersion / libohosgrid_symbols 全局符号);
  • 41 项 JVM 交叉验证全绿(期望值手算自上游公式:Fixed(3)/10/1000 → [327,327,326]、Adaptive(240)/24/1080 → [252×4]、Track[288,W1,W2]/1080 → [288,264,528]);
  • DevEco Stage ArkUI 单页验收页面(约 690 行:版本徽标、五种策略、四种 span 模式、双轴排列、九宫格、fill/varySizes、box 12 槽位含 row=99 越界、响应式重排、状态行与 JSON 折叠区);
  • HAP 构建(双 ABI 六 so,约 7.5MB)、DevEco 自动签名、真机(arm64)安装运行验证与效果图;
  • 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

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

更多推荐