不用 Photoshop,不用 Figma------用 Dart 代码生成你的应用图标。
一、背景
E-Brufen 是一个零外部资源的应用------没有设计师、没有图标素材、没有图片文件。所有视觉资产都由代码生成。应用图标也不例外:一个独立的 Dart 脚本 generate_icon.dart,在构建前运行,输出一个带有花瓣纹理和渐变背景的 512x512 PNG 文件。
这个脚本不依赖任何第三方包------PNG 编码、zlib 压缩、CRC32 校验全部用纯 Dart 手写。这意味着你可以在任何安装了 Dart SDK 的环境中运行它,包括 CI/CD 流水线。
★ Insight ─────────────────────────────────────程序化生成图标不是炫技。在零外部资源策略下,这意味着构建是完全可复现的 ------无论在哪台机器上、无论设计师的 Figma 版本如何变化,
dart run generate_icon.dart始终输出完全相同的 PNG 文件。这是 CI/CD 中"可复现构建"理念在资源层面的落地。
─────────────────────────────────────────────────
二、完整脚本架构
generate_icon.dart 共计 186 行,由以下模块组成:
| 模块 | 函数 | 行数 | 职责 |
|---|---|---|---|
| 入口 | main() |
L6-L17 | 调用生成逻辑,写入 ohos 资源目录 |
| 像素渲染 | _generatePng() |
L19-L89 | 512x512 逐像素绘制花形图案 |
| PNG 编码 | _encodePng() |
L91-L122 | 组装 PNG 签名 / IHDR / IDAT / IEND |
| 块写入 | _writeChunk() |
L124-L130 | 通用 PNG 块结构封装 |
| 大端写 | w32() |
L132-L137 | 32 位无符号整数大端写入 |
| Zlib 压缩 | _zlibCompress() |
L140-L175 | 零压缩模式 Deflate + Adler-32 |
| CRC32 | _crc32() |
L177-L186 | IEEE 802.3 多项式 CRC32 |
dart
// generate_icon.dart --- 入口
void main() {
const size = 512;
final bytes = _generatePng(size);
// 输出到 AppScope(应用全局图标)
final path = 'ohos/AppScope/resources/base/media/app_icon.png';
File(path).writeAsBytesSync(bytes);
stderr.writeln('Icon generated: $path (${size}x$size)');
// 同时输出到 entry 模块(模块级图标)
final entryPath = 'ohos/entry/src/main/resources/base/media/icon.png';
File(entryPath).writeAsBytesSync(bytes);
stderr.writeln('Icon copied: $entryPath');
}
两个输出路径的区别:
ohos/AppScope/resources/base/media/app_icon.png--- 应用全局图标,在系统设置中的应用列表、应用信息页显示ohos/entry/src/main/resources/base/media/icon.png--- entry 模块图标,在桌面快捷方式、最近任务中显示
HarmonyOS 的 Stage 模型下,一个应用可以包含多个 module(entry、feature),每个 module 可以有自己的图标。但对于单模块应用,通常两处使用相同的图标文件。
★ Insight ─────────────────────────────────────注意
main()向stderr写入日志而非stdout。这是因为stdout可能被构建脚本捕获用于解析(例如输出产物路径),而诊断信息应当流向stderr------这是 Unix 工具链的约定。
─────────────────────────────────────────────────
三、像素绘制算法
图标的视觉效果由四个数学函数叠加生成,每个函数操作 512x512 像素矩阵中的每一格(共 262,144 个像素)。
3.1 坐标系变换
dart
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
final idx = (y * width + x) * 4; // RGBA 偏移量
final cx = x - width / 2; // 将原点移至图像中心
final cy = y - height / 2; // 同上
final dist = sqrt(cx * cx + cy * cy) / (width / 2); // 归一化距离 [0, ~1.4]
final angle = atan2(cy, cx); // 极坐标角度 [-π, π]
每个像素首先从笛卡尔坐标 (x, y) 转换为极坐标 (dist, angle)。dist 经过归一化处理:图像中心为 0,图像边缘(水平/垂直)为 1,四角为 √2 ≈ 1.4。
3.2 层一:径向渐变底色
dart
// 柔和的绿色渐变背景
final bgR = (76 + (dist * 30)).toInt();
final bgG = (175 - (dist * 20)).toInt();
final bgB = (80 + (dist * 15)).toInt();
这是一层基础色。从中心向外,R 和 B 通道略微增加,G 通道略微减少------产生从鲜绿到深绿的过渡。基准色 (76, 175, 80) 是 Material Design 的 Green 400。
3.3 层二:花瓣纹理(核心视觉效果)
dart
final petalDist = dist * (1 + 0.3 * sin(angle * 3) * sin(dist * pi * 2));
if (petalDist < 0.85 && petalDist > 0.2) {
final petalAlpha =
(0.15 * sin(angle * 3) * sin(petalDist * pi * 1.5)).abs();
r = (r + (180 - r) * petalAlpha).toInt().clamp(0, 255);
g = (g + (220 - g) * petalAlpha).toInt().clamp(0, 255);
b = (b + (120 - b) * petalAlpha).toInt().clamp(0, 255);
}
这是整个算法的核心。两重三角函数组合产生三瓣花形图案:
sin(angle * 3)--- 乘以 3 在 0, 2π 范围内产生 3 个完整周期,形成三瓣对称结构。每 120° 一个花瓣。如果乘以 4 就是四瓣花,乘以 5 就是五瓣花。sin(dist * pi * 2)--- 随径向距离变化的波纹。在dist从 0 到 1 的过程中,正弦波完成两个完整周期,产生同心纹理。petalDist--- 将角度调制和径向调制叠加到基础距离上,使得在花瓣方向上距离"缩短"(图案向外延伸),在花瓣间隙方向上距离"拉长"(图案向内收缩)。
petalAlpha 的 .abs() 保证了非负混合系数,将像素向暖色方向偏移(180, 220, 120 目标色)。
3.4 层三:中心圆形高亮
dart
if (dist < 0.25) {
final alpha = (1 - dist / 0.25).clamp(0.0, 1.0);
r = (r + (255 - r) * alpha * 0.7).toInt();
g = (g + (255 - g) * alpha * 0.5).toInt();
b = (b + (255 - b) * alpha * 0.3).toInt();
}
在 dist < 0.25 的核心区域,逐步向白色混合。alpha 从中心的 1.0 线性衰减到边缘的 0.0。R 通道混合 70%(向 255),G 混合 50%,B 混合 30%------刻意不平衡使得最终高光偏向暖白(略微偏粉),而非完全中性灰白。
3.5 层四:外环描边
dart
if (dist > 0.8 && dist < 0.95) {
final ringAlpha = (1 - (dist - 0.8) / 0.15).abs().clamp(0.0, 1.0) * 0.5;
r = (r + (200 - r) * ringAlpha).toInt();
g = (g + (230 - g) * ringAlpha).toInt();
b = (b + (200 - b) * ringAlpha).toInt();
}
在 dist ∈ [0.8, 0.95] 的环带区域叠加淡色描边。ringAlpha 在环带中心最大(0.5),向两边线性衰减。这给花瓣图案增加了一个视觉边界。
3.6 层五:圆角方形裁剪
dart
final edgeDist = max(
(cx.abs() - width * 0.35).clamp(0, width.toDouble()),
(cy.abs() - height * 0.35).clamp(0, height.toDouble()),
);
if (edgeDist > 0 && edgeDist < 20) {
final edgeAlpha = (1 - edgeDist / 20.0).clamp(0.0, 1.0) * 0.6;
a = (255 * (1 - edgeAlpha)).toInt();
}
这层不修改颜色,而是修改 Alpha 通道。它定义了一个 "圆角方形" 区域:X 和 Y 方向上,超出中心 ±35% 范围的像素进入半透明过渡带(20 像素宽),过渡带之外的像素 Alpha 归零,完全透明。
效果是最终图标呈现为柔和的圆角方形------适配 Android(自适应图标)和 HarmonyOS(圆角方形图标)的裁剪规范,避免图标内容被系统级裁剪后丢失关键信息。
★ Insight ─────────────────────────────────────五层叠加的设计模式在图形编程中非常经典(类似 Photoshop 的图层概念)。每一层只做一件事,叠加后得到复杂效果。这种"组合简单函数得到复杂输出"的方法,在
generate_audio.dart(WAV 编码器)中也被同样使用------多层正弦波叠加生成自然音效纹理。
─────────────────────────────────────────────────
3.7 数学总结
| 函数 | 参数域 | 视觉效果 |
|---|---|---|
atan2(cy, cx) |
极角 -π, π | 控制花瓣方向性 |
sin(angle * 3) |
周期 = 2π/3 | 三瓣对称结构 |
sin(dist * pi * 2) |
径向波纹 | 同心圆纹理 |
clamp(0, 255) |
通道值约束 | 防止溢出 |
clamp(0.0, 1.0) |
Alpha 混合系数 | 线性插值边界 |
四、PNG 编码器实现
完整的 PNG 编码器在 _encodePng() 函数中,约 30 行。它在二进制层面构造了一个合法的 PNG 文件。
4.1 PNG 文件结构总览
┌──────────────────────┐
│ PNG Signature │ 8 bytes: 137 80 78 71 13 10 26 10
├──────────────────────┤
│ IHDR Chunk │ 图像头:宽、高、位深、颜色类型
├──────────────────────┤
│ IDAT Chunk │ 图像数据(原始像素经 zlib 压缩)
├──────────────────────┤
│ IEND Chunk │ 图像结束标记(空数据)
└──────────────────────┘
每个 Chunk 的内部结构完全一致:
┌────────────────┬──────────────┬────────────────┬────────────────┐
│ Length (4B) │ Type (4B) │ Data (N B) │ CRC32 (4B) │
└────────────────┴──────────────┴────────────────┴────────────────┘
- Length:Data 字段的字节数(不含自身),大端序
- Type:4 个 ASCII 字符(如 "IHDR"、"IDAT"、"IEND"),编码为 4 字节
- Data:块类型决定的内容
- CRC32:对 Type + Data 的 CRC32 校验值(不含 Length 字段)
4.2 通用块写入器
dart
void _writeChunk(List<int> out, String type, List<int> data) {
w32(out, data.length); // [4B] 数据长度
out.addAll(type.codeUnits); // [4B] 类型码 (ASCII)
out.addAll(data); // [N B] 数据体
final crc = _crc32(type.codeUnits + data); // 对 type+data 计算 CRC32
w32(out, crc); // [4B] 校验值
}
_writeChunk 是所有块的工厂函数。三个块(IHDR、IDAT、IEND)都通过它写入,保证结构一致。
4.3 PNG 签名(Magic Number)
dart
output.addAll([137, 80, 78, 71, 13, 10, 26, 10]);
这 8 字节是 PNG 规范规定的固定签名(hex: 89 50 4E 47 0D 0A 1A 0A)。它同时起到:
- 魔法数字:识别文件类型(第 2-4 字节是 ASCII "PNG")
- 换行检测 :
0D 0A是 CRLF,用于检测 DOS/Unix 换行转换 - EOF 检测 :
1A是 DOS 的 EOF 字符,阻止type命令输出二进制垃圾
4.4 IHDR(Image Header)
dart
final ihdrData = <int>[];
ihdrW32(width); // 4B: 图像宽度 (512)
ihdrW32(height); // 4B: 图像高度 (512)
ihdrData.addAll([8, 6, 0, 0, 0]);
// byte 0: 8 = bit depth (每通道 8 位)
// byte 1: 6 = color type (RGBA --- Truecolor + Alpha)
// byte 2: 0 = compression method (0 = deflate, PNG 目前唯一合法值)
// byte 3: 0 = filter method (0 = adaptive, PNG 目前唯一合法值)
// byte 4: 0 = interlace method (0 = none, 1 = Adam7)
_writeChunk(output, 'IHDR', ihdrData);
颜色类型 6 (RGBA) 意味着每个像素占 4 字节:R、G、B、A 各 8 位。对于 512x512 图像,IHDR 数据只有 13 字节,但它宣告了后续 IDAT 的格式。
4.5 IDAT(Image Data)
dart
// 构建原始像素数据(逐行,每行前缀 filter byte)
final raw = <int>[];
for (var y = 0; y < height; y++) {
raw.add(0); // filter type: None(不应用任何过滤)
final rowStart = y * width * 4;
raw.addAll(rgba.sublist(rowStart, rowStart + width * 4));
}
final compressed = _zlibCompress(raw);
_writeChunk(output, 'IDAT', compressed);
关键点:
- Filter byte :每行数据前加一个 0(
None过滤器),表示像素数据原样存储。PNG 支持 5 种过滤器(None / Sub / Up / Average / Paeth),用于提高压缩率,但我们选择最简单的。 - Zlib 压缩:原始数据(含 filter byte)通过 zlib 压缩后存入 IDAT。不压缩直接存是不合法的------PNG 规范要求 IDAT 数据必须是 deflate 压缩的。
- 对于 512x512 RGBA 图像,原始数据大小 =
512 * 512 * 4 + 512(filter bytes)= 1,049,088 字节 ≈ 1MB。
4.6 IEND(Image End)
dart
_writeChunk(output, 'IEND', []);
IEND 块的数据长度为 0------它只是一个"文件结束"的标记。任何 PNG 解析器看到 IEND 后停止读取。
4.7 w32:大端序写入
dart
void w32(List<int> out, int v) {
out.add((v >> 24) & 0xFF); // 最高字节先写
out.add((v >> 16) & 0xFF);
out.add((v >> 8) & 0xFF);
out.add(v & 0xFF); // 最低字节后写
}
PNG 规范要求所有多字节整数使用大端序(Big-Endian / Network Byte Order)。这与 WAV 格式的小端序不同------编写二进制格式时需要仔细确认字节序。
五、zlib 压缩的简易实现
PNG 的 IDAT 必须经过 zlib 压缩。真正的 Deflate 算法需要 LZ77 + Huffman 编码,实现复杂度极高。E-Brufen 选择了一个务实的方案:零压缩模式(BTYPE=00)。
dart
List<int> _zlibCompress(List<int> data) {
// ── Zlib Header ──
final out = <int>[0x78, 0x01];
// 0x78 = CMF: deflate method, 32K window size
// 0x01 = FLG: no dictionary, compression level 0 (no compression)
// ── Deflate Block (BTYPE=00, No Compression) ──
out.add(1); // BFINAL=1 (final block), BTYPE=00 (no compression)
// LEN (2 bytes, little-endian)
final len = data.length;
out.add(len & 0xFF);
out.add((len >> 8) & 0xFF);
// NLEN (2 bytes, one's complement of LEN)
final nlen = len ^ 0xFFFF;
out.add(nlen & 0xFF);
out.add((nlen >> 8) & 0xFF);
// Raw data
out.addAll(data);
// ── Adler-32 Checksum ──
var s1 = 1, s2 = 0;
for (final b in data) {
s1 = (s1 + b) % 65521;
s2 = (s2 + s1) % 65521;
}
out.add((s2 >> 8) & 0xFF); // s2 hi
out.add(s2 & 0xFF); // s2 lo
out.add((s1 >> 8) & 0xFF); // s1 hi
out.add(s1 & 0xFF); // s1 lo
return out;
}
5.1 Zlib 头(CMF + FLG)
| 字段 | 值 | 含义 |
|---|---|---|
| CMF bits 0-3 | 8 | Compression Method: deflate |
| CMF bits 4-7 | 7 | Window size: 2^(7+8) = 32KB |
| FLG bits 0-4 | 1 | Check bits (使 CMF*256+FLG 能被 31 整除) |
| FLG bit 5 | 0 | FDICT: no preset dictionary |
5.2 Deflate 块头
BTYPE=00 是无压缩存储模式。它将原始数据直接写入,前面加 LEN 和 NLEN(LEN 的 1 补码)用于完整性校验。如果 len != (~nlen & 0xFFFF),说明数据已损坏。
Deflate 支持三种块类型:
| BTYPE | 模式 | 压缩率 | 实现复杂度 |
|---|---|---|---|
| 00 | 无压缩 | 0% | 极低 |
| 01 | 固定 Huffman | ~50% | 中等 |
| 10 | 动态 Huffman | ~70% | 很高 |
5.3 Adler-32 校验
Adler-32 = (s2 << 16) | s1,其中:
s1 = sum of all bytes (mod 65521)s2 = sum of all intermediate s1 values (mod 65521)
65521 是关键------它是小于 2^16 的最大质数。使用质数模运算减少哈希碰撞。在 zlib 中,Adler-32 以大端序存储。
★ Insight ─────────────────────────────────────零压缩方案用约 1MB(而非 300KB)的文件大小换来了约 30 行(而非 3000 行)的代码。对于一个只生成一次的应用图标,1MB 完全可以接受。如果未来需要更高压缩率(例如批量生成大量图片),可以替换为
dart:io的ZLibCodec------但在此之前,保持简单的实现更有利于维护。参见 post-40 获得 zlib 的完整分析。
─────────────────────────────────────────────────
六、CRC32 校验
dart
int _crc32(List<int> data) {
var crc = 0xFFFFFFFF;
for (final b in data) {
crc ^= b;
for (var i = 0; i < 8; i++) {
crc = (crc & 1) != 0
? (crc >> 1) ^ 0xEDB88320 // 多项式反射
: crc >> 1;
}
}
return crc ^ 0xFFFFFFFF;
}
6.1 算法剖析
- 初始值 :
0xFFFFFFFF(全 1) - 逐字节处理:每个字节与当前 CRC 的低 8 位 XOR
- 逐位处理 :对每个 bit 循环 8 次
- 如果 LSB 是 1:右移 1 位后 XOR
0xEDB88320 - 如果 LSB 是 0:仅右移 1 位
- 如果 LSB 是 1:右移 1 位后 XOR
- 最终 XOR :
crc ^ 0xFFFFFFFF
6.2 多项式 0xEDB88320
这是 IEEE 802.3 标准 CRC32 多项式的反射(reversed)形式:
标准形式: 0x04C11DB7
反射形式: 0xEDB88320(bit 反转)
PNG 规范指定使用反射多项式------这对应 zlib 库的默认行为,也是几乎所有 PNG 库使用的多项式。使用非反射多项式生成的 CRC32 会导致 PNG 查看器拒绝打开文件。
6.3 CRC32 生成表 vs 逐位计算
典型的 CRC32 实现会预计算一个 256 项的查找表以加速:
dart
// 优化版 CRC32(使用查找表,速度提升 8 倍)
final _crcTable = List<int>.generate(256, (n) {
var c = n;
for (var k = 0; k < 8; k++) {
c = (c & 1) != 0 ? (c >> 1) ^ 0xEDB88320 : c >> 1;
}
return c;
});
int _crc32Fast(List<int> data) {
var crc = 0xFFFFFFFF;
for (final b in data) {
crc = _crcTable[(crc ^ b) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFF;
}
E-Brufen 使用逐位计算版本------对于 1MB 的数据,CRC32 计算时间约 0.2 秒,完全可接受。参见 post-39 获得 PNG 格式的完整分析。
七、HarmonyOS 图标资源配置
7.1 目录结构

生成的图标需要放置到 HarmonyOS 项目规定的资源目录中:
ohos/
├── AppScope/
│ └── resources/
│ └── base/
│ ├── element/
│ │ └── string.json # 应用名称等字符串
│ └── media/
│ └── app_icon.png # ← 生成到这里(应用全局图标)
├── entry/
│ └── src/
│ └── main/
│ ├── module.json5 # entry 模块配置
│ └── resources/
│ └── base/
│ ├── element/
│ │ └── string.json
│ └── media/
│ └── icon.png # ← 生成到这里(entry 模块图标)
└── build-profile.json5
7.2 app.json5 配置
json5
// ohos/AppScope/app.json5
{
"app": {
"bundleName": "com.ebrufen.app",
"vendor": "example",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:app_icon", // 通过 $media 引用 app_icon.png
"label": "$string:app_name"
}
}
$media:app_icon 引用的是 resources/base/media/app_icon.png。$media: 前缀是 HarmonyOS 的资源引用语法,类似于 Android 的 @mipmap/ 或 @drawable/。
7.3 module.json5 配置
json5
// ohos/entry/src/main/module.json5
{
"module": {
"name": "entry",
"type": "entry",
"abilities": [
{
"name": "EntryAbility",
"icon": "$media:icon", // 桌面图标
"label": "$string:EntryAbility_desc",
"startWindowIcon": "$media:icon", // 启动页图标(冷启动时显示)
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": ["entity.system.home"],
"actions": ["action.system.home"]
}
]
}
]
}
}
icon vs startWindowIcon 的区别:
icon:桌面快捷方式、最近任务列表中显示的图标startWindowIcon:应用冷启动时,在 Flutter 引擎初始化完成之前显示的占位图标(防止白屏闪现)
两者通常指向同一文件。如果 startWindowIcon 缺失,冷启动时会显示一个默认的灰色图标。
★ Insight ─────────────────────────────────────HarmonyOS 使用
$media:filename引用媒体资源(不带扩展名)。这与 Android(@mipmap/ic_launcher)和 iOS(Assets.xcassets)的资源引用机制都不同。Flutter 的 HarmonyOS 适配层(flutter_ohos)在编译时会自动解析这些引用并打包到 HAP 中。
─────────────────────────────────────────────────
7.4 资源限定符
HarmonyOS 支持资源限定符来适配不同设备:resources/base/media/(默认)、resources/dark/media/(深色模式)、resources/tablet/media/(平板)等。对于 E-Brufen,只在 base 目录放置一份图标即可覆盖所有场景。
7.5 构建时自动生成
生成脚本应在每次构建前运行,确保图标始终与代码同步:
bash
# 开发构建
dart run generate_icon.dart
flutter run -d <device_id>
# 生产构建
dart run generate_icon.dart
flutter build hap --release
也可以集成到 Makefile 或 Gradle 任务中,将生成步骤与构建绑定。
关于零外部资源的完整打包策略,参见 post-41。
八、与传统方式对比
| 维度 | 传统方式(Figma/PS + 导出) | 程序化生成 |
|---|---|---|
| 工具依赖 | 需要设计工具 | 仅需 Dart SDK |
| 可复现性 | 取决于设计师的操作系统/版本 | 100% 可复现 |
| 版本控制 | 二进制 PNG 文件入 Git(bloat) | 只有 ~200 行 Dart 源码 |
| 参数化定制 | 设计师重新出图 | 修改几个常量即可 |
| 文件大小 | 取决于导出设置 | 完全可控 |
| CI/CD 集成 | 需要确保二进制文件存在 | 构建时自动生成 |
| 学习成本 | 需要设计技能 | 需要数学 + 二进制格式知识 |
九、扩展:修改图案
如果你希望生成不同的图标图案,只需修改 _generatePng() 中的数学公式:
- 五瓣花 :将
sin(angle * 3)改为sin(angle * 5) - 不同配色 :修改
bgR、bgG、bgB的基准值 - 正方形而非圆角方形:移除第五节(edgeDist)的 Alpha 处理
- 更大尺寸 :修改
const size = 512为1024
所有修改仅涉及代码中的数值常量,无需重新导出任何文件。
十、验证生成的图标
bash
# 1. 检查 PNG 签名(应为 89 50 4E 47 0D 0A 1A 0A)
xxd ohos/AppScope/resources/base/media/app_icon.png | head -1
# 2. 确认文件大小(约 1MB)
ls -lh ohos/AppScope/resources/base/media/app_icon.png
# 3. 用任何图片查看器打开,确认显示绿色渐变带花瓣纹理的圆角方形图标
小结
generate_icon.dart 用 186 行纯 Dart 代码完成了一个完整的 PNG 图标生成流水线:像素级数学绘制(五层叠加)、PNG 二进制编码(IHDR/IDAT/IEND 三个块)、zlib 压缩(BTYPE=00 零压缩模式)、CRC32 校验(IEEE 802.3 反射多项式)、以及 HarmonyOS Stage 模型的资源目录输出。
这个脚本体现了 E-Brufen "零外部资源 " 设计的核心原则------任何可能变化的二进制资源都用代码生成,将"资源文件"退化为"构建产物"。这不仅减少了 Git 仓库体积,更重要的是保证了构建的完全可复现性。
延伸阅读 :post-39 PNG 格式详解 / post-40 手写 zlib 压缩 / post-41 零资源打包策略
作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化。
项目源码 :https://gitcode.com/PengXiansheng/E-Brufen