在这里插入图片描述

给应用生成一个图标,你的第一反应是打开 Figma 还是运行 dart run?本文继续拆解 E-Brufen 的程序化 PNG 编码器。

一、PNG 的诞生:从 GIF 专利风波说起

1994 年圣诞节刚过,CompuServe 突然宣布:GIF 格式使用的 LZW 压缩算法需要向 Unisys 支付专利费。整个互联网炸了锅——GIF 是当时 Web 上唯一支持透明色的图片格式,几乎所有网站都在用它。更糟的是,Unisys 不仅向商业软件收费,连免费软件的开发者也会收到律师函。

Thomas Boutell 和一群开发者决定做一个 GIF 的替代品。他们的设计目标非常明确:

  1. 完全无专利——格式规范公开,任何人可以免费实现
  2. 比 GIF 更好——支持真彩色(GIF 只有 256 色)、更好的压缩率
  3. 不做 JPEG 的事——PNG 是无损格式,和 JPEG 的无损模式不同定位
  4. 流式友好——文件头包含尺寸信息,可以边下载边解码
  5. 抗损坏——每个数据块都有 CRC32 校验,坏一个字节就能发现

最终的格式名叫 PNG(Portable Network Graphics),还有一个非官方的递归缩写:“PNG’s Not GIF”。

★ Insight ─────────────────────────────────────
PNG 规范在其开篇就声明"本格式的设计目标之一是推动 GIF 的淘汰"。从今天来看,这个目标基本实现了——网络上透明背景的图片几乎全是 PNG,而 GIF 存活下来靠的是动画(APNG 最终也没能完全取代它)。
─────────────────────────────────────────────────

二、PNG 文件结构

┌─ PNG Signature ──┐  8 bytes: 137 80 78 71 13 10 26 10
├─ IHDR ───────────┤  图像头:宽、高、位深、颜色类型
├─ IDAT ───────────┤  图像数据(压缩后)
├─ IEND ───────────┤  图像结束标记
└──────────────────┘

PNG 由"块"(chunk)组成,每个块的结构相同。

那 8 字节的签名不是随机的。89 50 4E 47\x89PNG)的高位字节 89 是非 ASCII 字符,防止文本编辑器把 PNG 当成文本文件打开;0D 0A\r\n)是 DOS 风格换行,用来检测换行符转换问题;1A 是 DOS 的文件结束符 Ctrl-Z,让 TYPE 命令停止输出;0A\n)是 Unix 换行,用来检测相反的换行符转换问题。短短 8 个字节,做了四层防御。

三、块的通用结构

void _writeChunk(List<int> out, String type, List<int> data) {
  w32(out, data.length);        // [4 bytes] 数据长度
  out.addAll(type.codeUnits);   // [4 bytes] 类型码
  out.addAll(data);             // [N bytes] 数据
  final crc = _crc32(type.codeUnits + data);
  w32(out, crc);                // [4 bytes] CRC32 校验
}

每个块 = 长度 + 类型 + 数据 + CRC32。

类型码的 4 个字节各有含义:首字节的大小写决定块是"关键块"(大写,如 I)还是"辅助块"(小写);第二字节的大小写决定块是"公共块"(大写)还是"私有块"(小写);第三字节保留,目前必须是大写;第四字节的大小写决定解码器在遇到不认识的块时是否可以安全跳过(小写 = 安全跳过)。比如 IHDR 是"关键-公共-必须理解"的,而 iTXt 是"辅助-公共-可跳过"的。

★ Insight ─────────────────────────────────────
CRC32 校验是 PNG 的安全特征——任何块数据的损坏都能被检测到。E-Brufen 用纯 Dart 实现了它(_crc32 函数),确保生成的 PNG 能被任何查看器正确打开。
─────────────────────────────────────────────────

四、IHDR:图像头块

final ihdrData = <int>[];
ihdrW32(width);     // 图像宽度 (512)
ihdrW32(height);    // 图像高度 (512)
ihdrData.addAll([8, 6, 0, 0, 0]);
// 8  = bit depth (8 bits per channel)
// 6  = color type (RGBA = Truecolor + Alpha)
// 0  = compression method (deflate)
// 0  = filter method (adaptive)
// 0  = interlace method (none)
_writeChunk(output, 'IHDR', ihdrData);

颜色类型 6 = RGBA,每个像素 4 字节(R, G, B, A)。

