本文记录 ProShape 接入 OpenHarmony 的完整过程,覆盖依赖形态盘点、适配路线抉择、几何内核提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

ProShape 是 JetBrains Compose Multiplatform 的 iOS 风格 squircle 圆角库:ProSquircleShape 用四条二次贝塞尔曲线(控制点位于包围盒角点)生成「超椭圆」轮廓,ProShape 工厂对象提供 11 个预设(全圆角 / 顶部 / 底部 / 左侧 / 右侧 / 四个单角…),Modifier.proShape() 一行裁剪。整个上游只有 3 个 Kotlin 文件、约 120 行,但 100% 站在 androidx.compose.ui(Shape / Outline / Path / Dp / Density)上。本次适配与 KStore 的「整库真编译」有本质区别:上游 API 没有一行能脱离 Compose 编译,而社区 HarmonyOS CMP(1.9.2)与上游 Compose(1.7.3)/ Kotlin(2.1.10)工具链无法共存于一个 Gradle 构建。因此走社区早期已审的 cmp_qrose「零 UI 内核提取」路线——把 createOutline 的几何数学逐字提取进 commonMain,原样编译成 OpenHarmony 动态库,ArkTS 页面经 N-API 调用内核、以软件光栅化 PNG 的方式完成渲染验收。

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

上游仓库: riadmahi/ProShape

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

ProShape 为 Compose 应用提供「iOS 观感圆角」这一件事的完整答案:ProShape.rounded(24.dp) 一行拿到 squircle 形状,clip() 或 Modifier.proShape() 直接裁剪;四角半径独立、缺省为直角、超限自动钳制到 min(宽, 高) / 2。KMP 项目把这套 API 放在 commonMain,Android、iOS 共享。OpenHarmony 应用同样需要这种「饱满连续」的转角观感,但上游构建(Compose 1.7.3 / Kotlin 2.1.10 / AGP 8.5.2)不会生成 OpenHarmony 变体。

如果只在 ArkTS 里手写一套贝塞尔圆角,页面能画出来,却无法证明上游那份数学(角的选择、钳制、曲线参数)真的运行在 OpenHarmony 设备上——写错了也看不出来。适配需要同时验证几何内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 产物。
工具链不一致上游 Compose 1.7.3 / Kotlin 2.1.10 / AGP 8.5.2 与鸿蒙工具链无法共存于一个 Gradle 构建。
依赖形态最极端三个源文件全部 import androidx.compose.ui,没有 KStore 那样的「纯 Kotlin 引擎」可直接整库编译。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
渲染语义跨不了桥createOutline 返回 Outline.Generic(path),语义是「交给 Compose 一条矢量路径」;ArkUI 没有等价的矢量路径消费口。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:squircle 几何内核的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。几何语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示 PNG。

1.2 库提供的能力

Demo 提供一个真实的渲染会话,对应桥的全部入口:

  • 预设渲染:SquirclePresets 九个预设(全圆角 / 顶部 / 底部 / 左侧 / 右侧 / 左上 / 右上 / 左下 / 右下)× 半径滑杆,页面同步显示等价的上游 API 代码(如 ProShape.bottomRounded(24.dp));
  • 尺寸定制:宽 / 高 / 半径三个滑杆实时重渲;
  • 单角定制:四角独立半径,等价上游 ProSquircleShape(topStart = …, topEnd = …, bottomEnd = …, bottomStart = …) 四参构造;
  • 对比验收:Native 光栅化的 squircle 与 ArkTS borderRadius(四分之一圆弧)同屏并排,转角曲率差异肉眼可见;
  • 版本握手:proShapeVersion() 返回 fork 版本号,来自真实 Native 调用而非静态文字。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
preset回显本次生效的预设名(或 custom)。
width / height实际渲染尺寸(px)。
bytesPNG 字节数(未压缩 stored 编码,可直接估算校验)。
pngBase64RGBA PNG 的 Base64,ArkTS 解码为 PixelMap 显示。

1.3 实现适配

维度要求
代码复用上游 3 个 Compose 文件以纯 git mv 移入 src/composeMain/,内容零改动;commonMain 新增唯一源文件 SquircleGeometry.kt(几何与上游 createOutline 逐字对应)。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample/(Android/iOS Compose 示例)留盘不进构建。
桥接稳定使用 2 个 C ABI 和扁平 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律几何内核零第三方依赖(仅 kotlin-stdlib),是这一批适配库里依赖面最小的一个。
UI 完整单页覆盖全部桥接入口:五 Tab(预设 / 定制 / 单角 / 对比 / 关于)+ 五套色板 + 实时指标行。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。ProShape 的鸿蒙侧价值在几何内核 + N-API 链路;ArkTS Demo 把桥暴露的渲染闭环画出来。

