开源鸿蒙平台 CMP 三方库「Aspecto」适配全流程
本文记录
Aspecto接入 OpenHarmony 的完整过程,覆盖宽高比网格库盘点、Compose 层与算法层分离、真实行算法编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 模拟器页面、HAP 构建和模拟器验收,以及一次从 faultlog 定位到 Kotlin 作用域遮蔽引发的原生栈溢出崩溃的完整复盘。
Aspecto是 500px greedo-layout 的 Compose Multiplatform 版本:一个保持每张图片宽高比的智能网格布局库——每行放几张图、行高多少,由各条目的宽高比共同决定,行分组按评分选最优组合、行高钳制在保护区间,保证图片永不变形、宽度利用最优,并原生支持在网格中插入全宽广告/头部/尾部装饰行。本次适配与社区两条既有路线都不同:qrose 走的是"零依赖内核提取"(编码器本来就纯),KStore 走的是"整库真编译"(kotlinx ohos 制品线补齐后引擎原样编译)。Aspecto是 UI 库,它的价值一分为二:292 行纯 Kotlin 行分组算法(零 Compose 依赖)+ Compose 渲染胶水层。本次适配把边界画在算法与 UI 之间——算法层(AspectoRowCalculator、行模型、装饰拼装)原样编译成 OpenHarmony 动态库,Compose 层隔离保留不编译,ArkUI 用同一份 Kotlin 算法算出的行布局渲染网格,页面上每一个色块的宽高都与 Android/iOS 侧完全一致。
项目地址: AtomGit/oh-tpc/aspecto.git
上游仓库: vipulasri/aspecto
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
Aspecto 为 Compose 应用提供"图片流不变形"这一件事的完整答案:AspectoGrid 一个 Composable 入口,items(images, key = { it.id }, aspectRatio = { it.width / it.height }) 一行接入;内部对每个行起点枚举候选条目数,行高 = 有效宽度 / Σ宽高比,评分取 |总宽 - 有效宽| / 条目数 最优的组合;超高行钳到 0.75 × max、超矮行抬到 0.5 × max(行高保护);布局是纯函数——分页追加时整表重算但旧行 key 不变,已渲染行不失效;RowDecoration 在任意行号前插全宽装饰行,正是广告位的形态。KMP 项目把这些能力放在 commonMain,由 Android、iOS 共享。OpenHarmony 应用同样需要这套行布局能力,但上游构建(Kotlin 2.4.10 / Compose 1.11.1 / AGP 9.3.1 / Gradle 9.5.0)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS 手写分行,页面可以显示网格,却无法证明上游行分组算法在 OpenHarmony x86_64 / ARM64 设备上完成编译、链接和执行。适配需要同时验证行算法、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 产物。 |
| 工具链不一致 | 上游 Kotlin 2.4.10 / Gradle 9.5.0 / AGP 9.3.1(com.android.kotlin.multiplatform.library)与鸿蒙工具链无法共存于一个 Gradle 构建;HarmonyOS Kotlin 2.2.21-1.0.0 线最高支持 Gradle 8.x,wrapper 必须降级。 |
| Compose 断层 | 上游 UI 层依赖 Compose Multiplatform 1.11.1(Kotlin 2.4),社区鸿蒙 CMP 停在 1.9.2(Kotlin 2.2),编译器插件与运行时都对不上,AspectoGrid 无法直接编译到 ohos。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 布局语义跨不了桥 | @Composable content、LazyListState 是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:算法层的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。布局语义完全由上游原版 AspectoRowCalculator 负责,ArkTS 只负责调用 Native、解析 JSON 和用 ArkUI 渲染行布局。
1.2 库提供的能力
Demo 提供一个真实的行布局会话,对应桥接的全部入口:
- 行分组(layout):
Aspecto.calculateRows(items, width, maxRowHeight, horizontalPadding, decorations)按评分算法把条目列表切成行,每个条目回填width/height; - 行高保护(clamp):
maxRowHeight滑条调小后,超高行被压到0.75 × max、超矮行被抬到0.5 × max——上游 292 行算法的原版行为; - 装饰行(decorations):
RowDecoration(index, key)在指定行号前插全宽行,越界追加到尾部,与条目 key 冲突时快速失败; - 分页追加(append):纯函数重算——追加 20 条只新增末尾行,旧行 key 原样复用;
- 耗时观测(elapsedMs):响应携带算法纯耗时(
TimeSource.Monotonic实测),可与上游 JVM 基准数据对照。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 计算是否成功,失败时 error 带原文。 |
rowCount / totalHeight | 行数与总高(不含行间距,间距由调用方 List space 控制)。 |
elapsedMs | 纯算法耗时,模拟器上 20 条目约 0.2~0.3ms。 |
rows[].key | 行身份:首条目 key(无 key 时为数字下标),ArkTS ForEach 直接用它做增量更新。 |
rows[].fullWidth / items[] | 装饰行标记 / 条目 {key, aspectRatio, left, width, height}。 |
算法侧完整能力(贪心+评分分行、行高钳制、装饰拼装、重复 key 检查、零宽容错)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 35 项 jvmTest 交叉验证用例(上游 36 项中 3 项 Compose scope 测试随 UI 层移出,另补 2 项公共入口回归),证明 ohos .so 里执行的布局逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | AspectoRowCalculator.kt 上游原样编译、零改动;AspectoRow / AspectoLayoutInfo 仅剥离 Compose 注解与 @Composable content 半边;新增 Aspecto.kt 公共入口(算法原为 internal)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 androidApp / iosApp / shared / benchmark* 留盘不进构建。 |
| 桥接稳定 | 单个 C ABI(AspectoLayout)+ UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 库本体零第三方依赖(上游仅依赖 Compose runtime/foundation,隔离后 commonMain 零依赖),不需要 ohos 制品线,Kotlin 钉社区已审的 2.2.21-1.0.0 线。 |
| UI 完整 | 单页覆盖全部算法入口:五个数据集、三个参数滑条、装饰行开关、分页追加按钮、状态行。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧的渲染由 ArkUI
List/Row/Stack完成——算法是上游原件,渲染是 ArkUI 原生;页面证明的是行分组、行高、装饰位与分页稳定性这些布局语义与上游完全一致。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与源码依赖度、划定算法/UI 边界、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 降 8.14.1
第 3 阶段:桥协议建模 ── 公共入口 Aspecto 对象、layout JSON 契约、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 四 Tab 页面、数据集切换、滑条参数、装饰行与分页演示
第 6 阶段:示例与验证 ── HAP、签名、模拟器安装、运行验收和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
aspecto/ 上游行算法库(进 ohos 构建,jvm + ohosArm64 + ohosX64)
aspecto/src/commonMain/ 算法 + 纯模型 + Aspecto 公共入口(新增 1 个文件)
aspecto/src/composeMain/ 上游 Compose 层(AspectoGrid + LayoutScope,原样保留,不编译)
aspecto/src/commonTest/ 35 项算法单测(JVM 交叉验证)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
androidApp/ iosApp/ shared/ benchmark*/ 上游工程遗留,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :aspecto 与 :example:nativeApp,rootProject.name = "ohos_aspecto"。上游的 AGP 9 / Compose Compiler / vanniktech 发布插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 钉死本机路径) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 9.5.0 降级;2.2.21 线不支持 Gradle 9.x) |
| 库依赖 | commonMain 零第三方依赖(Compose 依赖已随 UI 层隔离) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.aspecto |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 文件 | 行数 | 依赖形态 | 处置 |
|---|---|---|---|
AspectoRowCalculator.kt | 292 | 仅 kotlin.math.abs,零 Compose | 原样编译(一行不动) |
AspectoRow.kt | 35 | @Stable 注解 + 引用 LayoutInfo | 去 @Stable,internal→public |
AspectoLayoutInfo.kt | 138 | @Composable content + @Stable + Scope | 拆两半:纯模型进 commonMain,Compose 半边随 composeMain |
AspectoGrid.kt | 195 | androidx.compose.foundation.lazy.* 全套 | 整文件移入 composeMain,不编译 |
这是整个适配最重要的一张表:AspectoRowCalculator 是上游库的价值所在,而它恰好零 UI 依赖——calculateRows(items, availableWidth, maxRowHeight, horizontalPadding, decorations) 是纯函数,输入宽高比列表与宽度,输出分行结果。真正挡住 UI 库适配的是 Compose 断层(社区 CMP 1.9.2 对不上上游 1.11.1),而不是算法本身。边界画在算法/UI 之间,适配面就收敛到"补一个公共入口 + 一个桥"。
1.4 页面边界
标题区 ASPECTO · OPENHARMONY / 副题(CMP 宽高比网格 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换 基础网格 / 参数调节 / 装饰行 / 分页追加
数据集区 随机 / 1:1 / 照片 / 宽幅 / 竖幅 五种宽高比数据集(基础网格 Tab)
参数区 maxRowHeight / 条目间距 / 条目数 三个滑条(参数调节 Tab)
装饰区 每 N 行插广告滑条 + 头部/尾部开关(装饰行 Tab)
分页区 追加 20 条 / 重置 按钮(分页追加 Tab)
状态行 行数 · 条目数 · 总高 xxvp · 耗时 ms
网格区 List + Row + Stack 按行渲染彩色比例块(块上印宽高比与 key)
页面启动时 aboutToAppear 即发起首次布局计算,首屏就是默认数据集的网格。
第 2 阶段:目标与依赖打通
2.1 路线判定:为什么不像 KStore 那样整库真编译
KStore 整库真编译的前提是 kotlinx ohos 制品线补齐(coroutines / serialization / kotlinx-io 全有 ohos 变体)。Aspecto 的情况相反:它的 commonMain 依赖是 org.jetbrains.compose.runtime + org.jetbrains.compose.foundation——社区鸿蒙 CMP 是 1.9.2(Kotlin 2.2),上游是 1.11.1(Kotlin 2.4),这条制品线短期内补不齐。但库的分层救了适配:UI 依赖全部集中在 AspectoGrid.kt(195 行胶水)与 AspectoLayoutInfo.kt 的 content 半边,算法 292 行零依赖。处置:
aspecto/src/composeMain/:上游 Compose 层原样移入,不注册为源集,不编译,保留作 API 对照;aspecto/src/commonMain/:算法 + 纯模型(AspectoLayoutInfo去掉content参数与@Stable,RowDecoration去掉content)+ 新增Aspecto公共入口;- 库模块 build 收敛为纯 KMP:
jvm()+ohosArm64()+ohosX64(),commonMain 零依赖。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
库零第三方依赖,没有"依赖元数据反推 Kotlin 版本"的诉求,直接钉社区已审的 qrose 同款:HarmonyOS Kotlin 2.2.21-1.0.0。代价是 Gradle:上游 9.5.0 wrapper 与 2.2.21 线插件不兼容(配置期即失败),必须降到 qrose 验证过的 8.14.1——wrapper 三件套(jar/properties/gradlew)从已审仓库原样拷贝。JDK 钉 Temurin 21,gradle.properties 里 org.gradle.java.home 写死路径。
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_aspecto"
include(":aspecto")
include(":example:nativeApp")
2.3 编译真实算法
aspecto/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
实测:
./gradlew :aspecto:compileKotlinJvm :aspecto:compileKotlinOhosArm64 :aspecto:compileKotlinOhosX64
# BUILD SUCCESSFUL
算法文件一个没动;androidMain(空清单)、androidHostTest(Robolectric UI 测试)与 proguard 模板随 Android 目标移除,上游 git 历史即存档。
2.4 测试的处置:36 项里 3 项属于 Compose 层
上游 commonTest 有 36 项算法测试,其中 3 项测的是 AspectoLayoutScope(负/零宽高比校验、单条目 DSL)——scope 是 Compose 层类型,随 composeMain 移出;其余 33 项只做两处机械改造(删 content = @Composable {} 实参、删装饰构造的尾 lambda),断言零改动。另补 2 项直调 Aspecto 公共入口的回归测试(动机见 8.1 踩坑第 1 条),共 35 项。ohos 侧测试任务全局禁用:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
第 3 阶段:桥协议建模
3.1 JSON 契约
com.vipulasri.aspecto/**(上游算法 + 纯模型,原样编译)
↓ Aspecto.calculateRows / 评分分行 / 行高钳制 / 装饰拼装
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API aspectoLayout()
↓
ArkUI Index.ets(四 Tab)
请求与响应:
请求 : {"width":1064,"maxRowHeight":600,"horizontalPadding":4,
"items":[{"aspectRatio":1.5,"key":"item-0"}, ...],
"decorations":[{"index":3,"key":"ad-3"}]}
响应 : {"ok":true,"width":1064,"rowCount":6,"totalHeight":2413,"elapsedMs":0.21,
"rows":[{"key":"item-0","fullWidth":false,"height":320,
"items":[{"key":"item-0","aspectRatio":1.5,"left":0,
"width":480,"height":320}, ...]}, ...]}
{"ok":false,"error":"..."}
三个设计:width 单位由调用方定(Demo 用 vp,ArkUI 数字单位即 vp,桥不做 px/vp 换算);rows[].key 有 key 条目输出字符串、无 key 条目输出数字下标(行 key = 首条目 key,与上游 LazyColumn 语义一致,ArkTS ForEach 拿它做增量复用);装饰行 fullWidth=true 且 items=[]、height=0——装饰高度上游也由 Composable 决定,鸿蒙侧由 ArkUI 自定(Demo 56/64vp)。
3.2 公共入口:上游算法是 internal
上游 calculateRows 与 AspectoRow 都是 internal,桥在另一个模块必须走公开 API。处置与 qrose 给 internal 编码器补 Qrose 入口同款——新增 Aspecto 入口对象,AspectoRow internal→public:
// aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/Aspecto.kt
import com.vipulasri.aspecto.calculateRows as computeRows // 别名是防递归的,见 8.1
object Aspecto {
fun calculateRows(
items: List<AspectoLayoutInfo>,
availableWidth: Int,
maxRowHeight: Int = DEFAULT_MAX_ROW_HEIGHT_PX,
horizontalPadding: Int = 0,
decorations: List<RowDecoration> = emptyList()
): List<AspectoRow> = computeRows(items, availableWidth, maxRowHeight,
horizontalPadding, decorations)
}
桥内校验复刻上游 AspectoLayoutScope 的 require 语义:width > 0、maxRowHeight > 0、horizontalPadding >= 0、每条 aspectRatio > 0(报错文案与上游同义);重复 key 由算法内部的 check 抛出,被桥整体捕获。
3.3 错误通道
导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("AspectoLayout")
fun aspectoLayout(request: String): String = try {
render(request)
} catch (t: Throwable) {
"""{"ok":false,"error":${MiniJson.str(t.message ?: t.toString())}}"""
}
非法 JSON、缺 items、ratio ≤ 0、重复 key,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进状态行。但要注意:try/catch 只能接住 Throwable——栈溢出这类硬信号量级故障接不住(见 8.1 第 1 条,真实踩过)。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libaspecto_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosaspecto.so)
│ AspectoLayout
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制布局语义,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("AspectoLayout")
fun aspectoLayout(request: String): String // 布局一口进,rows 全量出
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
AspectoLayout | aspectoLayout(req) | calculateRows 分行 + 行高钳制 + 尺寸回填 + 装饰拼装 + key 派生 |
单一符号、const char* → const char* 签名恒定,以后加字段(如 contentType 透传)不改签名。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"aspectoLayout", nullptr, NapiAspectoLayout, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosaspecto_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosaspecto_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),并与工具链生成的头(build/bin/<abi>/debugShared/libohosaspecto_api.h)核对过一致。
4.4 CMake 和类型声明
add_library(ohosaspecto SHARED IMPORTED)
set_target_properties(ohosaspecto PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(aspecto_napi SHARED napi_init.cpp)
target_link_libraries(aspecto_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosaspecto)
OHOS_ARCH 为空或 libohosaspecto.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const aspectoLayout: (request: string) => string;
nm_modname = "aspecto_napi" ↔ ArkTS import 'libaspecto_napi.so' ↔ index.d.ts 导出名,三名必须一致。
第 5 阶段:ArkUI 宿主
5.1 触发布局与渲染
private generate(): void {
const items: RequestItem[] = [];
ratios.forEach((r: number, i: number) => {
items.push({ aspectRatio: r, key: `item-${i}` });
});
const request: LayoutRequest = {
width: this.gridWidth(), // px2vp 屏宽 - 16
maxRowHeight: this.maxRowHeight,
horizontalPadding: this.itemPadding,
items: items,
decorations: this.currentDecorations()
};
const parsed = JSON.parse(aspectoLayout(JSON.stringify(request))) as LayoutResult;
this.rows = parsed.rows; // @State → List 增量刷新
this.status = `${parsed.rowCount} 行 · ${itemCount} 条目 · 总高 ${parsed.totalHeight}vp · ${parsed.elapsedMs}ms`;
}
渲染侧 List({ space: itemPadding }) 套 ForEach(rows, key => \${row.key}`);普通行 Row({ space })内每个条目Stack().width(item.width).height(item.height)`(数字即 vp,桥传的就是 vp);装饰行渲染全宽横幅。条目色块按 key 哈希取调色板、块上印宽高比数值——肉眼即可核对"宽高比保持"这一核心承诺。
5.2 四个 Tab 覆盖全部算法入口
| Tab | 覆盖 |
|---|---|
| 基础网格 | 五种数据集(随机/1:1/照片/宽幅/竖幅),同一算法不同输入分布 |
| 参数调节 | maxRowHeight / 条目间距 / 条目数滑条——行高保护区间(0.5×max 抬升、超 max 压 0.75×max)实时可见 |
| 装饰行 | RowDecoration 每 N 行插广告、头/尾开关、index 越界追加尾部 |
| 分页追加 | "追加 20 条"按钮——纯函数重算,列表不跳动、旧行 key 复用 |
5.3 ArkTS 严格模式的两个坑
一是 arkts-no-untyped-obj-literals:对象字面量必须对应显式声明的接口。qrose 把单字面量直接传 JSON.stringify 能过编译,但放在 map 回调里返回就不行——必须声明 RequestItem / LayoutRequest 接口并让字面量推到声明类型上。二是 ForEach 的 key 生成器返回 string | number 皆可,行 key 直接 ${row.key} 模板串化,与算法派生的行身份天然对齐。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosaspecto)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/com/vipulasri/aspecto/bridge/
│ ├── Bridge.kt @CName AspectoLayout + 校验 + 错误通道
│ └── MiniJson.kt 零依赖 JSON 解析/序列化
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.aspecto、versionName 1.2.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/libohosaspecto_api.h
├── src/main/cpp/types/libaspecto_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 四 Tab 验收页面
6.2 构建、签名和安装
./gradlew :aspecto:jvmTest # 35 项交叉验证
./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 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 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.aspecto -m entry
四、完整代码对照
4.1 整体架构
com.vipulasri.aspecto/**(上游算法原样编译)
│ AspectoRowCalculator / 评分分行 / 行高钳制 / 装饰拼装
com.vipulasri.aspecto.Aspecto(新增公共入口)
│ calculateRows(items, width, maxRowHeight, padding, decorations)
Bridge.kt + MiniJson
│ AspectoLayout(JSON in / JSON out)
libaspecto_napi.so
│ aspectoLayout
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 四 Tab 页面(List/Row/Stack 渲染行布局)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_aspecto、上游 app 模块移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 + kotlin-test,仅此两项 |
build.gradle.kts(根) | 版本 1.2.0-ohos.1、全局禁用 Ohos*Test* 任务 |
aspecto/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64 |
aspecto/src/commonMain/.../Aspecto.kt | 唯一新增库源文件(公共入口,import 别名防递归) |
aspecto/src/composeMain/ | 上游 Compose 层原样保留(不编译) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、校验复刻上游 require、MiniJson、错误通道 |
nativeApp/.../MiniJson.kt | 零依赖 JSON(kotlin.math 替代 java.lang.Math,KN 兼容) |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 四 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(24 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 算法 | calculateRows(items, width, maxRowHeight, padding, decorations) | 纯函数分行(internal,上游原版) |
| 入口 | Aspecto.calculateRows(...) | 公开委托(fork 新增,桥的调用点) |
| 模型 | AspectoLayoutInfo / AspectoRow / RowDecoration | 纯条目/行/装饰模型(fork 剥离 Compose 半边) |
| Native | AspectoLayout | C ABI JSON 导出(唯一符号) |
| N-API | aspectoLayout(req) | 向 ArkTS 暴露能力 |
| ArkTS | generate() | 组请求、刷新 @State rows 与状态行 |
上游 AspectoGrid 参数与桥字段的对应:maxRowHeight: Dp = 600.dp → maxRowHeight(默认 600);itemPadding.calculateStartPadding() → horizontalPadding(上游只取 start 侧);contentPadding → 调用方从 width 先减掉;decorations: List<RowDecoration> → decorations[{index,key}](content 是 Composable,鸿蒙侧 UI 自决)。
4.4 ArkTS 与 Kotlin 的边界
const request: LayoutRequest = {
width: 1064, maxRowHeight: 600, horizontalPadding: 4,
items: [{ aspectRatio: 1.5, key: 'item-0' }, { aspectRatio: 1.0, key: 'item-1' }],
decorations: [{ index: 3, key: 'ad-3' }]
};
const res = JSON.parse(aspectoLayout(JSON.stringify(request))) as LayoutResult;
// res.rows[0].items[0] = { key:'item-0', aspectRatio:1.5, left:0, width:480, height:320 }
val rows = Aspecto.calculateRows(
items = listOf(
AspectoLayoutInfo(aspectRatio = 1.5f, key = "item-0"),
AspectoLayoutInfo(aspectRatio = 1.0f, key = "item-1")
),
availableWidth = 1064, maxRowHeight = 600, horizontalPadding = 4,
decorations = listOf(RowDecoration(index = 3, key = "ad-3"))
)
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;同一输入在 Android Compose 与鸿蒙 ArkUI 上得到同一组行。
五、关键决策说明
决策 1:算法/UI 分层移植,而不是整库真编译或 ArkTS 重写
KStore 整库编译的前提是 kotlinx ohos 制品线补齐;Aspecto 的 Compose 制品线(1.11.1)短期补不齐,但库的分层恰好把 UI 依赖全部集中在一个 195 行胶水文件里。算法 292 行零依赖原样编译,Compose 层隔离保留——这比 qrose 的"内核提取"还轻(无需提取,算法本来就纯),又守住了"不重写"的底线:页面上每个色块的宽高都是上游评分算法算出来的,不是 ArkTS 手写分行的近似。
决策 2:工具链钉 2.2.21-1.0.0 + Gradle 降 8.14.1
库零第三方依赖,无制品线版本反推诉求,直接复用 qrose 已审组合(HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21)。与 KStore 的 2.4.20/9.6.1 线互为印证:工具链版本跟着依赖走,没有依赖诉求就用最保守的已审线——两条线的经验不能互相套用(2.2.21 线必须降 Gradle,2.4.20 线可以留 9.x)。
决策 3:模型拆两半 + internal→public,上游算法文件零改动
AspectoLayoutInfo 在 commonMain 只留纯模型(ratio/key/contentType/width/height),@Composable content 半边随 composeMain;AspectoRow internal→public;新增 Aspecto 入口对象。三处都是可见性/形状调整,AspectoRowCalculator.kt 一行不动——JVM 交叉验证的有效性建立在"算法文件零改动"上。
决策 4:单一 C ABI + 手写 MiniJson
布局计算是"一进一出"的同步调用,一个 AspectoLayout(const char*) 符号 + 扁平 JSON 足够,加字段不改签名。MiniJson 手写 200 行零依赖:不为一个 6 字段协议引入 kotlinx.serialization 的 ohos 版本对齐成本(qrose 同款决策,已审)。
决策 5:jvmTest 交叉验证 + 公共入口必须直调回归
算法在 commonMain,JVM 与 ohos 跑同一份代码;35 项 jvmTest 是"算法没漂"的闸门。本次踩坑的教训写进了测试:新增的公共入口必须有直调回归测试——Aspecto.calculateRows 的递归 bug 之所以漏网,就是因为测试全部直调顶层函数、从未走过入口对象(详见 8.1 第 1 条)。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21(Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与 DevEco 模拟器(emulator 7.0.0.107(SP8DEVC00E999R4P11),x86_64)。
6.2 JVM 交叉验证
./gradlew :aspecto:jvmTest
35 项用例全部通过(33 项上游算法测试机械改造 + 2 项公共入口回归):
| 用例族 | 钉住什么 |
|---|---|
| 行分组(等比、多行、追加、删除) | 评分算法与 key 稳定性 |
| 行高钳制(min 0.5×max / 超高 0.75×max / 零宽容错) | 行高保护区间 |
| 装饰(插入位置、排序、越界追加、key 冲突快速失败) | spliceDecorations 语义 |
Aspecto 入口(委托等价、默认参数、终止性) | 公共入口无递归、默认值与 Compose 侧一致 |
6.3 HAP 验证
:aspecto:compileKotlinJvm / OhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: AspectoLayout / libohosaspecto_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libaspecto_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
debugShared 未 strip 时 KN so arm64 约 3.8MB、x86_64 约 3.5MB(含 Kotlin/Native 运行时),每个 ABI 另带 libc++_shared.so(约 1.8MB / 1.7MB)与 NAPI so 三件套,linkDebugShared* 任务 finalizedBy 拷贝任务自动回填 entry/libs/<abi>/。
6.4 功能用例
- 启动即首屏计算,基础网格 Tab 默认"随机"数据集出图,状态行显示
行数 · 条目数 · 总高 · 耗时(模拟器实测约 0.2~0.3ms/次); - 切"1:1"数据集:每行 4~5 块等宽正方形;切"照片"数据集:4:3 / 3:4 / 16:9 混排,块上比例数值与肉眼形状一致——宽高比保持成立;
- 切"竖幅"数据集:行被钳制,无一行高耸出屏——0.5×max / 0.75×max 保护区间可见;
- 参数调节 Tab 把
maxRowHeight拉到 150:所有行明显变矮、超 max 行压到约 112vp;条目间距拉大:条目变窄、每行条目数变少; - 装饰行 Tab 设"每 2 行":黑色横幅出现在第 0、2、4…行前;开头部 → 首行是 header;尾部 index 999 → 最后一行是 footer(越界追加语义);
- 分页追加 Tab 连点"追加 20 条":列表不跳动,只追加末尾新行,状态行条目数递增而耗时仍在亚毫秒级;
- 重复 key / 负 ratio 等非法输入由桥拦截,状态行显示与上游 require 同义的错误原文。
6.5 验证结论
行分组算法、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在 DevEco 模拟器(x86_64)上成功安装并进入前台,四 Tab 页面展示了真实的行布局会话:评分分行、行高保护、装饰拼装与分页稳定性(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/aspecto.git
cd aspecto
export JAVA_HOME="/path/to/jdk-21"
./gradlew :aspecto:compileKotlinOhosArm64 :aspecto:compileKotlinOhosX64 # klib
./gradlew :aspecto:jvmTest # 35 项交叉验证
./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.aspecto -m entry
hdc shell hilog | grep Aspecto
八、遗留问题与改进方向
8.1 踩坑复盘
- Kotlin 成员函数遮蔽同包顶层同名函数 → 原生栈溢出:
object Aspecto { fun calculateRows(...) = calculateRows(...) }里右侧调用绑定到对象自己,无限递归;栈耗尽后连异常都抛不出,桥的 try/catch 接不住,模拟器上表现为启动即SIGSEGV(SEGV_ACCERR),faultlog 同一 pc 重复数百帧。修复用同包import ... calculateRows as computeRows别名委托,并补"直调入口"回归测试。教训:JVM 测试全绿 ≠ 桥的调用路径被覆盖——测试此前全部直调顶层函数,入口对象是盲区; - Kotlin/Native 没有
java.lang.Math,Math.floor/abs编不过,用kotlin.math.floor/abs/round; - ArkTS
arkts-no-untyped-obj-literals:qrose 单字面量直传JSON.stringify能过,map回调返回字面量不行——请求体必须声明RequestItem/LayoutRequest接口; - 上游 Gradle 9.5.0 wrapper 与 HarmonyOS Kotlin 2.2.21 线插件不兼容,必须降 8.14.1(wrapper 三件套从已审仓库拷贝);
settings.gradle.kts里 eazytec 双仓必须排在pluginManagement最前,否则配置期Unresolved reference: ohosArm64;- 上游
androidApp/shared/benchmark*模块引用已剥离的 AGP / Compose 插件,include 进来 sync 必炸——留盘但绝不进settings.gradle.kts; .gitignore的*/build只盖两层深度,example/nativeApp/build构建产物会混进提交——改用build/(任意深度);- HAP 缺当前 ABI 的 so 报安装失败
9568347,abiFilters双 ABI、每 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - ohos 目标没有测试运行器,根构建全局禁用
Ohos*Test*任务,测试统一 JVM 跑; - 先跑 Gradle link(回填 so)再开 DevEco 构建 HAP,顺序反了 CMake 直接 FATAL_ERROR;
- 手写 C ABI 头必须与工具链生成头(
build/bin/<abi>/debugShared/*_api.h)核对符号与结构体字段序——ExportedSymbols只读前两个成员(DisposeStablePointer/DisposeString),字段序错位=调用错函数指针。
8.2 已知问题
- Compose 层(
AspectoGrid/AspectoLayoutScope)鸿蒙不可编译,等社区 CMP 1.11+ 制品线补齐后可整体回归; - 桥按调用方单位透传数值(Demo 用 vp),px/vp 换算由调用方决定;
left是逻辑偏移; - 装饰行高度算法不决定(上游亦然),桥输出
height: 0,ArkUI 自定; contentType未透传(鸿蒙 ForEach 用 key,无 LazyColumn contentType 概念);- 上游
androidHostTest(Robolectric UI 测试)与 iOS framework 构建随目标移除; - android / ios / js 目标剥离(源文件留在树里),本 fork 不发 Maven Central。
8.3 未来优化方向
linkReleaseSharedOhos*出 strip 后 release so,对比体积与耗时;- 大数据集(1 万 / 10 万条目)真机耗时采样,对照上游
benchmark-results.json(JVM 基准 p50 约 0.12ms/千条); - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(arm64)验收矩阵与自动化截图脚本;
- 评估桥协议透传
contentType与 RTL 方向标记。
九、总结
9.1 核心难点回顾
上游 aspecto(算法 292 行 + Compose 胶水 195 行)
→ 划定算法/UI 边界(Compose 层隔离 composeMain,不编译)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 降 8.14.1)
→ 根构建替换(cmp_qrose 模式)
→ ohosArm64 / ohosX64 KLIB + Aspecto 公共入口
→ C ABI(@CName × 1)+ 扁平 JSON
→ C++ N-API(DisposeString 纪律)
→ ArkTS JSON 解析(严格模式接口化)
→ ArkUI 四 Tab 模拟器验收(评分分行 / 行高保护 / 装饰 / 分页)
9.2 封装层次
- 算法层:上游原版
AspectoRowCalculator——评分分行、行高钳制、装饰拼装、key 派生; - 模型层:纯
AspectoLayoutInfo/AspectoRow/RowDecoration(fork 剥离 Compose 半边); - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、组请求、四 Tab 交互、状态行与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和模拟器运行。
9.3 三条经验
- UI 库适配的正确切口是分层审计:先盘源码依赖度——
AspectoRowCalculator零 Compose 依赖、UI 依赖全部集中在一个胶水文件,这个事实决定了"算法移植 + ArkUI 渲染"路线。qrose(内核提取)、KStore(整库编译)、Aspecto(算法/UI 分层)三个库三种路线,共同点都是先看依赖在哪一层断开,再定边界,而不是先选路线再改代码; - 工具链版本跟着依赖走,没有诉求就用最保守已审线:Aspecto 零第三方依赖,2.2.21 + Gradle 8.14.1 一次到位;KStore 因为 kotlinx 制品线必须上 2.4.20 + Gradle 9.x。两条线经验互斥,抄错线就是配置期失败;
- 公共入口必须有直调回归测试:本次最大的坑不在鸿蒙也不在 N-API,而在 Kotlin 作用域——成员遮蔽顶层同名函数的递归在 JVM 测试全绿的情况下于真机栈溢出。补上"入口直调 + 委托等价 + 终止性"两条测试后,这类 bug 在 JVM 阶段就会当场爆栈,而不是等到模拟器 faultlog 里数重复帧。
9.4 适配成果
当前适配已完成:
- Aspecto
1.2.0真实算法的双 ABI Kotlin/Native 编译(算法文件零改动,仅 1 个新增库源文件); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号,双 ABI); - DevEco Stage ArkUI 四 Tab 验收页面(五数据集、三滑条、装饰行、分页追加、状态行);
- 35 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、签名、模拟器安装和运行验证;
- 一次完整的 SIGSEGV 故障定位复盘(faultlog 取证 → 符号核对 → 作用域根因 → 别名修复 → 回归测试封口);
- AtomGit 项目文档、OpenHarmony README(中英双份)、24 节适配主文档和本地效果图。
适配过程 FAQ
以下问题来自
ohosArm64目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。
Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?
- 确认 DevEco Studio 版本与工程
build-profile.json5的compatibleSdkVersion一致; - 在
File > Settings > Build, Execution, Deployment > Ohpm配置可用的 ohpm 仓库镜像或代理; - 删除
oh_modules、oh-package-lock.json5与.hvigor缓存后重新 Sync; - 公司网络需要代理时,同时给 ohpm 与 hvigor 配置
http.proxy。
Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?
- 先在
example目录执行./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用linkDebugSharedOhosX64); - 确认
libxxx.so与libc++_shared.so已复制到ohos/entry/libs/<abi>/; - 检查
entry/build-profile.json5的abiFilters与设备 ABI 是否一致(真机arm64-v8a,模拟器x86_64); - 清理
entry/.cxx与entry/build后重试。
Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?
- 确认 CMake 的
add_library(<target> SHARED ...)名称与import ... from 'libxxx_napi.so'完全一致; - 解包 HAP,确认
libs/<abi>/libxxx_napi.so已打包; - 删除
entry/build、entry/.cxx与oh_modules后重新构建; - 检查
module.json5的srcEntry与页面路径是否正确。
Q4:真机安装报 signature verification failed 或 install failed?
- 在 DevEco Studio 的
Signing Configs配置证书、Profile 与密码; bundleName必须与签名 profile 中的 bundle name 一致;- 设备需加入 profile 的
deviceIds,调试证书有效期需覆盖当前时间; - 用
hdc list targets确认设备在线,再执行hdc install -r xxx-signed.hap。
Q5:应用启动白屏、闪退或 NAPI 调用崩溃?
- 用
hdc hilog | grep -i "napi\|kotlin"查看原生日志; - 确认
libc++_shared.so与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致; - 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用
XxxFree释放; - 在 DevEco 的 Native 调试配置中附加
lldb,定位空指针与越界访问。
Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?
- 使用鸿蒙发行版 Kotlin(如
2.2.21-1.0.0),并在settings.gradle.kts配置 eazytec 仓库; - Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
- 确认
kotlin { ohosArm64() }已应用,且local.properties的 SDK 路径有效; - 清理
~/.gradle/caches中对应版本的 Kotlin/Native 缓存后重试。
Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?
- 对齐 DevEco Studio、HarmonyOS SDK 与
build-profile.json5中的compatibleSdkVersion; - 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免
modelVersion与 IDE 不匹配; - 命令行构建时设置
DEVECO_SDK_HOME指向 DevEco 的sdk目录。
Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?
- 使用 DevEco 自带的 node 与 hvigor:
"$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...; - 设置
JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME; - 加上
--no-daemon排除守护进程缓存影响,并清理entry/build与.hvigor。
参考文档
更多推荐


所有评论(0)