五、颜色类型详解:为什么选 RGBA(类型 6)

PNG 定义了 5 种颜色类型,每一种对应不同的通道组合和使用场景:

类型 名称 通道 每像素字节 典型用途
0 灰度 (Grayscale) 1 通道 1 字节 黑白文档扫描
2 真彩 (Truecolor) RGB 3 通道 3 字节 不透明照片
3 索引 (Indexed) 调色板 1 字节 GIF 风格的小图
4 灰度+Alpha 2 通道 2 字节 带透明度的灰度图
6 真彩+Alpha RGBA 4 通道 4 字节 应用图标、UI 素材

类型 0 和类型 4 没有彩色信息,显然不适合应用图标。

类型 2(RGB) 是真彩色但不含透明度通道。如果你用它生成图标,透明区域会变成黑色或随机颜色——对于非矩形的图标来说,这是致命的。

类型 3(索引色) 是 GIF 的 PNG 版本——用一个调色板(最多 256 色)和一个 PLTE 块来定义颜色。优点是文件小,缺点是渐变色会产生明显的色带。E-Brufen 的图标是圆形带花瓣纹理的渐变,256 色完全不够用。

类型 6(RGBA) 是真彩色 + Alpha 通道的完全体。每个像素 4 字节,R、G、B 各占 1 字节(0-255),Alpha 也是 1 字节(0 全透明,255 全不透明)。这正是 AppGallery 要求的:“一个正方形的、有透明背景的 512×512 PNG 图标”。

// RGBA 像素的内存布局(每个像素 4 字节)
// [R, G, B, A, R, G, B, A, R, G, B, A, ...]
//  ↓首像素       ↓第二像素       ↓第三像素
final pixel = rgba[y * width * 4 + x * 4];
// pixel + 0 = Red
// pixel + 1 = Green
// pixel + 2 = Blue
// pixel + 3 = Alpha

★ Insight ─────────────────────────────────────
位深(bit depth)和颜色类型是耦合的:类型 0 支持 1/2/4/8/16 位深,类型 2/4/6 只支持 8/16,而类型 3 只支持 1/2/4/8。E-Brufen 使用 8 位深,这是 RGBA 的标准配置。
─────────────────────────────────────────────────

六、IDAT:图像数据块

// 构建原始像素数据(带 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):最简单的行过滤器,每行数据前面加一个 0 表示不应用任何过滤。Filter 是为了提高压缩率,但对 E-Brufen 的 512×512 图标来说,压缩率的提升微乎其微。

七、Filter 算法全景:为什么选择 None

PNG 的过滤(Filter)机制是它和 BMP 等简单格式最本质的区别之一。BMP 直接存像素值,PNG 则对每一行先做预测变换,再交给 zlib 压缩。这个变换本身是完全可逆的(无损),目的是让数据更有规律,从而让 deflate 压缩得更狠。

PNG 定义了 5 种过滤器,每一行可以独立选择最适合自己的:

Filter 0 (None)    → raw[x] = pixel[x]
Filter 1 (Sub)     → raw[x] = pixel[x] - pixel[x - bpp]
Filter 2 (Up)      → raw[x] = pixel[x] - pixel_above[x]
Filter 3 (Average) → raw[x] = pixel[x] - floor((pixel[x - bpp] + pixel_above[x]) / 2)
Filter 4 (Paeth)   → raw[x] = pixel[x] - PaethPredictor(left, above, upper_left)

其中 bpp(bytes per pixel)对于 RGBA 就是 4。

Sub 过滤器利用水平方向的相关性——如果一行内相邻像素变化不大(比如纯色背景),Sub 后的值大多接近 0,压缩率会非常高。

Up 过滤器利用垂直方向的相关性——如果图像有很多水平条纹,Up 的效果会很好。

Average 过滤器综合水平和垂直,是中等复杂度的折中选择。

Paeth 过滤器是最复杂的,它使用 Alan Paeth 的预测器——在左、上、左上三个相邻像素中选择一个做参考,选择标准是"哪个使得预测误差最小"。它在照片类图像上表现最好。

那么 E-Brufen 为什么全用 None?

  1. 代码简单——None 不需要任何变换逻辑,一行代码解决
  2. 收益有限——应用图标是人工生成的矢量图形(渐变 + 几何形状),像素间相关性本来就高度可预测,filter 能带来的额外压缩率可能只有 5%-10%
  3. 快速——省去了每行做 filter 比较和选择的时间
  4. 正确性优先——手写编码器首先需要保证生成的文件是正确的。每多一个 filter,就多一个可能出 bug 的地方