二、实现路线图

第 0 阶段:项目盘点       ── 依赖形态判定(全 Compose → 内核提取路线)、确定交付物
第 1 阶段:工具链钉版     ── eazytec Maven 置前、Kotlin 2.2.21-1.0.0、Gradle 8.14.1、JDK 21
第 2 阶段:源集拆分       ── 上游 Compose 三件套 git mv 进 composeMain(不注册、不编译)
第 3 阶段:ohos 目标      ── :proshape 出 jvm + ohosArm64 + ohosX64 KLIB
第 4 阶段:光栅化 + C ABI ── 扫描线 + 4x4 超采样 + PNG + @CName × 2
第 5 阶段:ArkUI 宿主     ── CMake NAPI、五 Tab HAP、签名、真机验收

三、逐步实现过程

第 0-1 阶段:项目初始化与工具链

1.1 工程边界
proshape/src/commonMain/            新增:SquircleGeometry + SquirclePresets + SquircleCorners(唯一新源文件)
proshape/src/composeMain/           上游 ProSquircleShape / ProShape / ModifierExt(纯 git mv,ohos 不编)
example/nativeApp/                  Kotlin/Native 动态库(Bridge + Rasterizer + PngEncoder)
example/harmonyApp/                 DevEco Stage、CMake N-API 和 ArkTS 五 Tab 页面
README.OpenHarmony.md / _CN.md      鸿蒙适配说明(中英双份)
README.upstream.md                  上游原文保存
sample/ docs/                       上游 Android/iOS 示例与文档,留盘不进 settings

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :proshape 与 :example:nativeApp,rootProject.name = "ohos_proshape"。上游的 AGP / Compose / vanniktech 发布插件与 sample/ 全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,cmp_qrose 社区已审钉法)
JDK21(org.gradle.java.home 指向 Eclipse Adoptium jdk-21.0.12.101)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 8.9 升级;2.2.21 旧线必须配 8.14.1,与 cmp_qrose 一致)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.proshape
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version

注意:gradlew 脚本自身要先找到 JVM 才能启动,gradle.properties 里的 org.gradle.java.home 是 Gradle 起来之后才生效——命令行必须显式带 JAVA_HOME。

1.3 源码依赖度盘点
模块文件数依赖形态处置
proshape3(约 120 行)100% androidx.compose.ui(geometry/graphics/unit/draw/Modifier)几何提取:数学进 commonMain,Compose 壳进 composeMain
sample/composeApp4Compose Demo + CMP 资源/字体留盘不进构建
sample/iosAppXcode 工程iOS 宿主留盘不进构建

与 KStore 的关键差异在此:KStore 的 commonMain 本来就是纯 Kotlin(有 kotlinx 制品线可依赖),可以整库真编译;ProShape 的 commonMain 连一行纯 Kotlin 都没有——ProSquircleShape.createOutline 的第一行就在用 Size / Density。所以唯一可行提取物是算法本身:把 Dp→px 换算后那段路径数学(钳制 + 九条路径命令)逐字搬进独立内核。

1.4 页面边界
标题区          PROSHAPE · OPENHARMONY / 版本握手行(native 版本 1.0.0-ohos.1)
Tab 切换        预设 / 定制 / 单角 / 对比 / 关于
预设区          九个预设芯片 + 半径滑杆 + 等价上游 API 提示行
定制区          宽 / 高 / 半径三个滑杆
单角区          左上 / 右上 / 右下 / 左下四个滑杆 + ProSquircleShape 四参提示行
对比区          Native squircle 图像 vs ArkTS borderRadius 卡片并排 + 曲率说明
色板区          五套前景/背景色一一点选
预览区          Image(PixelMap) + 状态行 + 指标行(preset / size / png 字节)
关于区          版本握手、库简介、架构分层、仓库与社区链接

页面启动时先调 proShapeVersion() 做握手,再读屏幕密度为后续 vp→px 换算做准备。

第 2 阶段:目标与依赖打通

2.1 路线判定(本适配的成败手)

ProShape 没有任何第三方制品依赖:几何内核只用到 kotlin-stdlib(编译器自带)。这意味着 KStore 式「先验证 kotlinx ohos 制品线再整库真编译」的前置条件不存在也不需要——挡路的只有 androidx.compose.ui 一个,而它没有 ohos 变体。路线因此回到社区最早过审的 cmp_qrose 内核提取模式,且是这批库里提取面最小的:上游价值全部浓缩在一个函数的路径数学里。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

