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 里预留的全部扩展点:

  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:

kotlin 复制代码
// 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):

kotlin 复制代码
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 版新增两个字段:

kotlin 复制代码
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 默认绕左上角会导致放大后偏移:

typescript 复制代码
// 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° 切成多条三次贝塞尔:

kotlin 复制代码
// 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...)都是这条路径上的重复劳动,可以按需批量平移。

相关推荐
旺仔Sec2 小时前
2026年江西省职业院校技能大赛鸿蒙应用开发赛项竞赛任务书(高职组)样题
华为·harmonyos
曲鸟2 小时前
体验完鸿蒙AI后的几点感受
人工智能·华为·harmonyos
HwJack203 小时前
【HarmonyOS开发小实践】Node-API 的SO 命名规则、多线程限制与调试
华为·harmonyos
LucianaiB3 小时前
用 HarmonyOS 做一张会写诗的月夜明信片:追月的完整开发复盘
华为·ai·harmonyos·skill
蒸鱼Yuzheng4 小时前
HarmonyOS HAP 与调试工件治理:包结构、版本身份与自动化证据链
自动化·性能测试·数据治理·harmonyos·hap
轻口味4 小时前
HarmonyOS 7 新特性3:TiledGSNode——轻带看让 71MB 庭院按视口按需加载:真机实测与零请求降级
华为·harmonyos·鸿蒙·tiledgsnode
李游Leo5 小时前
《HarmonyOS 7 ArkGraphics 3D 空间设计开发实战》01:从空场景到第一个可运行的3D房间【鸿蒙心迹】
3d·华为·harmonyos
猛犸象限5 小时前
【共创稿事节】关卡进了源码,却对不上 JSON——回归不是再截一张图
harmonyos
用户29469405448165 小时前
鸿蒙真机调试三板斧:Playwright 为什么驱动不了 Electron-on-鸿蒙
harmonyos·deepseek