对于专业 PNG 编码器(如 pngquant、optipng),每行做 filter 选择是标准操作。但对于一个 100 行的编码脚本,None 是务实的选择。

// 如果要做 filter 选择,代码会变成:
// (这只是伪代码,E-Brufen 实际并未实现)
int bestFilter = 0;
int bestSum = sumOfAbs(filteredByNone);
for (var f = 1; f <= 4; f++) {
  final filtered = applyFilter(f, row, previousRow);
  final s = sumOfAbs(filtered);
  if (s < bestSum) { bestFilter = f; bestSum = s; }
}
raw.add(bestFilter);
raw.addAll(applyFilter(bestFilter, row, previousRow));

八、IEND 与块的顺序规则

_writeChunk(output, 'IEND', []);

IEND 块的数据长度为 0——它只是一个"文件结束"的标记。

PNG 的块顺序不是任意的,规范有严格的约束。理解这些约束能帮你理解为什么 IHDR、IDAT、IEND 在代码中按这个顺序出现:

  1. PNG Signature 必须排在第一位——8 字节的文件签名,让任何程序一看就知道"这是 PNG"
  2. IHDR 必须紧接在 Signature 之后——解码器拿到 IHDR 才知道宽、高、颜色类型,才能分配内存
  3. IDAT 包含实际的图像数据——可以有多个 IDAT 块(最大每个 2GB),但它们必须连续出现,中间不能插入其他类型的块。这样做是为了方便流式解码——解码器看到一个 IDAT 就开始解,不需要跳来跳去
  4. IEND 必须排在最后——它标记"数据到此结束",解码器看到 IEND 就知道文件完整了
  5. 辅助块(如 tEXt、gAMA、sRGB)可以出现在 IHDR 之前或之后,IDAT 之前——但不能插在 IDAT 之间,也不能跑到 IEND 后面。最常见的做法是 IHDR 后面跟辅助块,然后是 IDAT,最后 IEND
Signature → IHDR → [辅助块...] → IDAT → [IDAT...] → IEND
  ↓          ↓                    ↓                  ↓
 必须第一   必须第二个           必须连续            必须最后

E-Brufen 的编码器只生成最精简的布局:Signature + IHDR + IDAT + IEND,没有任何辅助块。这符合"最小可行"原则。

九、CRC32 校验实现

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   // 多项式 0xEDB88320
        : crc >> 1;
    }
  }
  return crc ^ 0xFFFFFFFF;  // 最终 XOR
}

CRC32 的多项式 0xEDB88320 是 IEEE 802.3 标准(也是 PNG 规范指定的)。

十、大端字节序

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)字节序——最高字节先写入。这与 WAV 的小端不同。

★ Insight ─────────────────────────────────────
大端字节序也叫"网络字节序"(Network Byte Order)。PNG 的 “N” 是 “Network”——它生来就是为网络设计的,所以用大端再自然不过。
─────────────────────────────────────────────────

十一、验证生成的 PNG

# 检查文件头
xxd app_icon.png | head -1
# 应输出: 8950 4e47 0d0a 1a0a ...

# 用任何图片查看器打开
# 应该看到一个绿色渐变背景带花瓣纹理的圆形图标

十二、PNG vs 其他格式:为什么图标必须用 PNG

手写编码器总要选一个格式。E-Brufen 为什么选 PNG 而不是别的?简单分析一下主要候选:

JPEG:有损压缩,适合照片。不支持 Alpha 通道(没有透明背景)。512×512 的图标用 JPEG,透明区域会变成白底,AppGallery 直接拒收。结论:不适合图标

WebP:Google 的现代格式,支持无损/有损,支持 Alpha,文件比 PNG 小 25%-35%。看起来很美,但 HarmonyOS 的 RichEditor 和 AppGallery 的上传流程对 WebP 不是 100% 兼容。写编码器也很复杂(VP8/VP8L 不是几十行代码能搞定的)。结论:技术好,兼容性差

