Compose Multiplatform 三方库 compose-icons(Octicons)的 OpenHarmony 鸿蒙化适配实战
Compose Multiplatform 三方库 compose-icons(Octicons)的 OpenHarmony 鸿蒙化适配实战(fill 模式图标包验证:一套 shim 平移,踩中 ArkUI 椭圆弧大坑)
库版本:compose-icons(Octicons 包,414 个图标)|验证环境:Compose Multiplatform 生态 / Kotlin 2.2.21-1.0.0(鸿蒙定制版)|DevEco Studio 26.0.0|DevEco 模拟器|HarmonyOS 7.0.0(API 26)
我之前Tabler Icons 适配验证了「shim 记录几何数据 → JSON → ArkUI Path() 渲染」这条路径的可行性,但 Tabler 是stroke 模式图标(线条描边),文末 FAQ 留了一个问题:fill 模式(实心填充)图标包怎么办? 本文就是那个问题的答案——把同一套 shim 平移到 GitHub 官方图标库 Octicons(414 个图标,16px + 24px 双尺寸),验证「数据定义类库」适配路径对填充型图标包的通用性。

结论先行:414 个 Octicons 图标源码零修改,整条链路(Kotlin/Native .so → NAPI → ArkTS → ArkUI Path() fill 渲染)最终跑通。但与 Tabler 版不同,本次踩中一个 Tabler 没暴露的大坑——ArkUI Path().commands() 不支持 SVG 椭圆弧命令 A/a,而 Octicons 几乎每个图标都靠它画圆形/圆角,导致首版渲染里所有含圆弧的图标全部变形(Search 放大镜变成实心圆)。最终在 shim 的 arcTo 出口处按 SVG 1.1 规范把椭圆弧展平成多条三次贝塞尔 C 曲线解决——上游图标源码依然零修改,改动全部收敛在 shim 一个文件里,架构的可复用性反而得到了更扎实的验证。
先睹为快:DevEco 模拟器实测,414 个 Octicons 图标(16px + 24px 双尺寸)由 Kotlin/Native 侧导出几何数据、ArkUI Path() 按 fill 模式渲染——Search 放大镜空心圆镂空、CheckCircle/PlusCircle 等圆形图标全部正确

一、适配目标与整体链路
目标:在鸿蒙模拟器里跑一个 ArkTS 应用,页面加载时真实调用 Kotlin/Native 里的 compose-icons Octicons 图标构建代码,把精选图标的几何数据(SVG path 字符串 + fill 颜色 + alpha)拉到 ArkTS,用 ArkUI Path() 组件按 fill 模式渲染成图标网格——验证同一套 shim 对填充型图标包的通用性。