零依赖库的钉版不受制品线反推约束,直接采用社区批准时间最长、先例最多的稳定钉法:HarmonyOS Kotlin 2.2.21-1.0.0(cmp_qrose 同款)。该线要求 Gradle 8.14.1(上游 wrapper 是 8.9,改 gradle-wrapper.properties 的 distributionUrl 升级即可),JDK 21。

settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

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_proshape"

include(":proshape")
include(":example:nativeApp")

两个 eazytec 仓库必须排在 gradlePluginPortal / google 之前——官方 Maven Central 的 Kotlin 没有 ohosArm64。

2.3 编译真实几何内核

proshape/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()
    ohosArm64()
    ohosX64()

    sourceSets {
        // commonMain 只装纯 Kotlin 几何内核;
        // 上游 Compose API 留在 src/composeMain(未注册源集,永不编译)
        commonMain.dependencies {}
    }
}

composeMain 不是注册源集,KGP 根本不看它——上游三件套以纯 rename 躺在里面,等社区 compose.ui ohos 制品与 CMP 对齐后一行注册即可接回。

内核是全仓唯一新增 Kotlin 源文件,几何与上游逐字对应:

// proshape/src/commonMain/kotlin/com/riadmahi/proshape/SquircleGeometry.kt
public object SquircleGeometry {

    public sealed interface PathCommand {
        public data class MoveTo(public val x: Float, public val y: Float) : PathCommand
        public data class LineTo(public val x: Float, public val y: Float) : PathCommand
        public data class QuadTo(
            public val controlX: Float, public val controlY: Float,
            public val x: Float, public val y: Float,
        ) : PathCommand
    }

    public fun squirclePath(
        width: Float, height: Float,
        radiusTopStart: Float = 0f, radiusTopEnd: Float = 0f,
        radiusBottomEnd: Float = 0f, radiusBottomStart: Float = 0f,
    ): List<PathCommand> {
        require(width >= 0f && height >= 0f) { "size must be non-negative" }

        val maxRadius = minOf(width, height) / 2f
        val rTopStart = minOf(radiusTopStart.coerceAtLeast(0f), maxRadius)
        val rTopEnd = minOf(radiusTopEnd.coerceAtLeast(0f), maxRadius)
        val rBottomEnd = minOf(radiusBottomEnd.coerceAtLeast(0f), maxRadius)
        val rBottomStart = minOf(radiusBottomStart.coerceAtLeast(0f), maxRadius)

        return listOf(
            PathCommand.MoveTo(rTopStart, 0f),
            PathCommand.LineTo(width - rTopEnd, 0f),
            PathCommand.QuadTo(width, 0f, width, rTopEnd),
            PathCommand.LineTo(width, height - rBottomEnd),
            PathCommand.QuadTo(width, height, width - rBottomEnd, height),
            PathCommand.LineTo(rBottomStart, height),
            PathCommand.QuadTo(0f, height, 0f, height - rBottomStart),
            PathCommand.LineTo(0f, rTopStart),
            PathCommand.QuadTo(0f, 0f, rTopStart, 0f),
        )
    }
}

命令顺序、钳制(min(宽,高)/2)、四角二次贝塞尔控制点(位于包围盒角点)与上游 createOutline 完全一致;SquirclePresets 的十一个函数与上游 ProShape 工厂一一对应(Float px 版),SquircleCorners 对应四参构造。实测:

./gradlew :proshape:compileKotlinJvm          # BUILD SUCCESSFUL
./gradlew :proshape:compileKotlinOhosArm64 :proshape:compileKotlinOhosX64
                                              # BUILD SUCCESSFUL
2.4 测试依赖的处置

上游 ProShape 没有测试套件,不存在 KStore 那种「turbine 无 ohos 制品」的问题;交叉验证降级为编译级(同一份 commonMain 编 jvm + 双 ohos)+ Demo 渲染验收。纪律仍然保留:根构建全局禁用 ohos 测试任务,防止未来补测试时卡死。

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

第 3 阶段:桥协议建模