BMP:格式最简单,不压缩,像素直写。50 行代码就能搞定。但是 512×512 RGBA 的 BMP 是 512 × 512 × 4 + 54 = 1,048,630 字节(约 1 MB),而同样内容的 PNG 大约 30-50 KB。20 倍的大小差距让 BMP 在 CI/CD 流程中毫无竞争力。结论:太简单,文件太大

PNG:无损,支持 Alpha,格式规范清晰(可被 100 行代码实现),全球通用(从 Windows 95 到鸿蒙 5.0 都支持),文件大小合理。结论:最适合应用图标

格式 无损 Alpha 文件大小 (512×512 图标) 实现复杂度
JPEG ~15 KB 高(DCT + 霍夫曼)
WebP ~25 KB 极高
BMP ~1 MB 极低
PNG ~30-50 KB 中(deflate + CRC)

这个表格基本说明了一切:PNG 是正确性和实现成本的甜蜜点。

小结

100 行 Dart 代码 = 完整的 PNG 编码器。当平台支持不确定时,"自己造轮子"不是炫技,而是降低外部依赖风险的务实策略。E-Brufen 的图标生成脚本不需要任何第三方库,在 CI/CD 中可以无痛运行。

附录:最小 PNG 文件的逐字节解读

如果你读到这里还觉得抽象,下面是一个完整的最小 1×1 透明 RGBA PNG 的十六进制 dump,每一行都有注释。这个文件只有 70 字节,但它完整演示了 PNG 结构:

Offset  Hex                                          Decoded
------  ------------------------------------------   ------------------------
0x00    89 50 4E 47 0D 0A 1A 0A                      PNG Signature
                                                      89 = 非ASCII检测
                                                      50 4E 47 = "PNG"
                                                      0D 0A = DOS换行
                                                      1A = Ctrl-Z文件结束符
                                                      0A = Unix换行

0x08    00 00 00 0D                                  数据长度 = 13 bytes
0x0C    49 48 44 52                                  "IHDR"
0x10    00 00 00 01                                  宽度 = 1 pixel
0x14    00 00 00 01                                  高度 = 1 pixel
0x18    08                                           位深 = 8 bits/channel
0x19    06                                           颜色类型 = 6 (RGBA)
0x1A    00                                           压缩方法 = 0 (deflate)
0x1B    00                                           过滤方法 = 0 (adaptive)
0x1C    00                                           隔行扫描 = 0 (none)
0x1D    XX XX XX XX                                  IHDR的CRC32

0x21    00 00 00 0A                                  数据长度 = 10 bytes
0x25    49 44 41 54                                  "IDAT"
0x29    78 9C 62 60 60 F8 0B 00 00 41 00 E0          zlib压缩的像素数据
                                                      78 9C = zlib头
                                                      62 60 ... = deflate块
                                                      (解码后 = [0, 0,0,0,0])
                                                      0 = filter None
                                                      0,0,0,0 = RGBA全透明像素
0x33    XX XX XX XX                                  IDAT的CRC32

0x37    00 00 00 00                                  数据长度 = 0 bytes
0x3B    49 45 4E 44                                  "IEND"
0x3F    AE 42 60 82                                  IEND的CRC32
                                                      (CRC of "IEND" only,
                                                       空数据)

总大小: 67 bytes

你可能会好奇中间那个 zlib 块 78 9C 62 60 60 F8 0B 00 00 41 00 E0 是怎么来的。它的解码过程是:

  1. 78 9C 是标准的 zlib 头部(默认压缩级别)
  2. 62 60 60 F8 0B 00 00 41 00 E0 是 deflate 压缩的 5 个字节原始数据:[0x00, 0x00, 0x00, 0x00, 0x00](filter byte = 0 + RGBA 全透明 4 字节)
  3. E-Brufen 用 _zlibCompress() 包装了 Dart 的 zlib.encode(),得到的压缩结果就是这一串
// 验证:把原始 raw 数据喂给 zlib 看看
final raw = [0, 0, 0, 0, 0];  // filter=None + RGBA(0,0,0,0)
final compressed = zlib.encode(raw);
print(compressed);  // [120, 156, 98, 96, 96, 248, 11, 0, 0, 65, 0, 224]
// 即 78 9C 62 60 60 F8 0B 00 00 41 00 E0

这就是 PNG 的核心循环:原始像素 → filter 变换 → deflate 压缩 → IDAT 块。无论 1×1 的透明像素还是 512×512 的渐变图标,流程完全一样。


作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen

Logo

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

更多推荐