本文记录 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 钉死本机路径)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(上游 wrapper 9.5.0 降级;2.2.21 线不支持 Gradle 9.x)
库依赖commonMain 零第三方依赖(Compose 依赖已随 UI 层隔离)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.aspecto
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
文件行数依赖形态处置
AspectoRowCalculator.kt292仅 kotlin.math.abs,零 Compose原样编译(一行不动)
AspectoRow.kt35@Stable 注解 + 引用 LayoutInfo去 @Stable,internal→public
AspectoLayoutInfo.kt138@Composable content + @Stable + Scope拆两半:纯模型进 commonMain,Compose 半边随 composeMain
AspectoGrid.kt195androidx.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 方法覆盖内核
AspectoLayoutaspectoLayout(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.tomlKotlin 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.kt1 个 C ABI、校验复刻上游 require、MiniJson、错误通道
nativeApp/.../MiniJson.kt零依赖 JSON(kotlin.math 替代 java.lang.Math,KN 兼容)
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 四 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 半边)
NativeAspectoLayoutC ABI JSON 导出(唯一符号)
N-APIaspectoLayout(req)向 ArkTS 暴露能力
ArkTSgenerate()组请求、刷新 @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 功能用例

  1. 启动即首屏计算,基础网格 Tab 默认"随机"数据集出图,状态行显示 行数 · 条目数 · 总高 · 耗时(模拟器实测约 0.2~0.3ms/次);
  2. 切"1:1"数据集:每行 4~5 块等宽正方形;切"照片"数据集:4:3 / 3:4 / 16:9 混排,块上比例数值与肉眼形状一致——宽高比保持成立;
  3. 切"竖幅"数据集:行被钳制,无一行高耸出屏——0.5×max / 0.75×max 保护区间可见;
  4. 参数调节 Tab 把 maxRowHeight 拉到 150:所有行明显变矮、超 max 行压到约 112vp;条目间距拉大:条目变窄、每行条目数变少;
  5. 装饰行 Tab 设"每 2 行":黑色横幅出现在第 0、2、4…行前;开头部 → 首行是 header;尾部 index 999 → 最后一行是 footer(越界追加语义);
  6. 分页追加 Tab 连点"追加 20 条":列表不跳动,只追加末尾新行,状态行条目数递增而耗时仍在亚毫秒级;
  7. 重复 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 踩坑复盘

  1. Kotlin 成员函数遮蔽同包顶层同名函数 → 原生栈溢出:object Aspecto { fun calculateRows(...) = calculateRows(...) } 里右侧调用绑定到对象自己,无限递归;栈耗尽后连异常都抛不出,桥的 try/catch 接不住,模拟器上表现为启动即 SIGSEGV(SEGV_ACCERR),faultlog 同一 pc 重复数百帧。修复用同包 import ... calculateRows as computeRows 别名委托,并补"直调入口"回归测试。教训:JVM 测试全绿 ≠ 桥的调用路径被覆盖——测试此前全部直调顶层函数,入口对象是盲区;
  2. Kotlin/Native 没有 java.lang.Math,Math.floor/abs 编不过,用 kotlin.math.floor/abs/round;
  3. ArkTS arkts-no-untyped-obj-literals:qrose 单字面量直传 JSON.stringify 能过,map 回调返回字面量不行——请求体必须声明 RequestItem / LayoutRequest 接口;
  4. 上游 Gradle 9.5.0 wrapper 与 HarmonyOS Kotlin 2.2.21 线插件不兼容,必须降 8.14.1(wrapper 三件套从已审仓库拷贝);
  5. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 最前,否则配置期 Unresolved reference: ohosArm64;
  6. 上游 androidApp / shared / benchmark* 模块引用已剥离的 AGP / Compose 插件,include 进来 sync 必炸——留盘但绝不进 settings.gradle.kts;
  7. .gitignore 的 */build 只盖两层深度,example/nativeApp/build 构建产物会混进提交——改用 build/(任意深度);
  8. HAP 缺当前 ABI 的 so 报安装失败 9568347,abiFilters 双 ABI、每 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. ohos 目标没有测试运行器,根构建全局禁用 Ohos*Test* 任务,测试统一 JVM 跑;
  11. 先跑 Gradle link(回填 so)再开 DevEco 构建 HAP,顺序反了 CMake 直接 FATAL_ERROR;
  12. 手写 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 三条经验

  1. UI 库适配的正确切口是分层审计:先盘源码依赖度——AspectoRowCalculator 零 Compose 依赖、UI 依赖全部集中在一个胶水文件,这个事实决定了"算法移植 + ArkUI 渲染"路线。qrose(内核提取)、KStore(整库编译)、Aspecto(算法/UI 分层)三个库三种路线,共同点都是先看依赖在哪一层断开,再定边界,而不是先选路线再改代码;
  2. 工具链版本跟着依赖走,没有诉求就用最保守已审线:Aspecto 零第三方依赖,2.2.21 + Gradle 8.14.1 一次到位;KStore 因为 kotlinx 制品线必须上 2.4.20 + Gradle 9.x。两条线经验互斥,抄错线就是配置期失败;
  3. 公共入口必须有直调回归测试:本次最大的坑不在鸿蒙也不在 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。

参考文档

Logo

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

更多推荐