开源鸿蒙平台 CMP 三方库「PhosphorIcon-compose」适配全流程
本文记录
PhosphorIcon-compose接入 OpenHarmony 的完整过程,覆盖图标库盘点、Compose 源集隔离、纯 Kotlin 数据层机械转码、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 图标浏览器、HAP 构建和真机验收。
PhosphorIcon-compose是把完整 Phosphor 图标集(6 种字重、9073 个图标)发布为 ComposeImageVector扩展属性的 Compose Multiplatform 图标库:PhIcons.Bold.AcornBold一行取图,Icon(imageVector, tint, modifier.size)一行渲染,thin 8f / light 12f / regular 16f / bold 24f 四档描边字重加 filled 纯填充与 duotone 双层(0.2 透明度阴影层 + 16f 描边轮廓层)。本次适配与社区已有路线(qrose 的"零依赖内核提取"、KStore 的"kotlinx 制品线真编译")又不一样:图标库的有效载荷不是算法而是九千多个文件里的几何数据本身,且全部以 Compose 构建器 DSL 书写、离开androidx.compose.ui无法编译。因此适配的核心是一次可重放、可对账的机械转码——上游文件一字节不动地隔离到composeMain(cmp_qrose 审核模式),平行的纯 KotlinPhIconSpec数据层(SVG path 字符串 + 描边/填充属性)编译进 OpenHarmony 动态库,ArkTS 通过 NAPI 按名取用任意图标并用Path2D矢量渲染。
项目地址: AtomGit/oh-tpc/PhosphorIcon-compose.git
上游仓库: dev778g-me/PhosphorIcon-compose
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
PhosphorIcon-compose 为 Compose 应用提供"图标"这一件事的完整答案:6 个字重模块(thin / light / regular / bold / filled / duotone)各含约 1512 个图标文件,phosphor-core 提供空对象命名空间 PhIcons,library 聚合发布为 8 个 Maven 工件(io.github.dev778g-me:phosphoricon-compose:1.0.5)。每个图标是一个惰性 val PhIcons.<Weight>.<Name>: ImageVector,几何以 path(fill/stroke 参数) { moveTo/curveTo/arcTo/… } 的构建器 DSL 书写,全部落在 256×256 视窗(唯一例外 duotone 的 WandSparkle 为 30×30)。KMP 项目把这些放在 commonMain,Android、iOS、桌面、js、wasmJs 共享。OpenHarmony 应用同样需要这套图标能力,但上游构建(Kotlin 2.3.20 / Compose 1.10.3 / AGP 9.0.0 / Gradle 9.1.0)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示几个写死的图标,却无法证明 9073 个图标的几何数据在 OpenHarmony ARM64 设备上完成编译、链接和按名取用。适配需要同时验证数据层、注册表、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 制品。 |
| 工具链不一致 | 上游 Kotlin 2.3.20 / Gradle 9.1.0 / AGP 9.0.0(com.android.kotlin.multiplatform.library 新插件)与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 数据与 Compose 深度耦合 | 9073 个文件全部 import androidx.compose.ui.graphics.*,几何以构建器 DSL 书写——库的"有效载荷"本身离开 Compose 编译不了,这是与算法类库(qrose)本质不同的难点。 |
| 渲染语义要跨桥 | 描边宽度/圆头/圆角连接、duotone 的 0.2 阴影层、26 处真实 ARGB 颜色、非 256 视窗,都要在 ArkTS 侧等价呈现。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:上游文件零改动的 composeMain 隔离、机械转码生成的 PhIconSpec 数据层 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。图标几何完全由上游原版数据负责(逐字转码、可重放对账),ArkTS 只负责调用 Native、解析 JSON 和矢量绘制。
1.2 库提供的能力
Demo 提供一个真实的图标数据会话,对应桥接的全部入口:
- 总览(overview):
phosphorIconsSpec('{}')返回六字重各自的图标数与总数 9073——来自真实 Native 调用而非静态文字; - 名称表(names):
{"weight":"bold"}返回该字重全部 1512 个图标名,支撑列表与搜索; - 单图标(spec):
{"weight":"bold","name":"AcornBold"}返回完整几何:viewport、每条 path 的 SVG 命令串、fill/stroke 开关、颜色、透明度、描边宽度与圆头属性; - 渲染闭环:ArkTS
Path2D+Canvas按 viewport 等比缩放绘制,duotone 阴影层自动垫底、描边层在上,矢量缩放无损。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
total / weights[] / count / names[] | 总览与名称表载荷。 |
viewportWidth / viewportHeight / defaultWidth / defaultHeight | 坐标系与默认尺寸(全库唯一非 256 视窗是 duotone 的 WandSparkle=30)。 |
paths[].d | SVG path 命令串,new Path2D(d) 直接可用。 |
paths[].fill/stroke* | 填充/描边开关、#RRGGBB 颜色、有效透明度(ARGB alpha 字节已折算,duotone 阴影即黑 × 0.2)、描边宽度、cap/join。 |
数据侧完整能力(六字重 9073 图标、32491 条 path、约 17.8 万条命令调用的全部几何与绘制属性)都在生成的 commonMain 数据层里随编译进入 .so,JVM 侧保留 5 项 jvmTest 交叉验证用例加"9073 文件全量重放解析",证明 ohos .so 里执行的图标数据没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 9073 个图标文件 git mv 到 composeMain,零字节改动(rename 100% 相似度);转码生成的 *Spec.kt 与上游文件一一同名对应、同包同目录,是"数据没漂"的对账单位。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,7 个库模块全目标编译,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);library / composeApp / androidApp / iosApp 留盘不进构建。 |
| 桥接稳定 | 单个 C ABI(@CName("PhosphorIconsSpec"))+ UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 数据层纯 Kotlin 零第三方依赖(连 kotlinx.serialization 都不进 KN 二进制,手写 MiniJson);工具链钉 HarmonyOS Kotlin 2.2.21-1.0.0 与已审仓库同线。 |
| UI 完整 | 单页覆盖全部桥接入口:六字重 chips(带计数)、搜索、网格分页、详情着色与缩放。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit,全仓不出现任何第三方镜像站字样。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。图标库本身不碰 UI,适配面天然落在数据层;ArkTS Demo 只是把桥暴露的几何数据画出来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与命令词汇表、确认数据形态、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、wrapper 降级 8.14.1
第 3 阶段:数据层转码 ── 严格行级状态机、19 种命令映射、颜色/画笔语义、分块注册表
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 六字重 chips、搜索、Path2D 网格、着色与缩放详情
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
phosphor-core/ PhIcons 命名空间(上游原样)+ PhosphorSpec.kt 数据模型(新增)
phosphor-{thin,light,regular,bold,filled,duotone}/
src/commonMain/ 生成的 <Icon>Spec.kt × N + <Weight>Specs.kt 注册表(进 ohos 构建)
src/composeMain/ 上游 ImageVector 文件 × N(git mv 零改动,未注册源集不编译)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
tools/transform_icons.py 一次性转码脚本(fail-loud、可重放)+ spec_stats.json 统计
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份,CN 版 29 节)
README.upstream.md 上游原文保存
library/ composeApp/ androidApp/ iosApp/ 上游遗留,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include 7 个库模块与 :example:nativeApp,rootProject.name = "ohos_phosphoricon_compose"。上游的 AGP / Compose / hot-reload / vanniktech 发布插件、TYPESAFE_PROJECT_ACCESSORS 预览、foojay 工具链解析器全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Temurin jdk-21.0.12.101-hotspot) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 9.1.0 降级;2.2.21 线不支持 Gradle 9.x,qrose 同款处置) |
| 第三方依赖 | 零(数据层与桥均纯 Kotlin/KN 标准库) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.phosphor |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点(本适配的成败手)
先对 9073 个文件做全仓命令词汇表扫描,确认转码器的覆盖面是闭合的:
| 调查项 | 结果 |
|---|---|
| path 命令变体 | 19 种(moveTo/lineTo/horizontal/vertical/curve/reflectiveCurve/quad 系各绝对相对 + arcTo 系 + close),约 17.8 万次调用 |
path(...) 参数键 | 仅 7 个:fill / fillAlpha / stroke / strokeAlpha / strokeLineWidth / strokeLineCap / strokeLineJoin(无渐变、无 trimPath) |
| 画笔省略 | 34 个 path 只有描边参数无 stroke 画笔(全部在 WandSparkle),0 个完全裸 path() |
| 真实颜色 | 26 处 SolidColor(Color(0x…))(Plugs 家族 6 字重、HighHeel、WandSparkle),其余全为 Color.Black |
| 视窗 | 9073 个 256×256,唯一例外 WandSparkle 30×30 |
| 上游瑕疵 | regular 混入 AirplaneInFlightDuotone(1513 之源)、filled 缺 BeachBall(1511 之源)——原样保留保证可对账 |
| 模块 | 文件数 | 依赖形态 | 处置 |
|---|---|---|---|
phosphor-core | 1 | 空对象命名空间,纯 Kotlin | 原样编译 + 新增数据模型 |
| 6 个字重模块 | 1511–1513/个 | 全部 import androidx.compose.ui.graphics.* | git mv 到 composeMain + 生成数据孪生 |
library | 0 源码 | 纯 Maven 聚合发布壳 | 留盘不进构建 |
composeApp / androidApp / iosApp | — | Compose Demo / AGP / Xcode | 留盘不进构建 |
三个关键结论:其一,几何数据虽与 Compose 耦合,但形态高度规整(一行一命令、参数顺序固定、浮点带 f 后缀),机械转码可行且可用"行级白名单"做到零猜测;其二,描边参数(宽度/圆头/圆角连接)是字重语义的载体,必须逐 path 保留;其三,duotone 的阴影层靠 fillAlpha = 0.2 表达,跨桥时要作为独立透明度而非颜色混合。
1.4 页面边界
标题区 PHOSPHOR ICONS · OPENHARMONY / 副题(Kotlin/Native PhIconSpec → NAPI → ArkTS Path2D)
字重区 6 个 chips:thin(1512) / light(1512) / regular(1513) / bold(1512) / filled(1511) / duotone(1513)
搜索区 图标名搜索框(如 Acorn / Arrow / Logo)
状态行 当前置重 · 数量 · 总数(随切换刷新)
详情面板 大图预览(40–160 滑杆)、7 色着色板(含深底白)、viewport/path 数元信息、关闭
网格区 每页 48 个 Canvas + Path2D 实时绘制,"显示更多"步进 96
页面启动时先调 phosphorIconsSpec('{}') 拿六字重计数做握手,再加载默认字重 bold 的名称表。
第 2 阶段:目标与依赖打通
2.1 上游构建的三个暗坑
第一个坑:上游仓库没有提交 gradle.properties——.gitignore 里赫然一行 gradle.properties,克隆后既无 JVM 参数也无版本号,构建不可复现。处置:从 .gitignore 删除该行并提交完整文件(JDK 路径、konan 开关、version=1.0.5-ohos.1)。
第二个坑:上游 Gradle 9.1.0 wrapper 与 HarmonyOS Kotlin 2.2.21-1.0.0 不兼容(2.2.21 线最高支持 Gradle 8.x)。处置:wrapper 四件套(jar / gradlew / gradlew.bat / properties)整体替换为 8.14.1——与 qrose 审核仓库同款。
第三个坑:settings.gradle.kts 里的 foojay resolver 插件会联网解析工具链。处置:删除,org.gradle.java.home 本地钉死 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()
}
}
rootProject.name = "ohos_phosphoricon_compose"
include(":phosphor-core")
include(":phosphor-thin")
include(":phosphor-light")
include(":phosphor-regular")
include(":phosphor-bold")
include(":phosphor-filled")
include(":phosphor-duotone")
include(":example:nativeApp")
2.2 模块构建收敛
gradle/libs.versions.toml 收敛为两行(只留 Kotlin 插件),每个字重模块的构建文件收敛为纯 KMP:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {
api(project(":phosphor-core"))
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
phosphor-core 同构(无项目依赖)。实测:
./gradlew compileKotlinJvm
# 7 个模块 BUILD SUCCESSFUL
上游文件一个没编译——它们已整体迁往 composeMain;commonMain 里跑的是新生成的数据层。
2.3 composeMain 隔离(cmp_qrose 模式的核心)
composeMain 不是 Kotlin 源集,Kotlin 编译器不注册它,目录只是"存在"——这是 qrose 审核通过的 Painter 隔离手法在 9073 个文件上的批量应用:git mv phosphor-<w>/src/commonMain/.../<w> phosphor-<w>/src/composeMain/.../<w>,rename 100% 相似度,将来社区 CMP 与上游版本对齐后可整目录搬回恢复 Compose API。
第 3 阶段:数据层转码(技术难点)
3.1 数据模型
phosphor-core 新增全库唯一的手写数据模型(上游 PhIcons.kt 原样不动):
class PhPathSpec(
val d: String, // SVG path 命令串
val fill: Boolean = false,
val fillColor: Long = 0xFF000000L, // ARGB,默认黑
val fillAlpha: Float = 1f,
val stroke: Boolean = false,
val strokeColor: Long = 0xFF000000L,
val strokeAlpha: Float = 1f,
val strokeWidth: Float = 0f, // viewport 坐标系单位
val strokeLineCap: String = "butt",
val strokeLineJoin: String = "miter",
)
class PhIconSpec(
val name: String,
val viewportWidth: Float = 256f, // 全库唯一例外:WandSparkle = 30f
val viewportHeight: Float = 256f,
val defaultWidth: Float = 24f,
val defaultHeight: Float = 24f,
val paths: List<PhPathSpec>,
)
默认值即上游主流形态,生成的文件只写非默认参数,一个图标约 20 行、和上游文件并排可读。
3.2 严格行级状态机(fail-loud)
tools/transform_icons.py(Python 3,零第三方依赖)按 idle → path_args → path_body 三态扫描每个文件,任何未识别的行、参数键、命令、颜色表达式都会 FAIL: <file>:<line> 中止整批迁移——绝不全量静默跳过。两遍执行:先解析全部 6 模块 9073 文件(零失败才继续),再统一 git mv + 生成,避免半迁移状态。
命令映射表(19 种变体,覆盖全仓约 17.8 万次调用):
| Compose PathBuilder | SVG | 说明 |
|---|---|---|
moveTo / moveToRelative | M / m | 2 参 |
lineTo / lineToRelative | L / l | 2 参 |
horizontalLineTo(Relative) | H / h | 1 参 |
verticalLineTo(Relative) | V / v | 1 参 |
curveTo(Relative) | C / c | 6 参 |
reflectiveCurveTo(Relative) | S / s | 4 参 |
quadTo(Relative) | Q / q | 4 参 |
arcTo(Relative) | A / a | rx ry theta isMoreThanHalf isPositiveArc x y → rx ry theta large sweep x y |
close | Z | — |
校验清单:属性名 = 文件名、名称唯一、path 非空、d 字符集白名单、Builder name 与属性一致、包名一致。数字保真:浮点去 f 后按"整数化 + 6 位小数截断"输出,256 视窗下误差远低于亚像素。
3.3 画笔省略与颜色语义
34 个 path(全部在 WandSparkle 的点阵)只有 strokeLineWidth/strokeCap 没有 stroke = SolidColor(...) 画笔——按 Compose/SVG 默认语义补黑色描边,数量计入 spec_stats.json 统计而非静默。26 处真实颜色(如 Color(0x40F7F8F8))按 ARGB long 原值保留,桥出口统一折算为 #RRGGBB + 有效透明度(0x40F7F8F8 → #F7F8F8 @ 0.251),ArkTS 无需理解 ARGB。
3.4 分块注册表
每字重一个 <Weight>Specs.kt,约 1512 个条目拆成 12 块 × 128 条的私有函数,规避 JVM 单方法 64KB 字节码上限;by lazy 首访问才物化:
object BoldSpecs {
val names: List<String> get() = Registry.map.keys.toList()
fun specFor(name: String): PhIconSpec? = Registry.map[name]
}
private object Registry {
val map: Map<String, PhIconSpec> by lazy {
buildMap { chunk0(this); chunk1(this); /* …12 块 */ }
}
private fun chunk0(m: MutableMap<String, PhIconSpec>) {
m["AcornBold"] = PhIcons.Bold.AcornBoldSpec
// …
}
}
实测转码输出:thin 1512/5859 path、light 1512/5859、regular 1513/5865、bold 1512/5741、filled 1511/1547、duotone 1513/7620,合计 9073 图标 / 32491 path / d 字符串 2.18MB。对 composeMain 原件重放解析 9073/9073 通过——这是"数据没漂"的第一道闸门。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libphosphor_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosphor.so)
│ PhosphorIconsSpec
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;把渲染参数塞进协议又会让桥随 UI 需求膨胀,所以协议只认 weight + name 两个键,桥把上游数据模型的几何面原样送过去。
4.2 Kotlin/Native 导出函数
@CName("PhosphorIconsSpec")
fun phosphorIconsSpec(request: String): String // 总览 / 名称表 / 单图标 一口进
| 请求 | 响应 |
|---|---|
{} | {"ok":true,"total":9073,"weights":[{"weight":"thin","count":1512},…]} |
{"weight":"bold"} | {"ok":true,"weight":"bold","count":1512,"names":["AcornBold",…]} |
{"weight":"bold","name":"AcornBold"} | {"ok":true,…,"paths":[{"d":"M 216 112 …","stroke":true,"strokeWidth":24,"cap":"round",…}]} |
| 任意失败 | {"ok":false,"error":"icon 'Xxx' not found in bold (1512 icons)"} |
六字重分发用 weightEntries 表驱动;桥内零可变共享状态,注册表读取只读,N-API 侧可并发调用。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"phosphorIconsSpec", nullptr, NapiPhosphorIconsSpec, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosphor_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosphor_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与一个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohosphor SHARED IMPORTED)
set_target_properties(ohosphor PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(phosphor_napi SHARED napi_init.cpp)
target_link_libraries(phosphor_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosphor)
OHOS_ARCH 为空或 libohosphor.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const phosphorIconsSpec: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 启动握手与按需取图
aboutToAppear(): void {
const overview = JSON.parse(phosphorIconsSpec('{}')) as OverviewDto;
this.weights = overview.weights ?? []; // 六字重计数握手
this.loadNames(this.currentWeight); // 默认 bold 名称表
}
private fetchSpec(weight: string, name: string): SpecDto | null {
const key = `${weight}/${name}`;
const cached = specCache.get(key);
if (cached !== undefined) {
return cached; // 命中即不再过桥
}
const resp = JSON.parse(phosphorIconsSpec(JSON.stringify({ weight, name }))) as SpecDto;
if (resp.ok) {
specCache.set(key, resp);
return resp;
}
return null;
}
5.2 Path2D 矢量绘制
function drawSpec(ctx: CanvasRenderingContext2D, spec: SpecDto, tint: string): void {
ctx.clearRect(0, 0, ctx.width, ctx.height);
const scale = Math.min(ctx.width / (spec.viewportWidth ?? 256),
ctx.height / (spec.viewportHeight ?? 256));
ctx.save();
ctx.scale(scale, scale);
for (const p of spec.paths ?? []) {
const path = new Path2D(p.d);
if (p.fill) { ctx.globalAlpha = p.fillAlpha; ctx.fillStyle = tint || p.fillColor; ctx.fill(path); }
if (p.stroke) {
ctx.globalAlpha = p.strokeAlpha; ctx.strokeStyle = tint || p.strokeColor;
ctx.lineWidth = p.strokeWidth; ctx.lineCap = …; ctx.lineJoin = …;
ctx.stroke(path);
}
}
ctx.globalAlpha = 1;
ctx.restore();
}
fill 段先画(duotone 阴影天然垫底)、stroke 段后画;globalAlpha 逐段设置、结束复位;着色 = 覆盖颜色、保留透明度。网格每格一个 36×36 Canvas,48/页分页 + "显示更多"步进 96,specCache 保证同图标只过一次桥。
5.3 详情面板与对比度
点网格任意图标展开详情:大图预览(40–160 滑杆,@Watch 触发重绘)、7 色着色板(最后一色深底白图标,验证对比度场景)、viewport 与 path 数元信息。duotone 的 WandSparkle 是专项用例——30×30 视窗应等比放大不出框。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosphor)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName × 1 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.phosphor、versionName 1.0.5-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/libohosphor_api.h
├── src/main/cpp/types/libphosphor_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 图标浏览器
6.2 构建、签名和安装
./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 中勾选自动生成,证书不进入仓库(仓库内 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.phosphor -m entry
四、完整代码对照
4.1 整体架构
com.phosphor.icons(phosphor-core,上游 PhIcons 原样 + 新增 PhIconSpec 模型)
│
com.phosphor.icons.<weight>(6 个字重模块)
│ <Icon>Spec.kt × 9073 + <Weight>Specs.kt 注册表(分块 buildMap)
Bridge.kt + MiniJson(example/nativeApp)
│ PhosphorIconsSpec
libphosphor_napi.so
│ phosphorIconsSpec
Index.ets
│ JSON parse + Path2D + Canvas
▼
ArkUI 真机页面(单页滚动图标浏览器)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_phosphoricon_compose、遗留模块移出 |
gradle.properties | 上游从未提交,本 fork 新增:JDK 21 钉死、konan 开关、version=1.0.5-ohos.1 |
gradle/libs.versions.toml | 只剩 Kotlin 2.2.21-1.0.0 插件别名 |
build.gradle.kts(根) | group 沿用上游、版本 1.0.5-ohos.1、全局禁用 Ohos*Test* 任务 |
phosphor-*/build.gradle.kts × 7 | 纯 KMP:jvm + ohosArm64 + ohosX64,字重模块 api 依赖 core |
phosphor-core/.../PhosphorSpec.kt | 全库唯一手写模型:PhIconSpec / PhPathSpec |
tools/transform_icons.py | 严格转码器:两遍执行、19 命令映射、白名单校验、fail-loud |
phosphor-*/src/commonMain/.../*Spec.kt | 9073 个数据孪生 + 6 个注册表(生成) |
phosphor-*/src/composeMain/... | 9073 个上游文件(git mv,rename 100%) |
example/nativeApp/.../Bridge.kt | 1 个 C ABI、表驱动六字重分发、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 图标浏览器 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(29 节,5000+ 字) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 数据层 | PhIcons.Bold.AcornBoldSpec | 单图标几何(与上游属性一一对应的扩展属性) |
| 数据层 | BoldSpecs.names / specFor(name) | 字重注册表(分块 buildMap、惰性物化) |
| 模型 | PhIconSpec / PhPathSpec | viewport、d 串、fill/stroke 全属性 |
| Native | PhosphorIconsSpec | C ABI JSON 导出(总览/名称表/单图标三形态) |
| N-API | phosphorIconsSpec(request) | 向 ArkTS 暴露能力 |
| ArkTS | fetchSpec(weight, name) | 带缓存的按名取图 |
| ArkTS | drawSpec(ctx, spec, tint) | viewport 等比缩放 + Path2D 矢量绘制 |
| 上游等价 | Icon(imageVector = PhIcons.Bold.AcornBold, tint, modifier.size) | tint → 色板、size → Canvas 尺寸,语义对齐 |
4.4 ArkTS 与 Kotlin 的边界
const spec = JSON.parse(phosphorIconsSpec(JSON.stringify({
weight: 'duotone', name: 'AcornDuotone'
}))) as SpecDto;
// spec.paths[0] = 阴影层 { fill: true, fillAlpha: 0.2, d: 'M 216 112 …' }
// spec.paths[1] = 轮廓层 { stroke: true, strokeWidth: 16, cap: 'round', … }
drawSpec(ctx, spec, '#2563EB');
val entry = weightEntries.firstOrNull { it.key == weightKey }
?: return error("unknown weight '$weightKey' …")
val spec = entry.spec(name)
?: return error("icon '$name' not found in ${entry.key} (${entry.count} icons)")
specResponse(entry, spec)
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:跨界传矢量 path 而不是像素
qrose 在 KN 侧把条码光栅化成 PNG 跨界,因为条形码是方块填充几何,软件光栅器容易写。图标是描边贝塞尔路径——stroke-to-fill 在没有 skiko 的 Kotlin/Native 里不现实;而 ArkUI Canvas 原生支持 stroke/lineCap/lineJoin。所以桥把矢量模型(d 串 + 绘制属性)原样送过去:单图标约 1–4KB JSON,分辨率无关,duotone 透明度与圆头描边语义无损。同一条 cmp_qrose 路线上的"像素版"与"矢量版"分化,由载荷几何性质决定。
决策 2:数据孪生一一对应 + 分块注册表
每个上游文件生成同名 *Spec.kt(AcornBold.kt → AcornBoldSpec.kt),同包同目录,审核时可逐文件对账;注册表键 = 上游属性名。1512 个条目拆 128/块的私有函数,规避 JVM 单方法 64KB 字节码上限——这是 9073 文件规模下容易被忽略的工程细节。
决策 3:composeMain 隔离,上游零改动
不重写、不提取、不打桩(shim 会与 jvm 侧真实 Compose 同包名冲突)。git mv 保证 rename 100%,git diff 里上游内容零变化;上游瑕疵(regular 混入 duotone 命名、filled 缺图标)原样保留——对账能力优先于"顺手修正"。
决策 4:工具线选 2.2.21 + Gradle 8.14.1(而不是 2.4.20 + 9.x)
KStore 适配时因为依赖 kotlinx ohos 制品线(stdlib 2.4.20 元数据)被迫升 Kotlin 2.4.20 线、Gradle 留 9.x。本库数据层零第三方依赖,没有制品线牵引,直接沿用社区最早批准、qrose 已验证的 2.2.21-1.0.0 + 8.14.1 组合——两条工具链线的选择标准(依赖牵引 vs 稳定优先)在这个系列适配里形成了完整对照。
决策 5:JVM 交叉验证 + 全量重放
数据层在 commonMain,JVM 与 ohos 跑同一份代码;5 项 jvmTest(bold 的 Acorn 三 path 结构/24f 圆头/注册表 1512 全量,duotone 的 0.2 阴影层顺序/16f 轮廓/WandSparkle 30 视窗/注册表 1513 全量)跑的就是转码产物。加上转码器对 composeMain 原件的 9073/9073 重放解析,"数据没漂"有两级证明。
六、测试与验证
6.1 测试环境
本次验证使用 Windows 11(x64)、Temurin JDK 21(jdk-21.0.12.101-hotspot)、konan 预置 kotlin-native-prebuilt-windows-x86_64-2.2.21-1.0.0 与 ohos sysroot、DevEco Studio 5.1.0 Release 与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :phosphor-bold:jvmTest :phosphor-duotone:jvmTest
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
BoldSpecsTest | 2 | Acorn 三 path 结构、24f 圆头圆角描边、256 视窗、注册表 1512 且键键可取 |
DuotoneSpecsTest | 3 | 阴影层 fillAlpha=0.2 且垫底、轮廓 16f 圆头、WandSparkle 30×30 视窗与 4f 描边、注册表 1513 |
加上转码器重放解析(对 composeMain 原件再跑一遍解析器):9073/9073 通过,19 种命令变体、7 个参数键、34 个省画笔 path、26 处 ARGB 颜色全部按白名单消化,无一静默跳过。
6.3 HAP 验证
7 × compileKotlinJvm: BUILD SUCCESSFUL
compileKotlinOhosArm64 / OhosX64(7 模块): BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}
导出符号: libohosphor.so 双 ABI 内 PhosphorIconsSpec 均命中
BuildNativeWithCmake: finished(双 ABI 编出 libphosphor_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully / start ability successfully
libohosphor.so debugShared 未 strip 时 arm64 约 23.9MB、x86_64 约 29.9MB(含 Kotlin/Native 运行时与 2.18MB 图标数据常量;HAP 打包侧会做 strip)。
6.4 功能用例
- 启动后六字重 chips 显示 thin(1512)/light(1512)/regular(1513)/bold(1512)/filled(1511)/duotone(1513),总数 9073——来自真实 Native 调用;
- 搜索
Acorn网格收窄为橡子系列,每格为独立 Canvas + Path2D 实时绘制; - 切 duotone 可见 20% 阴影层与描边轮廓两层叠加;切 bold 描边明显粗于 regular(24f vs 16f),thin 最细(8f);
- 详情面板 7 色着色,duotone 阴影透明度不随换色丢失;末位色板为深底白图标;
- 预览滑杆 40→160,矢量缩放边缘无锯齿;
- 打开 duotone 的
WandSparkle(30×30 视窗)等比放大不出框; - "显示更多"按 96 步进扩展网格,滚动流畅(specCache 命中不再过桥);
- 拼错的图标名返回
ok:false+ 人可读错误,页面状态行提示。
6.5 验证结论
图标数据层、注册表、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的图标数据会话:六字重计数握手、按名检索、矢量绘制、着色与无损缩放(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/PhosphorIcon-compose.git
cd PhosphorIcon-compose
export JAVA_HOME="/path/to/jdk-21"
./gradlew compileKotlinJvm # 7 模块交叉编译
./gradlew :phosphor-bold:jvmTest :phosphor-duotone:jvmTest # 5 项交叉验证
./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.phosphor -m entry
hdc shell hilog | grep Phosphor
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让 7 个库模块真正编译成
ohosArm64/ohosX64制品、9073 个图标数据进.so; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 上游
.gitignore里有一行gradle.properties——仓库从未提交过该文件,克隆后构建不可复现;这类"上游就不在版本控制里的构建文件"是 fork 时最容易漏检的项; - 上游 Gradle 9.1.0 与 HarmonyOS Kotlin 2.2.21 线不兼容,wrapper 四件套必须整体降到 8.14.1;只改
distributionUrl不换 jar/脚本会留下版本错配; - foojay resolver 插件会在配置期联网解析工具链,与"离线可复现"目标冲突,删除并用
org.gradle.java.home本地钉死; - 转码器对
path(fill = SolidColor(Color.Black)) {单行形态的首次切片会多留一个右括号——fail-loud 策略让它在第一个文件就暴露;如果当时选择"容错跳过",9073 个文件里混进一个静默坏图将无法定位; - 34 个省略描边画笔的 path(WandSparkle 点阵)按 Compose/SVG 默认语义补黑,计数入统计——语义默认值必须显式决策并留痕,不能靠"恰好没炸";
- ArkTS 里
LineCap/LineJoin枚举名在 API 18 SDK 中不存在(canvas 的lineCap/lineJoin是'butt'|'round'|'square'字符串联合类型),用字面量三元赋值即可,import 枚举必炸Cannot find name; @Prop命名不能撞组件通用属性:DetailCanvas的size属性与size()属性方法冲突报not assignable to the same property in base type 'CustomComponent',改名previewSize解决;Circle().fill().stroke()画色板会触发"fill/stroke API supported since 26.0.0"兼容性告警(当前 compatible 5.1.0(18)),换成Column + backgroundColor + border零告警;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free();9073 文件规模的git add还会把 Python 重放验证产生的__pycache__带进提交,.gitignore 要补__pycache__/与*.pyc。
8.2 已知问题
- 上游 Compose API(
PhIcons.X.Y: ImageVector)在鸿蒙仍不可用,直到社区 CMP 与上游 Compose 版本对齐;届时composeMain整目录搬回即可恢复; libohosphor.sodebug 版 23.9–29.9MB(未 strip + 2.18MB 数据常量 + KN 运行时);发布需补linkReleaseSharedOhos*+ strip 链;- 名称表 JSON(1512 名 ≈ 25KB)一次性返回后本地缓存,未做分段拉取;
- 上游生成数据的既有瑕疵(regular 混入
AirplaneInFlightDuotone、filled 缺BeachBall、thin/regular 用BookUser而 bold 用BookOpenUser)原样保留,保证与上游一一对应可 diff; - 上游
.github/workflows/library-publish-ci.yml(Maven Central 发布)留盘未动,但本 fork 已剥离发布配置,该流水线不会再成功; - HAP 安装必须使用与 bundle name
org.terminator.ohos.phosphor匹配的签名。
8.3 未来优化方向
- 补
linkReleaseSharedOhos*+ strip 发布链,收敛.so体积; - 桥增加"按关键字批量取 spec"入口,减少网格首屏逐图标过桥的往返;
- 生成器直读上游 SVG 源(phosphoricons 官方仓库)做三方对账(Kotlin 源 ↔ SVG 源 ↔ Spec 产物);
- 在 CI 中加入 ohos 编译、ELF 符号检查、HAP 构建与转码重放校验;
- 评估把
PhIconSpec数据层作为specMain源集反哺上游 PR,让上游天然获得非 Compose 消费面。
九、总结
9.1 核心难点回顾
上游 9073 个 Compose ImageVector 文件
→ 全仓命令词汇表盘点(19 命令 / 7 参数键 / 26 颜色 / 34 省画笔)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 降 8.14.1,qrose 同线)
→ 根构建替换(cmp_qrose 模式,遗留模块留盘)
→ composeMain 隔离(git mv,rename 100%)+ 机械转码 PhIconSpec 数据层
→ 分块注册表 + 5 项 JVM 交叉验证 + 9073 文件重放解析
→ C ABI(@CName × 1,三形态 JSON 协议)
→ C++ N-API(DisposeString 纪律)
→ ArkTS Path2D 矢量绘制
→ ArkUI 真机单页验收(六字重 / 搜索 / 着色 / 无损缩放)
9.2 封装层次
- 数据层:上游几何逐字转码的
PhIconSpec/PhPathSpec(9073 图标 / 32491 path); - 注册层:六字重
<Weight>Specs分块 buildMap、惰性物化、键键可查; - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出三形态 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、specCache、viewport 等比缩放、Path2D 矢量绘制与着色;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 数据型库的适配答案是"可重放转码"而不是"重写":算法库(qrose)把内核留在 commonMain 即可,制品线补齐的库(KStore)直接真编译整库,而几何数据与框架 DSL 深度耦合的库,正确做法是把数据机械萃取成平行数据层——上游一字节不动、产物一一对应、重跑脚本即对账,三类库三种路线,判断依据是"载荷的形态";
- fail-loud 是大规模机械迁移的生命线:9073 个文件、17.8 万条命令调用里任何一个意外形态,都必须让整批迁移在第一时间中止并给出 file:line——容错跳过等于把坏数据埋进 .so,真机上表现为"某个图标画不出来"时已无法定位;
- 协议跟着载荷性质走,不跟着先例走:qrose 传 PNG 是因为条形码是填充方块、KN 侧光栅化便宜;图标是描边贝塞尔,stroke-to-fill 无 skiko 不现实而 ArkUI Canvas 原生支持 stroke——同一审核模式下的"像素版"与"矢量版"分化,省掉的是一整套软件光栅器,换来的是分辨率无关与 KB 级载荷。
9.4 适配成果
当前适配已完成:
- PhosphorIcon-compose
1.0.5全部 9073 个图标(6 字重、32491 path、约 17.8 万条命令)的双 ABI Kotlin/Native 编译,上游文件零改动(git mv rename 100%); - 严格转码器
tools/transform_icons.py(两遍执行、19 命令映射、白名单校验、fail-loud)+ 转码统计spec_stats.json,对上游原件重放解析 9073/9073 通过; - 1 个 C ABI 符号和 C++ N-API 包装层(双 ABI
.so内导出符号验证命中); - DevEco Stage ArkUI 单页验收页面(六字重计数握手、搜索、Path2D 网格、7 色着色、40–160 无损缩放);
- 5 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
- AtomGit 项目文档、OpenHarmony README(中英双份,CN 版 29 节 5000+ 字)和本地效果图。
适配过程 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)