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 IconsOcticons
渲染模式stroke(线条描边,stroke = SolidColor(...), fill = null)fill(实心填充,fill = SolidColor(Color(0xFF000000)), stroke = null)
尺寸体系单一 24px viewport16px + 24px 双尺寸并存(Home16 / Home24)
图标数量185 个(当前收录)414 个(16/24 两版合计)
几何命令纯直线/贝塞尔(M/L/C/Q/Z),零 arcTo**大量 arcTo/arcToRelative(400+ 处)**画圆形/圆角

这三个差异恰好覆盖了 Tabler 版 FAQ 里预留的全部扩展点:

  1. fill 模式:Tabler 版 ArkTS 用 stroke() + fillOpacity(0) 渲染线条;Octicons 必须反过来——fill() + strokeOpacity(0) 渲染实心形状。shim 的 PathData 早已记录了 fill: Color? / fillAlpha 字段,JSON 契约只需把这两个字段真正用起来。
  2. 双尺寸:Octicons 的 AllIcons 里同一个图标有 16px 和 24px 两个版本,viewport 不同(16 vs 24),序列化时各自独立成条目——天然验证了 JSON 契约对多 viewport 的兼容性。
  3. 数量翻倍: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 mismatchOcticons 图标是 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…)都是这条路径上的重复劳动,可以按需批量平移。

Logo

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

更多推荐