整体链路(与 Tabler 版完全一致,仅数据模式不同):
ArkTS (Index.ets)
│ import icons_napi from 'libicons.so'
▼ NAPI 调用 getFeaturedIcons() / getIconCount()
libicons.so ← C++ NAPI 薄层(entry/src/main/cpp/napi_init.cpp)
│
▼ extern "C" 调用
libohosicons.so ← Kotlin/Native (ohosArm64 / ohosX64)
│
▼
octicons 模块 → compose-icons 上游源码 + 自研 androidx.compose.ui shim(零修改)
适配目标的最终效果:
*首屏:56px 图标按 4 列排布,Search 放大镜、圆形勾选/关闭图标镂空清晰,图标名与 FEATURED 列表一致*
二、Octicons 与 Tabler 的差异:为什么值得单独适配
Octicons 是 GitHub 的官方图标库,在 compose-icons 里的形态与 Tabler 相同(Kotlin 源码 + ImageVector DSL),但数据模式有三个关键差异:
| 维度 | Tabler Icons | Octicons |
|---|---|---|
| 渲染模式 | stroke(线条描边,stroke = SolidColor(...), fill = null) | fill(实心填充,fill = SolidColor(Color(0xFF000000)), stroke = null) |
| 尺寸体系 | 单一 24px viewport | 16px + 24px 双尺寸并存(Home16 / Home24) |
| 图标数量 | 185 个(当前收录) | 414 个(16/24 两版合计) |
| 几何命令 | 纯直线/贝塞尔(M/L/C/Q/Z),零 arcTo | **大量 arcTo/arcToRelative(400+ 处)**画圆形/圆角 |
这三个差异恰好覆盖了 Tabler 版 FAQ 里预留的全部扩展点:
- fill 模式:Tabler 版 ArkTS 用
stroke()+fillOpacity(0)渲染线条;Octicons 必须反过来——fill()+strokeOpacity(0)渲染实心形状。shim 的PathData早已记录了fill: Color?/fillAlpha字段,JSON 契约只需把这两个字段真正用起来。 - 双尺寸:Octicons 的
AllIcons里同一个图标有 16px 和 24px 两个版本,viewport 不同(16 vs 24),序列化时各自独立成条目——天然验证了 JSON 契约对多 viewport 的兼容性。 - 数量翻倍:414 个图标的 JSON 达 154KB(Tabler 精选 185 个约 67KB),验证链路在更大数据量下的表现——实测依然毫秒级。
第四个差异是本次踩坑的根源:Tabler 的 185 个图标里没有任何一个用到 arcTo(SVG A/a 椭圆弧命令),而 Octicons 几乎每个图标都用它画圆形和圆角——这个差异在 Tabler 版完全没暴露,到 Octicons 才引爆(详见第五章踩坑)。
三、工程结构
octicons-ohos-demo/
├── octicons/ # 库模块:shim + 上游图标源码
│ └── src/commonMain/kotlin/
│ ├── androidx/compose/ui/ # shim(仅 ImageVector.kt 一处改动:椭圆弧展平)
│ │ ├── graphics/Color.kt # 与 Tabler 版相同
│ │ ├── graphics/vector/ImageVector.kt # ★ 本次唯一改动文件(arcTo → C 曲线展平)
│ │ ├── graphics/vector/Path.kt # 与 Tabler 版相同
│ │ └── unit/Dp.kt # 与 Tabler 版相同
│ ├── compose/icons/__Octicons.kt # AllIcons(414) / AllIconsNamed 注册表
│ └── compose/icons/octicons/*.kt # 414 个上游图标文件(零修改)
├── example/
│ ├── nativeApp/ # Kotlin/Native 桥接层 → libohosicons.so
│ │ └── src/
│ │ ├── commonMain/kotlin/IconBridge.kt # FEATURED 列表 + fill 模式 JSON
│ │ └── ohosMain/kotlin/IconExport.kt # @CName 导出(与 Tabler 版相同)
│ └── ohosApp/ # ArkTS 鸿蒙应用(bundleName: com.example.octiconsdemo)
│ └── entry/src/main/
│ ├── cpp/napi_init.cpp # C++ NAPI 薄层(与 Tabler 版相同)
│ ├── ets/pages/Index.ets # ArkTS fill 模式渲染
│ └── libs/{arm64-v8a,x86_64}/ # 双 ABI so(4.6MB / 4.1MB)
└── settings.gradle.kts / build.gradle.kts
与 Tabler 版的差异点只有四处:库模块名(octicons)、IconBridge.kt 的 FEATURED 列表与 JSON 序列化(fill 字段)、Index.ets 的渲染模式,以及 shim 的 ImageVector.kt 一处椭圆弧展平——C++ NAPI 薄层、@CName 出口全部原样复用。
四、适配过程:三个关键步骤
4.1 Gradle 工程配置(与 Tabler 版同构)
鸿蒙定制工具链(2.2.21-1.0.0)的 pluginManagement 仓库配置不变,模块名从 tabler-icons 换成 octicons:
// settings.gradle.kts
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") // 必须第一位
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories { /* 同上 */ }
}
rootProject.name = "octicons-ohos-demo"
include(":octicons", ":example:nativeApp")
4.2 核心:fill 模式的 JSON 序列化(IconBridge)
上游图标源码零修改,桥接层新增 fill 字段。Octicons 的图标定义长这样(注意 fill 有值、stroke = null,且大量使用 arcToRelative):
public val Octicons.Search24: ImageVector
get() {
_search24 = Builder(name = "Search24", defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f).apply {
path(fill = SolidColor(Color(0xFF000000)), stroke = null, ...,
pathFillType = EvenOdd) {
moveTo(14.53f, 15.59f)
arcToRelative(8.25f, 8.25f, 0.0f, true, true, 1.06f, -1.06f) // ← 放大镜外圆
lineToRelative(5.69f, 5.69f)
...
moveTo(2.5f, 9.25f)
arcToRelative(6.75f, 6.75f, 0.0f, true, true, 11.74f, 4.547f) // ← 放大镜内圆(镂空)
...
close()
}
}.build()
return _search24!!
}
IconBridge.iconToJson() 相比 Tabler 版新增两个字段:
fun iconToJson(name: String, icon: ImageVector): String = buildString {
append("{")
append("\"name\":\"").append(name).append("\"")
append(",\"viewportWidth\":").append(icon.viewportWidth) // 16 或 24(双尺寸)
append(",\"viewportHeight\":").append(icon.viewportHeight)
append(",\"paths\":[")
icon.paths.forEachIndexed { i, p ->
if (i > 0) append(",")
append("{")
append("\"d\":\"").append(escape(p.svgPath)).append("\"")
append(",\"fill\":\"").append(colorHex(p.fill)).append("\"") // 新增:fill 颜色
append(",\"fillAlpha\":").append(p.fillAlpha) // 新增:fill alpha
append(",\"strokeWidth\":").append(p.strokeLineWidth)
// ... strokeCap / strokeJoin / fillRule
append("}")
}
append("]")
append("}")
}
colorHex 把 Color(0xFF000000) 转成 #000000(commonMain 里没有 String.format,手动按位转 hex)。FEATURED 列表精选 207 个图标,16px 与 24px 混排,覆盖导航、Git 工作流、文件、安全、品牌(LogoGithub/MarkGithub/Octoface)等分类。
一个 Kotlin 语义细节:Octicons 的图标属性是 Octicons 对象的扩展属性(val Octicons.Home24),桥接层引用时必须写 Octicons.Home24 而非裸 Home24,且需要 import compose.icons.AllIcons(同样是扩展属性)——这是初次编译报 200+ 个 receiver type mismatch 的原因。
4.3 ArkTS 渲染:fill 模式 + 居中缩放
Index.ets 的渲染核心与 Tabler 版对称——fill 与 stroke 互换。另一个细节是缩放:Kotlin 导出的 path 坐标是相对 viewport(0~16 或 0~24)的,必须先按 viewport 尺寸布局、再绕中心缩放到目标像素尺寸,否则 scale 默认绕左上角会导致放大后偏移:
// Tabler 版(stroke 模式)
Path()
.commands(...)
.fillOpacity(0)
.stroke('#4FC3F7')
.strokeWidth(this.strokeScale(icon))
// Octicons 版(fill 模式 + 居中缩放)
Path()
.commands(icon.paths.map((p: PathJson) => p.d).join(' '))
.fill(tint) // JSON 里的 fill 颜色,回退主题色
.fillOpacity(this.iconFillAlpha(icon)) // JSON 里的 fillAlpha
.strokeOpacity(0)
.width(icon.viewport) // 先按 viewport 尺寸布局
.height(icon.viewport)
.scale({ x: sizePx / icon.viewport, y: sizePx / icon.viewport,
centerX: '50%', centerY: '50%' }) // 绕中心放大到 56px,不偏移
C++ NAPI 薄层(napi_init.cpp)、@CName 出口(IconExport.kt)与 Tabler 版逐字节相同——C ABI 符号名(OhosIconsFeatured / OhosIconsCount / OhosIconsLastError / OhosIconsFree)不变,so 名(libohosicons.so)不变,两个应用甚至可以并排装在同一台模拟器上(bundleName 分别为 com.example.composeiconsdemo / com.example.octiconsdemo)。
五、踩坑记录(4 个,第 1 个是本次核心)
| 坑 | 现象 | 解法 |
|---|---|---|
ArkUI Path.commands 不支持 SVG A/a 椭圆弧命令(核心坑) | 首版渲染:Search 放大镜变成实心圆,CheckCircle/XCircle/PlusCircle 等所有含圆弧的图标全部变形,纯直线图标(Check/X/Plus/Dash)正常 | 在 shim 的 arcTo/arcToRelative 出口处按 SVG 1.1 规范(endpoint → center 参数化)把椭圆弧展平成多条三次贝塞尔 C 曲线,导出的 JSON 只剩 M/L/C/Q/Z——ArkTS 侧零改动 |
| 扩展属性 receiver 丢失 | 桥接层裸引用 Home24 编译报 200+ 个 receiver type mismatch | Octicons 图标是 Octicons 对象的扩展属性,引用必须带 receiver(Octicons.Home24),AllIcons 同理需单独 import |
| 部分图标无 24 版 | Unresolved reference 'LogoGithub24' 等 14 个报错 | Octicons 部分图标只有 16px 版(LogoGithub/MarkGithub/Markdown/ThreeBars…),FEATURED 列表改用 16 版 |
ArkUI scale 默认绕左上角 | 图标放大到 56px 后在卡片里偏移、不居中 | 先按 viewport 尺寸布局,再 .scale({ ..., centerX: '50%', centerY: '50%' }) 绕中心缩放 |
椭圆弧展平的核心实现(shim 唯一改动)
为什么 Tabler 没踩这个坑?因为它的 185 个图标没有任何一个用 arcTo(stroke 线条图标用直线/贝塞尔就够了);而 Octicons 的圆形、圆角全靠椭圆弧——arcTo/arcToRelative 在 414 个图标文件里出现了 400+ 处。当 Path().commands(...) 解析到 A 命令时中断,后续的内圆镂空路径被丢弃,EvenOdd 填充退化成只画外轮廓——放大镜就成了实心圆。
修复方案是改 shim 而不是改上游图标(414 个文件零修改的承诺不破):在 PathBuilder 的 arcTo/arcToRelative 里直接输出贝塞尔曲线。算法是 SVG 1.1 规范附录 F.6 的标准转换——endpoint 参数化转 center 参数化,再按每段 ≤90° 切成多条三次贝塞尔:
// ImageVector.kt —— PathBuilder 内部
fun arcTo(rx: Float, ry: Float, theta: Float,
isMoreThanHalf: Boolean, isPositiveArc: Boolean,
x1: Float, y1: Float): PathBuilder {
appendArcAsCubics(rx, ry, theta, isMoreThanHalf, isPositiveArc, x1, y1)
currentX = x1; currentY = y1
return this
}
private fun appendArcAsCubics(rx, ry, xAxisRotationDeg, largeArc, sweep, endX, endY) {
// 1. (x1,y1) → (x1',y1') 变换到旋转前坐标系
// 2. 半径过小则按 sqrt(lambda) 放大校正
// 3. 求中心点 (cx', cy') → 变回原坐标系 (cx, cy)
// 4. 求起始角 θ1 与扫掠角 Δθ(按 sweep 修正到正确象限)
// 5. 按 ≤90° 分段,每段用一条三次贝塞尔逼近:
val t = (4f / 3f) * tan(delta / 4f) // 控制点系数
// 单位圆控制点 → 缩放 + 旋转 + 平移映射回椭圆
curveTo(mapX(p1x, p1y), mapY(p1x, p1y),
mapX(p2x, p2y), mapY(p2x, p2y),
mapX(p3x, p3y), mapY(p3x, p3y))
}
改完后导出的 JSON 里 Search24 的 d 字段从 M14.53 15.59A8.25 8.25 ...(含 A)变成纯 M...C...C...C...Z(贝塞尔展开),ArkUI 原样吃下。这个改动对 Tabler 版零影响(它本来就不含 arcTo),且对未来的 Feather/FontAwesome/Material 等图标包自动生效——圆形元素多的图标包都能直接受益。
六、运行效果(DevEco 模拟器实测)
Demo 深色图标网格页:顶部标题 + 状态栏(图标数/耗时)+ 图标按 56px 四列排布,fill 模式实心渲染。
首屏加载即拉取全部精选图标:
*首屏:精选 207 / 共 414 图标,30ms 拉取;Search 放大镜空心圆镂空、HomeFill 实心、CheckCircle/PlusCircle 圆形镂空清晰——椭圆弧展平修复生效*
向下滚动查看 Git 工作流与品牌图标区:
*中部:Repo 系列、LogoGithub/MarkGithub 品牌图标、Discussion/Organization 等社区图标,实心填充效果清晰*
继续滚动到底部时间与表情图标区:
*底部:Stopwatch/Trophy 等时间奖杯图标、Smiley/Heart 等表情图标、Octoface 章鱼猫,几何数据与上游完全一致*
真实性验证(hilog)——页面加载有日志铁证:
ComposeIconsNapi: getIconCount=414
ComposeIconsNapi: getFeaturedIcons: count=207
ComposeIconsNapi: getFeaturedIcons len=157772 head=[{"name":"Home","viewportWidth":24.0,"viewportHeight":24.0,"paths":[{"d":"M11.03...
渲染正确性验证——椭圆弧展平前后对比:
| 图标 | 修复前(含 A 命令) | 修复后(贝塞尔展开) |
|---|---|---|
| Search | 实心圆(镂空丢失) | 放大镜:空心圆 + 斜柄 |
| CheckCircle | 实心圆 | 圆形轮廓 + 内部对勾 |
| XCircle / PlusCircle / NoEntry | 实心圆 | 圆形镂空 + 内部符号 |
| Check / X / Plus / Dash(纯直线) | 正常 | 正常(不受影响) |
每个像素都来自 Kotlin/Native 侧导出的真实几何数据,非 mock——414 个图标的注册表全量可访问,FEATURED 精选 207 个,单次拉取 30ms。
七、FAQ
Q1:这次还是"shim 零修改"吗?不是了。Tabler 版的四文件 shim 在 Octicons 工程里改了一个文件(ImageVector.kt):arcTo/arcToRelative 从直接透传 A/a 命令改为椭圆弧展平成贝塞尔曲线。其余三文件(Color.kt / Dp.kt / Path.kt)依然零修改。这次改动恰恰说明:shim 的抽象边界是对的——fill/stroke 模式、双尺寸都能零改动吃下,唯一的缺口是 ArkUI 渲染端对 SVG 命令集的支持度(缺 A),而补齐这个缺口只需要在 shim 的几何出口处做一次命令降级,上游 414 个图标文件和 ArkTS 渲染侧都不用动。
Q2:16px 和 24px 双尺寸怎么处理?各自独立成 JSON 条目(viewport 16 或 24),ArkTS 侧先按 viewport 尺寸布局、再绕中心缩放到 56px 显示尺寸——同一套渲染代码对任意 viewport 通用。
Q3:fill 颜色都是黑色,JSON 里带颜色有意义吗?有。上游 Color(0xFF000000) 是图标的默认色,真实业务里会按主题重着色。JSON 契约保留颜色字段后,多色图标包(如 FontAwesome 的品牌色)可以零改动接入。
Q4:414 个图标全序列化会怎样?当前 FEATURED 精选 207 个(154KB JSON,30ms)。全量 414 个约 300KB,单次拉取依然是毫秒级;如果追求极致,可以走"编译期预生成 rawfile"路线(见 Tabler 版扩展方向)。
Q5:下一个图标包还需要做什么?按本次经验,平移一个新图标包(Feather / FontAwesome / Material…)的工作量 = 换 FEATURED 列表 + 确认渲染模式(fill 或 stroke)+ ArkTS 三行渲染参数。shim(含椭圆弧展平)、NAPI、出口层全部不动——而且经过 Octicons 这次,shim 对含大量圆弧的图标包也验证过了,后续平移的意外成本更低。
八、总结与参考
Octicons 适配把 Tabler 版的"数据定义类库"路径从单模式验证推进到模式覆盖验证:stroke 与 fill 两种渲染模式、16/24 双尺寸体系、414 个图标的数据量、400+ 处椭圆弧几何,同一套架构全部吃下。更重要的是,本次踩中并修复了 Tabler 没暴露的 ArkUI A 命令坑——这恰好证明了「shim 记录几何 → JSON 契约传输 → ArkUI 原生渲染」这条链路的可调试性与可收敛性:渲染端的命令支持缺口,可以在 shim 的几何出口处一次性补齐(椭圆弧 → 贝塞尔降级),而不需要触碰上游任何一个图标文件。
至此 compose-icons 的适配方法论已经收敛:shim(含命令降级)记录几何 → JSON 契约传输 → ArkUI 原生渲染。剩余的图标包(Feather、FontAwesome、Material、Simple Icons…)都是这条路径上的重复劳动,可以按需批量平移。
- 上游库:https://github.com/DevSrSouza/compose-icons
- 鸿蒙定制仓库:https://maven.eazytec-cloud.com/nexus/repository/maven-public/
- OpenHarmony 三方库社区:https://atomgit.com/oh-tpc
- 欢迎加入 KMP&CMP 鸿蒙社区:https://atomgit.com/CPF-KMP-CMP
- 本问适配源码仓库:https://atomgit.com/oh-tpc/compose-icons
更多推荐


所有评论(0)