开源鸿蒙平台 CMP 三方库「gridlayout-compose」适配全流程
本文记录
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 侧用 ArkUIStack+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:
@ComposableAPI(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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Homebrew openjdk@21) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(:grid 为 KLIB,example/nativeApp 为 sharedLib) |
| Gradle | 8.14.1(上游 wrapper 9.5.0 必须降,2.2.21 旧线撑不住 9.x) |
| 外部依赖 | 无(库模块纯 Kotlin;桥不引 kotlinx.serialization) |
| DevEco product | hvigor modelVersion 5.1.0、compatibleSdkVersion 5.1.0(18) |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.gridlayout(vendor oh-tpc,versionName 2.9.0-ohos.1) |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 源码集 | 文件数 | 依赖形态 | 处置 |
|---|---|---|---|
上游 commonMain | 17 | @Composable / Modifier / MeasurePolicy / Arrangement / Alignment,仅依赖 compose runtime / foundation / ui-util | git mv 到 composeMain 隔离(不编译,绝不删) |
上游 androidHostTest | 若干 | Paparazzi 快照 + Android 宿主 | 留盘不进构建 |
新增 commonMain/.../ohos/ | 5 | 纯 Kotlin(kotlin-stdlib + kotlin.math + kotlin.time) | 整体编译(jvm + 双 ohos) |
新增 commonTest/.../ohos/ | 3 | kotlin-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.kt | Start / End / Center / SpaceBetween / SpaceAround / SpaceEvenly 六种分布 |
OhosAlignment.kt | 九宫格对齐偏移计算 |
OhosGridSolver.kt | solveSequential(顺序折行)+ 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):
| 字段 | 类型 | 说明 |
|---|---|---|
kind | string | sequential(默认)或 box |
orientation | string | vertical(默认)或 horizontal |
cells | object | 单元格策略,五种 type 见下;缺省 fixed(4, fill) |
rows | object | 仅 box:行策略(对应上游 BoxGrid(rows=)) |
mainAxisArrangement / crossAxisArrangement | string | 各 6 种:start/end/center/spaceBetween/spaceAround/spaceEvenly |
mainAxisSpacing / crossAxisSpacing | number | 间距,vp,默认 0 |
contentAlignment | string | 九宫格(如 TopStart / Center / BottomEnd) |
density | number | 屏幕密度,必须 > 0;所有 vp 尺寸乘它转 px |
availableWidth / availableHeight | number | vp。主轴 ≤ 0 视为无约束(包裹内容) |
items | array | sequential 每项 { 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 方法 | 覆盖内核 |
|---|---|---|
GridSolve | gridSolve(req) | 五种 cells 策略 + 折行 + span + 六排列 + 九宫格 + BoxGrid 摆放 |
GridVersion | gridVersion() | 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/wrapper | 8.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.kt | 2 个 C ABI、vp→px、solveMicros、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面(约 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 取整) |
| Native | GridSolve / GridVersion | C ABI JSON 导出 |
| N-API | gridSolve(req) / gridVersion() | 向 ArkTS 暴露能力 |
| ArkTS | solve() | 组装请求、触发求解、按 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/columnSpan | items[].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 个测试文件):
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
OhosGridCellsTest | 13 | 五种策略的格宽数学:余数前置、Adaptive 定格数、FixedSize withSpacing、Track 差值取整、Responsive 断点 |
OhosArrangementTest | 14 | 六种排列的间隙公式(分母 n−1 / n / n+1)与零项/单项退化 |
OhosGridSolverTest | 14 | sequential 折行 / 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 功能用例
- 启动后版本徽标显示
2.9.0-ohos.1(来自GridVersion()真实 Native 调用,非静态文字); - 默认参数(sequential / fixed(4) / spacing 8/8 / fill / itemCount 12)下渲染区出 4 列网格,状态行
cells=4; - 切 orientation,折行方向旋转、行/列语义互换;
- cells 五种策略逐一切换:
crossAxisCells数组随之变化——fixed(3) 出 3 个数、adaptive 随宽度变格数、track 出[定宽, 加权…]、responsive 拖动可用宽过阈值切换 wide/narrow; - 排列切 spaceBetween / spaceAround / spaceEvenly:分别首尾贴边、每行两侧留半隙、两端也有隙;
mainAxisSize调小(varySizes 开)后切九宫格 contentAlignment,item 在槽位内按九宫格移动;- spacing 数字框调整,行距/列距实时变化、容器尺寸同步涨;
- fill 关闭后格子不再铺满交叉轴、容器收缩;
- span 模式四档:全 1 顺序折行;每 3 跨 2 折行断点按 span 前进;首项跨满经两阶段求解横跨整行;交替 1/2/3 中超总格数的项进 skipped;
- itemCount 滑杆 0-100,placed/skipped 联动,0 项时容器仍出尺寸;
- box 模式 12 个预设槽位按 row/column/rowSpan/columnSpan 摆放,
row=99项不出现、skipped +1; onAreaChange拖窗/旋转后 availableWidth 回写、整页自动重排;- 每次求解状态行刷新
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 踩坑复盘
| # | 现象 | 原因 | 处理 |
|---|---|---|---|
| 1 | Kotlin 2.2.21 线配上游 Gradle wrapper 9.5.0 同步直接失败 | 旧线 Kotlin 插件与新版 Gradle 不兼容 | 降 wrapper 到 8.14.1 |
| 2 | 配置期 Unresolved reference: ohosArm64 | eazytec 双仓没置前,插件从官方仓库解析 | 两个 eazytec 仓库写到 pluginManagement / dependencyResolutionManagement 最前 |
| 3 | ohos 编译爆满屏 Unresolved reference: Modifier / @Composable | 上游代码整体依赖 compose | git mv 到 composeMain 隔离;绝不能删(上游快照 + 未来接回入口) |
| 4 | ArkTS import 'libohosgrid.so' 拿不到 GridSolve | KN so 没有 NAPI 注册,@CName 只是 C ABI | 必须经 CMake NAPI 封装层 libgrid_napi.so |
| 5 | Native 侧内存持续增长 / 崩溃 | KN 返回字符串的内存归 KN 管 | 必须经 libohosgrid_symbols()->DisposeString 归还,N-API 层不能 free() |
| 6 | hvigor 打包报 00303038 | signingConfigs 缺 material 被 schema 拒绝(仓库不提交签名材料) | 留空数组占位,DevEco 自动签名回填 |
| 7 | 安装失败 9568347 | HAP 缺当前 ABI 的 so | abiFilters 双 ABI;每 ABI 三件套(libgrid_napi + libohosgrid + libc++_shared)缺一不可 |
| 8 | ArkTS 编译错误 10505001 | @State size 与组件内置属性冲突 | 改名 cellSize |
| 9 | span 模式「首项跨满」直接解出全 skipped | 求解器按上游语义跳过超出总格数的 span | Demo 两阶段求解:先解一次拿 crossAxisCells 数量,再按该数量重解 |
8.2 已知问题
- 几何桥不跑 Compose UI:ArkTS 拿到 frame 后用
Stack+position自行渲染;@ComposableAPI(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.uiohos 制品对齐到上游所需版本,把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 三条经验
- 载荷形态跟着库的产物走:同一条"逻辑编译到 ohos + 桥 + ArkTS 渲染"的路上,qrose 过 PNG、Vico 过图表数据、Reorderable 过列表状态,布局库过几何 frame——布局的产物本来就是坐标,硬套光栅化等于丢掉可交互性;路线选型先问"这个库对外输出的本质是什么";
- 隔离优于删改:编不过的源码
git mv进独立源码集保留快照,比删除干净得多——它既是回归对账的锚点,也是工具链对齐后的接回入口;配套纪律是"桥不进库的 public API",JSON 协议只活在 example,未来才拆得掉; - 手算期望值是移植正确性的唯一可信证明: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/ohosX64KLIB,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。
参考文档
更多推荐

所有评论(0)