3.1 JSON 契约
com.riadmahi.proshape/**(几何内核 + 预设,原上游数学)
        ↓ SquirclePresets → SquircleGeometry.squirclePath
Bridge.kt(@CName × 2,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API proShapeRender() / proShapeVersion()
        ↓
ArkUI Index.ets(五 Tab 滚动页)

请求与响应:

请求 : {"width":980,"height":560,"preset":"rounded","radius":84,
        "topStart":-1,"topEnd":-1,"bottomEnd":-1,"bottomStart":-1,
        "fill":"#7F75D8","bg":"#F3F0F9"}
响应 : {"ok":true,"preset":"rounded","width":980,"height":560,"bytes":N,"pngBase64":"..."}
       {"ok":false,"error":"..."}

三个设计要点:其一,长度全部用 px——上游用 Density 把 Dp 换算成像素,ArkTS 侧用 vp × display.getDefaultDisplaySync().densityPixels 完成同一件事,语义对齐而非单位对齐;其二,preset 字符串命中 SquirclePresets 的工厂函数(角的选择逻辑在内核里执行,ArkTS 不手写),显式四角键(topStart 等)存在时覆盖预设值,"custom" 则全靠四角键——正好对应上游「工厂函数 + 四参构造」两套 API;其三,钳制分两层:桥层 width 8..1024 / radius 0..512 / 角半径非负,几何层仍有 min(宽,高)/2 终极钳制,与上游行为一致。

3.2 载荷为什么是 PNG Base64

上游 Shape 的语义是「给渲染器一条裁剪路径」。Compose 在 Android 上由 Skia 消费这条路径,鸿蒙侧 ArkUI 没有等价的矢量路径消费口。桥的选择是把路径当场光栅化成位图:Kotlin/Native 侧扫描线填充 + 4×4 超采样(16 级边缘覆盖)+ 手写 PNG(RGBA、zlib stored 未压缩)→ Base64 → ArkTS Base64Helper → createImageSource → PixelMap → Image。这是 cmp_qrose 验证过的同一条渲染路径。

3.3 错误通道

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

@CName("ProShapeRender")
fun proShapeRender(request: String): String {
    return try {
        /* MiniJson 解析 → 预设 → 几何 → 光栅化 → PNG */
        ok(preset, image)
    } catch (t: Throwable) {
        """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
    }
}

非法 JSON、越界尺寸、坏颜色值全部走同一个 ok:false 通道,ArkTS 侧统一渲染进状态行。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libproshape_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosproshape.so)
  │ ProShapeRender / ProShapeVersion
  ▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("ProShapeRender")
fun proShapeRender(request: String): String   // 预设/定制/单角/对比 一口进

@CName("ProShapeVersion")
fun proShapeVersion(): String = "1.0.0-ohos.1"  // 版本握手
C ABI 符号ArkTS 方法覆盖内核
ProShapeRenderproShapeRender(req)预设选择 + 四角覆盖 + squirclePath 九条命令 + 光栅化 + PNG
ProShapeVersionproShapeVersion()fork 版本握手(真机版本行的数据来源)

光栅化要点:QuadTo 每条曲线 24 步 flattening 成多边形 → 逐输出行扫描线(偶奇填充)→ 每像素 4×4 子采样累计覆盖 → 按覆盖度把 fill 混合到 bg 上。一张 980×560 的图约 220 万像素 × 16 采样,真机毫秒级完成。

4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"proShapeRender", nullptr, NapiProShapeRender, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"proShapeVersion", nullptr, NapiProShapeVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosproshape_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosproshape_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号)。

4.4 CMake 和类型声明
add_library(ohosproshape SHARED IMPORTED)
set_target_properties(ohosproshape PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(proshape_napi SHARED napi_init.cpp)
target_link_libraries(proshape_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosproshape)

OHOS_ARCH 为空或 libohosproshape.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const proShapeRender: (request: string) => string;
export const proShapeVersion: () => string;

第 5 阶段:ArkUI 宿主

5.1 注入密度和触发渲染
aboutToAppear(): void {
  try {
    this.density = display.getDefaultDisplaySync().densityPixels;
  } catch (e) {
    this.density = 1;
  }
  this.version = proShapeVersion();   // 版本握手:来自真实 Native 调用
  this.generate();
}

private generate(): void {
  const d: number = this.density;
  let body: RenderRequest = {
    fill: this.fill, bg: this.bg, preset: this.preset,
    radius: Math.round(this.radius * d),
    width: Math.round(280 * d), height: Math.round(160 * d)
  };
  const raw: string = proShapeRender(JSON.stringify(body));
  const parsed: RenderOk = JSON.parse(raw) as RenderOk;
  if (!parsed.ok || parsed.pngBase64 === undefined) { /* 状态行报错 */ return; }
  this.decodePng(parsed.pngBase64);   // Base64 → PixelMap → @State 刷新
}

RenderRequest / RenderOk 都是显式声明的 interface——新版 DevEco ArkTS 编译器执行 arkts-no-untyped-obj-literals,裸对象字面量直接编译失败(见 8.1 踩坑第 3 条)。

5.2 页面交互

五个 Tab 自上而下:预设(九芯片 + 半径滑杆 + API 提示行)、定制(宽/高/半径)、单角(四滑杆 + 四参构造提示行)、对比(Native 图像 vs borderRadius 卡片并排 + 曲率说明)、关于(版本握手、架构分层、链接)。所有滑杆与色板点击都直接触发 generate(),页面是「活的」:每拖一格就是一次真实的 Native 调用 + PNG 解码。

5.3 对比度和错误边界

对比 Tab 把「这个库为什么存在」变成可见的验收点:同一半径下,左侧 Native squircle(二次贝塞尔、控制点在角点、曲率连续)与右侧系统圆角(四分之一圆弧、转角曲率突变)并排,差异一目了然。ok:false 时错误原文进状态行;Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行与 hilog。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosproshape)+ copyKnSo*
│   └── src/ohosMain/kotlin/
│       ├── Bridge.kt                         @CName × 2 + MiniJson(继承 cmp_qrose 审核模式)
│       ├── Rasterizer.kt                     曲线展开 + 扫描线 + 4x4 超采样
│       └── PngEncoder.kt                     手写 PNG(stored zlib + CRC32/Adler32)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.proshape、versionName 1.0.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/libohosproshape_api.h
        ├── src/main/cpp/types/libproshape_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 五 Tab 验收页面
6.2 构建、签名和安装
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# → 自动回填 example/harmonyApp/entry/libs/{arm64-v8a,x86_64}/ 三件套

# DevEco Studio 打开 example/harmonyApp(不要打开仓库根目录),勾选自动签名后:
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

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

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库。

四、完整代码对照

4.1 整体架构

com.riadmahi.proshape.SquircleGeometry(上游几何原样提取)
    │ squirclePath 九条命令 + min(宽,高)/2 钳制
com.riadmahi.proshape.SquirclePresets(上游工厂原样提取)
    │ rounded / topRounded / … / bottomEndRounded ×11
Bridge.kt + MiniJson
    │ ProShapeRender / ProShapeVersion
libproshape_napi.so
    │ proShapeRender / proShapeVersion
Index.ets
    │ JSON parse + PixelMap
    ▼
ArkUI 真机页面(五 Tab 滚动)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_proshape、sample 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单条目钉版
build.gradle.kts(根)版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务
proshape/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
proshape/src/commonMain/.../SquircleGeometry.kt唯一新增源文件(几何 + 预设 + 四角模型)
proshape/src/composeMain/(3 文件)上游 Compose API,纯 git mv、零改动、不编译
example/nativeApp/build.gradle.kts双 ABI sharedLib、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、MiniJson、错误通道
nativeApp/.../Rasterizer.kt曲线展开、扫描线、4x4 超采样、颜色混合
nativeApp/.../PngEncoder.ktstored zlib PNG(继承 cmp_qrose)
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、三件套
harmonyApp/.../ets/pages/Index.etsArkUI 五 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(23 节)

4.3 关键 API 对照

层次API作用
几何SquircleGeometry.squirclePath(w, h, ×4)生成九条路径命令(对应上游 createOutline)
预设SquirclePresets.topRounded(r) 等 ×11角的选择逻辑(对应上游 ProShape 工厂)
模型SquircleCorners(topStart, topEnd, bottomEnd, bottomStart)四角独立半径(对应上游四参构造)
NativeProShapeRender / ProShapeVersionC ABI JSON 导出 / 版本握手
N-APIproShapeRender(req) / proShapeVersion()向 ArkTS 暴露能力
ArkTSgenerate()组装请求并刷新 PixelMap

上游 API 与鸿蒙侧入口的一一映射:

上游(Compose)桥(ohos)
ProShape.rounded(24.dp){"preset":"rounded","radius":24×density}
ProShape.topRounded(24.dp){"preset":"topRounded","radius":…}
ProSquircleShape(topStart=…, bottomEnd=…){"preset":"custom","topStart":…,"bottomEnd":…}
Modifier.proShape(24.dp) + background(c){"preset":"rounded","radius":…,"fill":"#c"}
Dp × Density → pxvp × densityPixels → px

4.4 ArkTS 与 Kotlin 的边界

const raw: string = proShapeRender(JSON.stringify({
  fill: '#7F75D8', bg: '#F3F0F9', preset: 'bottomRounded',
  radius: Math.round(24 * d), width: Math.round(280 * d), height: Math.round(160 * d)
}));
const res = JSON.parse(raw) as RenderOk;
// res.ok === true;res.pngBase64 → PixelMap → Image 显示
val base = cornersFor(preset, radius)          // SquirclePresets 命中预设
val corners = SquircleCorners(
    topStart = cornerOverride(json, "topStart", base.topStart), // 四角键覆盖
    /* … */
)
val commands = SquircleGeometry.squirclePath(width.toFloat(), height.toFloat(),
    corners.topStart, corners.topEnd, corners.bottomEnd, corners.bottomStart)
val image = Rasterizer.render(commands, width, height, fill, bg)

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:提取几何内核,而不是整库编译或在 ArkTS 重写

上游三个文件没有一行能脱离 androidx.compose.ui 编译,「整库真编译」无从谈起;在 ArkTS 里重写贝塞尔则是白做——上游算法已经写好且语义清晰,重写没有对齐验证手段。提取内核让「角的选择、钳制、曲线参数」全部是上游原件,Demo 验收的就是「语义没漂」。

决策 2:工具链跟随 cmp_qrose 线——Kotlin 2.2.21-1.0.0、Gradle 8.14.1

零依赖库不受 kotlinx 制品线反推(KStore 因 coroutines 元数据被迫升 2.4.20 线),直接采用社区批准时间最长、先例最多的 2.2.21 钉法,Gradle 相应落在 8.14.1。两条工具链线的经验不能互相套用:2.2.21 线配 8.14.1,2.4.20 线可留 Gradle 9.x。

决策 3:根构建替换 + composeMain 纯 rename 隔离

上游根构建(AGP / Compose / vanniktech 发布)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。上游三件套以 git mv 纯重命名挪进 composeMain——git 历史显示 100% rename、零内容改动,等社区 CMP 与 compose.ui ohos 制品对齐后注册源集即可接回,光栅化降级为 fallback。

决策 4:光栅化走扫描线 + 4×4 超采样 + stored PNG

继承 cmp_qrose 的软件渲染路径并升级了边缘质量:每条二次贝塞尔 24 步展开,扫描线偶奇填充,4×4 子采样给 16 级边缘覆盖,fill 按覆盖度混合到 bg。PNG 用 zlib stored 未压缩(KN 无 java.util.zip),980×560 约 2.2 MB——Demo 可接受,且 bytes 字段可精确预估(宽×高×4 上浮 5‰),本身就是一个链路自检点。

决策 5:把「为什么要有这个库」做成对比 Tab

squircle 与普通圆角的差异在静态截图里不直观。对比 Tab 让 Native 光栅化的 squircle 与 ArkTS borderRadius 在同屏、同半径、同颜色下并排,转角曲率的连续/突变差异肉眼可见——Demo 不只验收桥,还顺带回答了「上游库存在的意义」。

六、测试与验证

6.1 测试环境

本次验证使用 Windows x64、JDK 21(Eclipse Adoptium)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64,屏幕密度 3.5)。

6.2 编译交叉验证

./gradlew :proshape:compileKotlinJvm          # 同一份 commonMain 编 JVM
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

上游无测试套件,交叉验证为编译级 + Demo 渲染验收:compileKotlinJvm、双 ABI linkDebugShared 均 BUILD SUCCESSFUL(本机全量 1m19s),KLIB 与动态库里的几何是同一份 commonMain。

6.3 产物与符号验证

linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: ProShapeRender / ProShapeVersion / libohosproshape_symbols 均为全局 T 符号(双 ABI)
copyKnSoArm64 / copyKnSoX64: 回填三件套
  arm64-v8a/libohosproshape.so 约 3.7 MB + libc++_shared.so 约 1.8 MB
  x86_64/libohosproshape.so  约 3.4 MB + libc++_shared.so 约 1.7 MB
BuildNativeWithCmake: finished(双 ABI 编出 libproshape_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL / install bundle successfully / start ability successfully

6.4 功能用例

  1. 启动后标题下方出现 native 版本 1.0.0-ohos.1(来自真实 Native 调用,非静态文字);
  2. 预设 Tab 默认全圆角 24vp 渲染成功,指标行显示实际像素与 PNG 字节数;
  3. 切换到「底部」预设,预览图变为上直角、下圆角,API 提示行同步为 ProShape.bottomRounded(24.dp);
  4. 拖动半径滑杆(8–120vp),每拖一格实时重渲,超过 min(宽,高)/2 时形状自动钳制为胶囊而不越界;
  5. 定制 Tab 调整宽(80–340vp)/ 高(60–240vp),渲染尺寸随密度换算(280×160vp → 980×560px);
  6. 单角 Tab 四滑杆独立控制四角,提示行实时显示 ProSquircleShape(topStart = …dp, …) 四参构造;
  7. 对比 Tab 同半径下,Native squircle 转角连续饱满,borderRadius 卡片转角可见曲率突变;
  8. 切换五套色板,前景/背景即时生效;真机指标行 bottomRounded 980x560 png=2195993B——字节数与 stored PNG 理论值(约 2.195 MB)吻合,链路自洽。

6.5 验证结论

几何内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的渲染会话:版本握手、九预设、尺寸/单角定制、squircle 与系统圆角对比闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/ProShape.git
cd ProShape
export JAVA_HOME="/path/to/jdk-21"
./gradlew :proshape:compileKotlinOhosArm64 :proshape:compileKotlinOhosX64       # klib
./gradlew :proshape:compileKotlinJvm                                            # JVM 交叉验证
./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.proshape -m entry
hdc shell hilog | grep ProShape

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :proshape 真正编译成 ohosArm64 / ohosX64 产物——本仓是 KLIB + 动态库两级;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 新版 DevEco ArkTS 编译器执行 arkts-no-untyped-obj-literals:JSON.stringify 的请求体若写成裸对象字面量直接 CompileArkTS 失败(Object literal must correspond to some explicitly declared class or interface)——必须声明 RenderRequest interface 并让字面量对应它;.margin({…}) 等 ArkUI 属性字面量有声明类型支撑,不受影响;
  4. display.getDefaultDisplaySync() 是可抛函数,编译器 WARN 要求特殊处理——aboutToAppear 里 try/catch 包裹,失败回退密度 1;
  5. gradlew 启动依赖命令行 JAVA_HOME,gradle.properties 的 org.gradle.java.home 来不及救;
  6. 2.2.21 旧线必须配 Gradle 8.14.1(上游 8.9 需升级 wrapper),不能套用 2.4.20 线「保留 9.x」的经验;
  7. ArkTS 不能直接 import 'libohosproshape.so'——HarmonyOS Kotlin 的 CAdapter 不把 @CName 业务符号挂到 ArkTS,必须经 CMake NAPI 包装;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347:abiFilters 双 ABI、每个 ABI 三件套(NAPI so + KN so + libc++)缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. 签名材料由 DevEco 自动写入 build-profile.json5,仓库内 signingConfigs 保持空数组,提交前防泄漏;
  11. 链接期 clang 对 CAdapter 生成代码的 api.cpp return-type 警告可忽略(社区已审仓库同款),链接不受影响。

8.2 已知问题

  • 鸿蒙侧没有 Compose Shape,出图是 KN 光栅化近似(4×4 超采样 16 级边缘),不支持渐变 Brush、描边样式;
  • PNG 用 zlib stored 未压缩,980×560 约 2.2 MB,超大画布会继续线性变大,Demo 场景可接受;
  • 上游没有测试套件,JVM 交叉验证停留在编译级,没有 KStore 那种「51 项用例零改动全绿」的运行时证据;
  • android / ios 目标与 sample/、发布、CI 工作流一并剥离(留盘),本 fork 不发 Maven Central;
  • composeMain 三件套等社区 compose.ui ohos 制品与 CMP 对齐后接回,届时光栅化降级为 fallback 路径。

8.3 未来优化方向

  • PNG IDAT 换 deflate 压缩(或直接输出 PixelMap 缓冲),把 2 MB 级降到 KB 级;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、llvm-nm 符号检查和 HAP 构建;
  • 增加模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本;
  • 内核补一套 JVM 单元测试(黄金路径命令序列 + 钳制边界),把「语义没漂」从编译级升到运行时级。

九、总结

9.1 核心难点回顾

上游 ProSquircleShape / ProShape / ModifierExt(全 Compose)
    → 依赖形态判定:零纯 Kotlin 可编 → 走 cmp_qrose 内核提取路线
    → 工具链对齐 Kotlin 2.2.21-1.0.0 / Gradle 8.14.1(cmp_qrose 已审钉法)
    → 几何 1:1 提取(squirclePath 九条命令 + SquirclePresets ×11)
    → 根构建替换 + composeMain 纯 rename 隔离
    → ohosArm64 / ohosX64 KLIB + 双 ABI 动态库
    → C ABI(@CName × 2)+ 扁平 JSON
    → C++ N-API(DisposeString 纪律)
    → KN 光栅化(扫描线 + 4x4 超采样)+ stored PNG
    → ArkTS JSON 解析 + PixelMap
    → ArkUI 真机五 Tab 验收(预设 / 定制 / 单角 / 对比 / 握手)

9.2 封装层次

  • 几何层:上游原版钳制与九条路径命令(SquircleGeometry);
  • 预设层:上游原版工厂语义(SquirclePresets ×11、SquircleCorners);
  • Native 层:双 ABI 动态库 + 2 个 C ABI JSON 导出;
  • 渲染层:曲线展开、扫描线填充、超采样混合、手写 PNG;
  • N-API 层:参数检查、字符串转换、Native 内存释放;
  • ArkTS 层:密度换算、请求组装、PixelMap 解码、五 Tab 状态管理;
  • DevEco 层:CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 适配面由依赖形态决定,不由库的大小决定:ProShape 只有 120 行却是这批库中依赖最极端的(100% compose.ui),只能走内核提取;KStore 依赖齐备就整库真编译;qrose 有纯编码器就提编码器。同一条已审路线,内核边界逐库判定;
  2. 小库更要走完整链路:上游价值浓缩在一段数学里,越是小库越容易诱惑人「ArkTS 重写一下就行」——但那样 Demo 验收的就不是上游代码。让 SquirclePresets 在 .so 里执行角的选择、bytes 与理论值吻合,才是「语义没漂」的证据;
  3. 新工具链的严格化是持续的新坑源:本次 arkts-no-untyped-obj-literals 在旧版 DevEco 不报、新版直接失败。社区先例(qrose 的内联字面量写法)不能盲抄——interface 化的对象字面量才是跨版本稳定的写法。

9.4 适配成果

当前适配已完成:

  • ProShape 1.0.0(MIT)squircle 几何内核的双 ABI Kotlin/Native 编译(上游数学零重写,唯一新增源文件 SquircleGeometry.kt);
  • 上游 Compose API 三件套以纯 git rename 保留在 composeMain,可随时接回;
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号,双 ABI 三件套 so:arm64 约 3.7 MB / x64 约 3.4 MB);
  • KN 软件光栅化(二次贝塞尔 24 步展开 + 扫描线 + 4×4 超采样)+ 手写 PNG;
  • DevEco Stage ArkUI 五 Tab 验收页面(九预设、尺寸/单角定制、squircle vs 系统圆角对比、版本握手、实时指标行);
  • JVM 编译级交叉验证 + HAP 构建、签名、设备安装和真机验证(指标行与理论值吻合);
  • AtomGit 项目文档、OpenHarmony README(中英双份)和本地效果图。

适配过程 FAQ

以下问题来自 ohosArm64 目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。

Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?

  • 确认 DevEco Studio 版本与工程 build-profile.json5 的 compatibleSdkVersion 一致;
  • 在 File > Settings > Build, Execution, Deployment > Ohpm 配置可用的 ohpm 仓库镜像或代理;
  • 删除 oh_modules、oh-package-lock.json5 与 .hvigor 缓存后重新 Sync;
  • 公司网络需要代理时,同时给 ohpm 与 hvigor 配置 http.proxy。

Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?

  • 先在 example 目录执行 ./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用 linkDebugSharedOhosX64);
  • 确认 libxxx.so 与 libc++_shared.so 已复制到 ohos/entry/libs/<abi>/;
  • 检查 entry/build-profile.json5 的 abiFilters 与设备 ABI 是否一致(真机 arm64-v8a,模拟器 x86_64);
  • 清理 entry/.cxx 与 entry/build 后重试。

Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?

  • 确认 CMake 的 add_library(<target> SHARED ...) 名称与 import ... from 'libxxx_napi.so' 完全一致;
  • 解包 HAP,确认 libs/<abi>/libxxx_napi.so 已打包;
  • 删除 entry/build、entry/.cxx 与 oh_modules 后重新构建;
  • 检查 module.json5 的 srcEntry 与页面路径是否正确。

Q4:真机安装报 signature verification failed 或 install failed?

  • 在 DevEco Studio 的 Signing Configs 配置证书、Profile 与密码;
  • bundleName 必须与签名 profile 中的 bundle name 一致;
  • 设备需加入 profile 的 deviceIds,调试证书有效期需覆盖当前时间;
  • 用 hdc list targets 确认设备在线,再执行 hdc install -r xxx-signed.hap。

Q5:应用启动白屏、闪退或 NAPI 调用崩溃?

  • 用 hdc hilog | grep -i "napi\|kotlin" 查看原生日志;
  • 确认 libc++_shared.so 与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致;
  • 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用 XxxFree 释放;
  • 在 DevEco 的 Native 调试配置中附加 lldb,定位空指针与越界访问。

Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?

  • 使用鸿蒙发行版 Kotlin(如 2.2.21-1.0.0),并在 settings.gradle.kts 配置 eazytec 仓库;
  • Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
  • 确认 kotlin { ohosArm64() } 已应用,且 local.properties 的 SDK 路径有效;
  • 清理 ~/.gradle/caches 中对应版本的 Kotlin/Native 缓存后重试。

Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?

  • 对齐 DevEco Studio、HarmonyOS SDK 与 build-profile.json5 中的 compatibleSdkVersion;
  • 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免 modelVersion 与 IDE 不匹配;
  • 命令行构建时设置 DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录。

Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?

  • 使用 DevEco 自带的 node 与 hvigor:
    "$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...;
  • 设置 JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME;
  • 加上 --no-daemon 排除守护进程缓存影响,并清理 entry/build 与 .hvigor。

参考文档

Logo

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

更多推荐