前阵子我们团队在做 Flutter 应用的 OpenHarmony 适配,第一个膈应人的环节就是应用图标。Flutter 生态里用 flutter_app_icon 这类库切图标已经是常规操作:一张 1024 源图,一条命令帮你切出 iOS、Android、Web 甚至桌面的各种尺寸,顺带把每个平台需要的资源索引文件也生成好。可做到鸿蒙这一端,输出列表里没有 OpenHarmony,多端切割在这个平台直接停摆。设计师更新一版 logo,我们就得手动缩放、手动计算尺寸、手动塞进 DevEco 工程,再手动改配置文件。这种活在项目冲刺阶段特别消耗精力。所以就有了这篇文章:把 flutter_app_icon 的鸿蒙适配讲清楚,做一套真正的自动化视觉资产部署。如果你也在做类似的多端视觉资产统一,或者正被应用图标规格折腾,这篇能帮你省掉不少摸索时间。
1. 先搞清楚 flutter_app_icon 这个库到底替你做了什么
1.1 一条命令切完全平台图标的底层逻辑
很多团队把 flutter_app_icon 当成一个“图片切尺寸工具”来用,这其实低估了它的价值。图标这件事真正的痛点不是“把 1024 缩成 512、192、144、48”这种缩放操作,随便一个图片处理工具都能做。真正的痛点是:每个平台对图标的组织方式都不一样,而且这些规则在持续变化。
iOS 要用 AppIcon.appiconset 目录,里面不光要按 40、58、60、80、87、120、180 这些规格放好图片,还必须有一份 Contents.json 描述所有图片的用途;Android 要把同一张图标打进 mipmap-mdpi、mipmap-hdpi、mipmap-xhdpi、mipmap-xxhdpi、mipmap-xxxhdpi 这几个密度目录,通知栏小图标又各有各的尺寸;Web 端更碎,favicon、apple-touch-icon、manifest 里的各种尺寸都要覆盖。如果这些规则靠人肉去记、去执行,每一个平台改版一次就要重新做一遍。
flutter_app_icon 这类库做的事情,本质上就是把上面这些规则固化成代码。它的内部可以分成三层:第一层是源图预处理,读入源 PNG,按需做缩放、去背景、格式化;第二层是平台规格表,把 iOS、Android、Web、Windows 每个平台需要的尺寸、文件命名、目录结构都写成配置;第三层是资源索引生成器,针对那些需要索引文件的平台,把图片放好后同步生成 Contents.json 或者清单配置。对外暴露的入口很简洁:一份配置文件加上一条命令,剩下的全自动。
我这里使用的流程大致是:在项目里放一张 1024 的源图,配置文件里指定源图路径和目标平台列表,然后命令行跑一次,工程里各个平台的图标目录就被刷新了。之所以强调这个底层逻辑,是因为后面做鸿蒙适配时,很多思路都直接复用这三层结构,而不是从头写一个新工具。
1.2 鸿蒙工程接入后,到底卡在哪个环节
我们的 Flutter 工程跑 OpenHarmony 的方式,是把 Flutter 模块作为依赖接入 DevEco 创建的宿主工程。这里面有一个关键认知需要先建立起来:应用图标、应用名称、权限声明这些都属于宿主工程的资源,Dart 代码本身不参与图标的生成。换句话说,flutter_app_icon 就算能把 Flutter 包里的图标变量再漂亮,OpenHarmony 这一侧也只认 DevEco 工程里的 AppScope 和 module 资源目录。
当时卡住的状态很典型:flutter_app_icon 的配置里能选的平台就那几个,没有 OpenHarmony 这个选项。我又翻了一遍它生成的资源清单,无论是文件命名还是目录约定,跟 OpenHarmony 的工程结构都对不上。OpenHarmony 要求图标放在 AppScope/resources/base/media 下,通过 app.json5 引用;模块图标放在 module/src/main/resources/base/media 下,通过 module.json5 里的 abilities 配置引用。这种资源组织方式和 iOS 靠目录约定加 Contents.json 自动识别的思路完全不同。
所以最原始的做法就是纯手工:设计师给新源图,开发打开图片处理工具,手动输出 512 的 app_icon.png、216 的分层图标前景和背景,放进对应目录,还要去改 layered_image.json、核对 app.json5 和 module.json5 里的引用路径。这活儿听起来不难,但要命的是它得跟 UI 改版频率同步执行。一个应用在冲刺阶段图标可能要改三四版,每一版都要把这套手工流程完整走一遍,中间只要漏掉一个文件,构建出来的效果就奇奇怪怪。这篇文章要讲的就是怎么把这段流程从“人肉”变成“自动化”。
顺带说一句,有人会问 Flutter 组件通信、Navigator 切换页面后的状态保持、PlatformView 适配这些运行层问题在鸿蒙侧表现怎么样。那些属于另一个话题,和图标这种纯资源适配的路径完全不同。今天这篇只围绕视觉资产部署这条线展开,把图标这一类资源问题彻底解决掉。
2. OpenHarmony 的图标规范,和 iOS/Android 的差异比想象中大
2.1 app.json5、module.json5 与 media 目录的三角关系
在动手写适配脚本之前,我先把 OpenHarmony 工程里图标的完整引用链路理了一遍。不把这个理清楚,后面代码写出来很容易只做了图片输出,却漏了配置引用,最后装到设备上图标还是旧的。
OpenHarmony 应用工程中图标资源主要分布在两处。一处是 AppScope 目录,它位于工程最外层,里面有一个 app.json5,是应用级的配置文件,应用图标在这里通过"icon": "$media:app_icon"这种形式引用 AppScope/resources/base/media/app_icon.png。另一处是模块目录,比如默认的 entry 模块,在 entry/src/main/resources/base/media 下放模块自己的图标资源,module.json5 中 abilities 数组里每个 ability 的 icon 字段会引用这里的资源,一般是"icon": "$media:icon"。
这两处引用缺一不可。实际测试中我发现,如果只改了 AppScope 下的图标,桌面图标可能还是旧的;如果只改了 module 下的,部分场景下系统里展示的图标又对不上。要保证应用图标在所有入口展示一致,最好两个位置的媒体资源都同步生成。
另外一个容易被忽略的细节:OpenHarmony 的资源管理走的是资源限定目录机制,media 目录下还可能按屏幕密度或语言拆分子目录,但默认情况放 default 层级即可,也就是 resources/base/media。从 XTS 认证的角度看,图标必须是规范的 PNG 文件,目录位置和配置引用一个都不能错。我们在准备认证材料时特意检查过这一块,官方文档对图标资源的检查项确实存在,与其到时候返工,不如在生成脚本里就把结构一次做对。
我给出的最终输出规格,是基于实测折中出来的方案:
| 输出文件 | 尺寸 | 用途 |
|---|---|---|
| app_icon.png | 512 x 512 | AppScope 应用级图标 |
| icon.png | 512 x 512 | module 模块级图标 |
| foreground.png | 216 x 216 | 分层图标前景层 |
| background.png | 216 x 216 | 分层图标背景层 |
| layered_image.json | - | 分层图标描述文件 |
为什么源图建议 1024、AppScope 输出 512,而不是直接把 1024 原图放进去?因为 OpenHarmony 在多个不同场景下会对图标做缩放和遮罩处理,512 在这个链条上兼容性最稳定,文件体积也更可控。如果你发布的目标 API 版本对图标尺寸有更明确的定义,以目标版本的官方图标规范为准。这套脚本里我把尺寸抽成了配置项,改一行就能重新生成。分层图标不是必须的,但新版系统对未分层图标的展示会做自动遮罩处理,观感不如分层图标精致,所以我在脚本里默认同时输出,也留了跳过开关。
2.2 分层图标的组合规则和透明通道这个隐藏大坑
分层图标是 OpenHarmony 这套体系里跟 iOS/Android 差异最大的地方。你可以把它理解成两张片叠在一起的视觉结构:前景层放 logo 主体,背景层放底色或者底纹,系统在桌面渲染的时候会把两层叠加,再应用统一的圆角遮罩。
这个机制的关键在于“分层”两个字。很多从 iOS 转过来的开发下意识把一张完整带圆角的图标放进了 foreground.png,结果系统渲染时再叠一层遮罩,图标边缘被二次裁剪,小尺寸下视觉信息直接糊掉。正确做法是:前景文件里放带透明通道的 logo 图形,背景文件放铺满画面的纯色或简单图形,两张图组合起来才是完整图标。透明通道在这里不是装饰,而是分层图标能不能正确工作的前提。
但要特别注意:如果前景图里除了 logo 主体之外有过多透明区域,桌面遮罩会把空白区域裁成一个“窟窿”,让图标看起来像被挖掉一块。我后来在实机上看过这种效果,非常明显。所以生成前景图时,我额外做了安全边距收缩:先把源图按中心缩放,留出边缘空白,再把 logo 主体控制在中心区域范围内。这也是适配脚本里最值得花心思的一段逻辑。
3. 适配方案选型:为什么最终选了独立的扩展脚本
3.1 fork 改源码、提 PR、独立脚本,三条路的取舍
把 OpenHarmony 输出能力加进 flutter_app_icon,直觉上最简单的是 fork 一份,在源码里加一个平台分支,把 OpenHarmony 的尺寸表和配置模板补进去。这条路我评估过,跑通确实快,但问题是后续维护成本很高:上游 flutter_app_icon 更新时,我们要把 fork 的版本也跟上,每次都要处理代码冲突;而且 OpenHarmony 输出属于我们自己项目的特有需求,长期维护一个私有 fork 版本对团队来说负担偏重。
第二条路是把 OpenHarmony 的生成逻辑做成一个完全独立的 Dart 脚本,只复用 flutter_app_icon 做完多端切割后的中间产物,然后单独输出到 DevEco 工程的资源目录。这条路维护成本最低,因为脚本跟库本身没有任何耦合,上游爱怎么更新都不影响我们。第三方库支持的平台越多,每个平台的适配深度往往越浅,OpenHarmony 这种明确提出差异化规范的目标平台,独立脚本反而比硬塞进库更可控。
还有第三条路是给上游提 PR。理论上是社区最理想的形态,但需要上游作者认同 OpenHarmony 这个扩展方向,还要过代码审查、写测试、维护后续兼容性,对项目交付周期来说不确定性太大。我们最后的选择是:本地先跑独立脚本把流程落地,等稳定之后再考虑回头给上游提交通用化的实现。
3.2 独立扩展脚本的完整实现
脚本放在工程 tools 目录下,叫 generate_harmony_icon.dart,用 Dart 写,依赖 image 包做图片处理。选择 Dart 而不是 Shell 或 Node,是因为 Flutter 工程本地一定具备 Dart 运行环境,团队成员不需要额外安装任何工具,直接 dart run 就能跑,这个理由足够充分。
脚本接收两个参数:源图路径和输出目录。源图直接用 flutter_app_icon 处理后的那张中间 PNG,比如 build/app_icon_1024.png,这样保证全平台图标视觉上源于同一张母图。核心逻辑分四步:读取源图;输出 AppScope 和 module 的 512 图标;输出分层图标的前景和背景;生成 layered_image.json。前景的制作逻辑是:源图居中缩放至 216 画布中心区域,四周留安全边距;背景则读取配置文件的主题色,生成一张纯色底,也可以按需换成模糊大图。核心代码大概是这样:
// tools/generate_harmony_icon.dart import 'dart:io'; import 'package:image/image.dart' as img; void main(List<String> args) { final sourcePath = args.isNotEmpty ? args[0] : 'build/app_icon_1024.png'; final outputRoot = args.length > 1 ? args[1] : 'openharmony'; final bytes = File(sourcePath).readAsBytesSync(); final src = img.decodePng(bytes); // 1. AppScope 应用级图标,512x512 final appIcon = img.copyResize(src, width: 512, height: 512); final appScopeDir = '$outputRoot/AppScope/resources/base/media'; File('$appScopeDir/app_icon.png') ..createSync(recursive: true) ..writeAsBytesSync(img.encodePng(appIcon)); // 2. module 模块级图标,512x512 final entryDir = '$outputRoot/entry/src/main/resources/base/media'; File('$entryDir/icon.png') ..createSync(recursive: true) ..writeAsBytesSync(img.encodePng(appIcon)); // 3. 分层图标:背景纯白,前景居中缩放,边缘留安全区 final background = img.Image(width: 216, height: 216); img.fill(background, color: img.ColorRgb8(255, 255, 255)); final foreground = img.copyResize(src, width: 170, height: 170); File('$appScopeDir/background.png') ..createSync(recursive: true) ..writeAsBytesSync(img.encodePng(background)); File('$appScopeDir/foreground.png') ..createSync(recursive: true) ..writeAsBytesSync(img.encodePng(foreground)); // 4. 生成分层图标描述文件 const layered = '''{ "layered-image": { "background": "\$media:background", "foreground": "\$media:foreground" } }'''; File('$appScopeDir/layered_image.json') ..createSync(recursive: true) ..writeAsStringSync(layered); }一个提醒:脚本里的写文件路径和 JSON 结构,需要跟工程实际的 DevEco 目录对应。不同 IDE 版本创建的工程,模块名可能不叫 entry,AppScope 结构也可能略有差别,跑之前先对着手头工程结构核对一次再执行。另外,image 包不同版本的 API 签名会有细微变化,如果遇到编译报错,先检查依赖版本,再看是否要改成 copyResizeCropSquare 之类的替代接口。
3.3 与 flutter_app_icon 的完整调用链
生成脚本本身解决不了“多端切割”的问题,它是和 flutter_app_icon 配合使用的。实际执行命令的顺序是这样:
# 先跑 flutter_app_icon,生成各平台图标 dart run flutter_app_icon # 再跑鸿蒙扩展脚本,生成 OpenHarmony 侧资源 dart run tools/generate_harmony_icon.dart配好后我把这两条命令写进项目根目录的 Makefile,以后不管是本地改图标还是后头 CI 换图,都只跑一个目标。这样职责划分很干净:flutter_app_icon 负责它擅长的多端切割,独立脚本只负责 OpenHarmony 这一个平台的资源落地。当时让项目里另一位同事照着 README 操作,全程没用图像处理软件,只靠这两条命令就完成了全部图标替换,这个结果比我预期顺利得多。
4. 实测过程中踩过的坑,每一个都值得记下来
4.1 透明背景图标在桌面上像被挖了个洞
第一次跑通脚本后,我把生成的图标安装到真机上,结果第一眼就把我整不会了:应用图标看起来像被利器切掉了一块角。原因是分层图标的背景层是纯白底,前景层把 logo 缩放之后外围留了透明区域,系统叠加两层再加上遮罩,透明区域里的背景色透出来,边缘又碰上遮罩裁剪,看起来就像有个缺口。
这个问题的教训是:OpenHarmony 的分层图标不能照搬 iOS“圆角图标自带透明边距”的思路。iOS 的 AppIcon 是完整的方形或有圆角的图,系统不做二次遮罩;OpenHarmony 会把你的前景和背景当原料再去组合。调整后的处理方式就是我前面写的:生成前景图时把 logo 缩放比例控制在 78% 左右,四周留出系统安全区,不要做额外圆角,透明区域越多,遮罩交互就越难控制。调整之后桌面图标终于跟设计稿视觉一致了。
4.2 DevEco 增量构建不识别替换后的图片
这个坑其实比图标本身更磨人。自动生成完资源后,打开 DevEco 直接点运行,桌面图标纹丝不动,就像没改过一样。一开始我以为是脚本路径写错了,反复检查文件内容、大小,发现文件确实是新的,但安装包里的资源就是旧的。
到最后定位到是 hvigor 的增量编译缓存问题:media 目录下的图片文件被替换后,构建系统按文件信息判断资源没有变化,就把旧的打包进去了。解决办法说起来简单,清理一次构建产物再重新跑就行。具体操作是删除工程根目录下的 oh_modules 和 build 目录,或者直接在 DevEco 里选 Build > Clean Project,然后再触发构建。我自己图省事,在脚本里加了一个 --clean 参数,生成完资源后顺带把目标工程的构建缓存删掉,之后再也没遇到过图标不更新的问题。
4.3 app.json5 和 module.json5 的引用路径,改错一个就前功尽弃
还有一次比较有迷惑性的情况:图标在设置页里显示正常,桌面图标却是旧的。我们排查了很久才发现,是 AppScope 和 module 两侧的引用不一致。设置页读的是应用级图标,桌面展示更多依赖模块图标能力上的图标配置,一处指向 app_icon.png,另一处没有同步更新,就出现了这种一半新一半旧的状态。
从那以后,生成脚本里强制输出两份同尺寸 PNG,也就是 AppScope 的 app_icon.png 和 module 的 icon.png,并且文档里明确标注:如果工程的 json5 中 icon 字段被自定义过,脚本不会去覆盖配置文件,需要开发者核对一次引用关系。自动化的边界就在这里——资源文件可以自动生成,但工程配置里如果存在业务自定义规则,脚本强行覆盖反而会出问题。
4.4 生成的图标发糊和边缘锯齿
分层图标刚跑通时还有个细节问题:桌面小尺寸下 logo 边缘有锯齿,看起来不干净。原因出在我直接用源图缩放到 216 再缩小展示,源图本身如果带了很细的线条,压缩抗锯齿没做好就会这样。image 库的 copyResize 可以传 interpolation 参数,我改成从 1024 大图做高质量插值缩放后再落盘,锯齿问题明显改善。如果你把源图丢进来发现边缘糊,优先检查这一步,而不是怀疑尺寸算错了。
5. 自动化视觉资产部署:从一条命令到 CI 流水线
5.1 本地一键刷新 OpenHarmony 图标资源
到这里,一套完整的本地命令链路已经成立:设计师把新 logo 命名为 app_logo_1024.png 放进 assets/icon 目录,flutter_app_icon 读取并生成多端切割结果,鸿蒙扩展脚本再从中间产物生成 OpenHarmony 资源,DevEco 工程里所有图标全部更新。我们甚至把命令做成了统一入口:
make icons这条命令在本地跑了两个多月,前后经历了好几版图标变更,没有再出现一次手工处理资源的情况。团队成员不管是 Flutter 开发还是 DevEco 工程开发,都只需要知道这一个命令。这大概就是自动化在资产场景下的价值所在:把最无聊的重复劳动交给脚本,让人的精力留在设计评审和视觉确认上。
5.2 CI 里按源图 hash 变化自动触发重新生成
本地命令解决了效率问题,但还没有解决“懒”的问题:UI 同事并不会自己去跑 make icons。真正的自动化视觉资产部署,应该做到源图变更时,资源自动重新生成并进入构建链。
我在 CI 流水线里加了一个任务,专门监听图标源文件的变化。具体做法比较土但很稳:对 assets/icon/app_logo_1024.png 取 SHA256,跟仓库里存的上一次 hash 对比,不一样就跑一次 make icons,然后把生成的资源目录提交回仓库。这样 UI 同学只要把新图推到固定目录,流水线会替他把所有平台的图标重新切割、把 OpenHarmony 资源重新生成,还会在合并请求里附上这次变更涉及的资源文件列表。
这一步做完,整个链路才算真正闭环。图标从“设计稿”到“全平台可用”被压缩成了一个推送事件,OpenHarmony 侧再也不是那个需要特别手工关照的异类平台。CI 里只需要一个简单的判断逻辑,参考写法是这样:
steps: - uses: actions/checkout@v4 - name: Check icon change id: check_icon run: | OLD=$(cat .icon-hash 2>/dev/null || echo "") NEW=$(sha256sum assets/icon/app_logo_1024.png | awk '{print $1}') if [ "$OLD" != "$NEW" ]; then echo "changed" > .icon-state; fi echo "$NEW" > .icon-hash - name: Generate icons if: hashFiles('.icon-state') != '' run: make icons5.3 让整个项目的视觉资产都统一起来
顺着这套思路,我还把应用内用到的其他固定视觉资产一起纳入了同一套管线:比如通知栏小图标、启动页背景图、商店宣传图里需要固定尺寸的部分。它们本质上是同一批约束:有着明确的规格表、需要随源图变更同步更新、需要进入受版本管理的资源目录。这类资产做成自动化的性价比非常高。
我对团队的建议是:凡是满足“规格固定、依赖同一个源文件、变更频繁”三个条件的资产,都应该从手工操作里解放出来。OpenHarmony 图标只是第一个吃螃蟹的,门前的路走通之后,整个多端视觉资产管理都会轻松很多。你甚至可以建立一个中心清单,把每个平台的图标规格和资源路径都登记在案,任何平台规范升级时,改一行配置重新生成一遍就行。
最后分享一个让我觉得这套方案真正成熟的小细节:脚本里所有尺寸值我都抽成了配置项,没有写死在代码里。有段时间官方调整过分层图标的推荐尺寸,我们只改了一行配置,重新跑一次就全部对齐了。我个人做这类自动化工具最大的体会是,规则型资产的自动化,最怕的不是代码写得不够聪明,而是规则本身被写死在代码里无法演进。给规则留出改动的空间,比一开始追求功能多更值得。