
PNG 需要 zlib 压缩图像数据。不想依赖dart:io的 zlib 自己手写一个零压缩版本。一、为什么选择零压缩真正的 Deflate 压缩需要 LZ77 Huffman 编码实现复杂度极高数千行代码。对于 E-Brufen 的 512x512 图标原始约 1MB零压缩的 PNG 文件约 1MB——完全可以接受。// 零压缩 数据直接存储不压缩// 文件略大但编码器极简~30 行★ Insight ─────────────────────────────────────工程中的最优解不总是压缩率最高。零压缩方案用 1MB而非 300KB的文件大小换来了 30 行而非 3000 行的代码——这是典型的够用就好决策。当 PNG 只用一次应用图标1MB 完全可以接受。─────────────────────────────────────────────────二、Deflate 算法的前世今生Deflate 这个名字来源于压缩deflate 放气由Phil Katz在 1993 年为 PKZIP 2.0 设计。它的核心思想是将两个经典算法组合在一起形成一个两阶段压缩流水线第一阶段 —— LZ77Lempel-Ziv 1977由 Abraham Lempel 和 Jacob Ziv 在 1977 年提出。核心思路是数据中经常出现重复的字节序列与其原样存储不如用一个指针距离 长度来表示往前看找到之前出现过的那段内容。比如hello hello可以编码为hello 后退5个字节, 长度5。Deflate 使用了一个 32KB 的滑动窗口作为 LZ77 的搜索范围。第二阶段 —— Huffman 编码由 David Huffman 在 1952 年提出。统计 LZ77 输出的每个符号字面量字节、长度码、距离码的出现频率高频符号分配短编码低频符号分配长编码从而在比特层面进一步压缩。Deflate 的设计非常成功。它后来成为互联网时代最重要的压缩格式体系1992 年Jean-loup Gailly 实现 gzip在 Deflate 数据外层包裹了 gzip 头部含文件名、时间戳和 CRC32 尾部。1995 年Jean-loup Gailly 和 Mark Adler 设计 zlib包裹了更紧凑的 2 字节头部和 Adler-32 尾部。1996 年PNG 格式诞生选择 zlib 作为其图像数据的压缩层——这就是为什么我们写 PNG 编码器时需要实现 zlib。至今Deflate 家族几乎统治了无损压缩领域。HTTP 的Content-Encoding: gzip/deflate、ZIP 文件、PDF、HTTP/2 头部压缩HPACK、Apache Parquet 列存格式……处处可见其身影。RFC 1950zlib、RFC 1951Deflate、RFC 1952gzip三份文档共同定义了这一压缩标准家族。Phil Katz 本人于 2000 年不幸早逝年仅 37 岁但他留下的 Deflate 算法影响了此后三十年的互联网数据传输。★ Insight ─────────────────────────────────────Deflate 的优雅之处在于组合优于发明LZ77 消除行级/块级冗余Huffman 消除符号级冗余。两者各自解决不同层面的问题组合后效果远超单独使用。这不是发明了一个全新的压缩算法而是找到了两种已有算法的最佳协作方式。─────────────────────────────────────────────────三、Zlib 格式结构┌─ CMF ────┐ 1 byte: 0x78 (deflate, 32K window) ├─ FLG ────┤ 1 byte: 0x01 (no dict, level 0) ├─ DATA ───┤ N bytes: deflate 压缩数据 ├─ ADLER32 ┘ 4 bytes: Adler-32 校验和四、CMF FLGfinaloutint[0x78,0x01];字节值含义CMF (0x78)bits 0-3 8Compression Method: deflatebits 4-7 7Window size: 2^(78) 32KFLG (0x01)bits 0-4 1Check bitsbit 5 0No dictionary preset五、Deflate 块零压缩模式out.add(1);// BFINAL1, BTYPE00// BFINAL1: 这是最后也是唯一一个块// BTYPE00: 无压缩存储Deflate 支持三种块类型BTYPE模式压缩率00无压缩0%01固定 Huffman中等10动态 Huffman最高六、Huffman 编码简介——为什么固定和动态模式那么复杂要理解为什么 BTYPE01 和 BTYPE10 各自需要上千行代码我们需要先了解 Huffman 编码的工作原理。Huffman 编码的核心思想给每个符号分配一个变长的二进制编码。出现频率高的符号用短的编码出现频率低的符号用长的编码。关键约束是前缀性——没有任何编码是另一个编码的前缀这样才能保证解码器从左到右读比特流时不会产生歧义。构建 Huffman 树的过程统计所有符号的出现频率。将每个符号放入一个优先队列最小堆频率越低优先级越高。重复取出两个最低频率的节点合并为一个新节点频率 两者频率之和放回队列。最终形成一棵二叉树从根节点出发向左走记 0向右走记 1到达每个叶子节点的路径就是该符号的 Huffman 编码。举个例子假设数据中有三个字节A频率 50%、B频率 30%、C频率 20%。构建过程是先合并 C 和 B 得到一个频率 50% 的中间节点再把这个中间节点和 A 合并成根节点。最终 A 的编码是01 位B 的编码是102 位C 的编码是112 位。为什么 BTYPE01固定 Huffman对编码端仍然复杂固定 Huffman 使用 RFC 1951 预定义的码表——你不需要传输 Huffman 树省下了描述码表的开销。但对于编码端来说你仍然需要实现完整的 Huffman 编码逻辑查表将每个符号替换为对应的变长编码。位级别的打包bit packing因为你输出的不是字节而是变长的比特流。需要维护一个位缓冲区逐位写入满 8 位才输出一个字节。正确映射 LZ77 输出的 256 个字面量字节、长度码29 个和距离码30 个到固定码表。换句话说BTYPE01 省去了传输码表的成本但编码和解码逻辑一行都省不了。仅位缓冲区管理就需要近百行精心调试的代码。为什么 BTYPE10动态 Huffman更复杂动态 Huffman 是三种模式中最复杂的频率统计扫描输入数据统计每个符号的实际出现次数。构建两棵 Huffman 树一棵给字面量 长度最多 288 个符号一棵给距离最多 32 个符号。码表本身的压缩Deflate 对码表做了二次压缩——用游程编码Run-Length Encoding压缩 Huffman 树的描述哪些符号用了多长的编码再对游程编码的结果再做一次 Huffman 编码。这是元压缩——用 Huffman 压缩 Huffman 树的描述。位精确输出最终将压缩后的码表 LZ77 Huffman 编码的数据流按位精确输出。一整套实现下来一个生产质量的 Deflate 压缩器通常需要 2000-4000 行 C 代码。zlib 库的deflate.c文件——只实现了压缩端不包括解压端——就有 2000 行。更不用说还要处理好内存分配、滑动窗口管理、匹配查找优化等工程细节。// 零压缩 vs 真正 Deflate 的代码量对比// 零压缩一个函数30 行// BTYPE01需要 Huffman 编码器 位缓冲区~500 行// BTYPE10需要 LZ77 匹配器 Huffman 树构建 码表压缩~3000 行七、LEN NLENfinallendata.length;out.add(len0xFF);// LEN 低字节out.add((len8)0xFF);// LEN 高字节finalnlenlen^0xFFFF;out.add(nlen0xFF);// NLEN 低字节out.add((nlen8)0xFF);// NLEN 高字节NLEN LEN 的 1 补码ones’ complement len ^ 0xFFFF或~len 0xFFFF。这是 Deflate 的完整性检查——如果 LEN ! ~NLEN数据已损坏。然后直接写入原始数据out.addAll(data);八、Adler-32 校验和vars11,s20;for(finalbindata){s1(s1b)%65521;// 字节累加模 65521s2(s2s1)%65521;// 累加器的累加模 65521}// 输出s2 高字节, s2 低字节, s1 高字节, s1 低字节out.add((s28)0xFF);out.add(s20xFF);out.add((s18)0xFF);out.add(s10xFF);Adler-32 (s2 16) | s1其中s1 所有字节的和模 65521s2 所有 s1 的和模 65521在 zlib 中Adler-32 以大端字节序存储s2 在前s1 在后。⚠️65521是 Adler-32 的关键——它是小于 2^16 的最大质数。使用质数模运算可以减少哈希碰撞。九、质数 65521 的数学性质Adler-32 选择了65521作为模数而不是看起来更整的 65536即 2^16。这不是随意之举——65521 是小于 2^16 的最大质数这个选择背后有着精妙的数学考量。为什么必须用质数在模运算校验和中模数的选择直接影响碰撞率。考虑一个简单的例子如果我们用 65536 作为模数即直接取低 16 位那么高位的所有信息都会被直接丢弃。这意味着如果数据中有两个字节同时增加了相同的值这两个变化可能互相抵消——校验和完全检测不到错误。而质数模数具有更好的代数结构在模质数 p 下非零元素构成一个循环群cyclic group。这意味着随着数据不断累加s1 和 s2 的值能均匀地覆盖 0 到 p-1 的整个范围不会卡在某些特定的值上也不会产生周期性重复。65521 2^16 - 15 的优化性质65521 不仅是质数还因为接近 2^16 而可以绕过昂贵的除法指令。注意这个关系2^16 ≡ 15 (mod 65521)因此对于任意整数 v可以用位移和乘法代替模运算// 快速模 65521不需要除法利用 2^16 ≡ 15 mod 65521intmod65521(int v){// 等效于 v % 65521但更快v(v0xFFFF)(v16)*15;// 最多再做一次减法即可returnv65521?v-65521:v;}推导v v_hi * 2^16 v_lo而 2^16 ≡ 15 mod 65521所以 v ≡ v_hi * 15 v_lo mod 65521。这个技巧在 Mark Adler 的原始实现中被广泛使用。★ Insight ─────────────────────────────────────65521 是一个工程数学的经典案例。纯粹从数学角度看小于 2^16 的最大质数只是一个巧合但从工程角度看它同时满足了三个约束(1) 是质数降低碰撞率(2) 小于 2^16校验和只占 4 字节(3) 接近 2^16可以优化除法。这就是优秀的工程设计——不是单一维度最优而是多维约束下最平衡的解。─────────────────────────────────────────────────十、与 CRC32 的对比特性CRC32Adler-32位置PNG 每个块末尾zlib 末尾目的检测块数据损坏检测解压后数据完整性速度较慢逐位处理较快逐字节加法碰撞概率极低较低十一、zlib、gzip 与原始 Deflate 的区别很多开发者在使用这三者时感到困惑——它们共享同一个核心压缩算法Deflate但有着不同的包装和适用场景。zlibgzip原始 DeflateRFC195019521951头部2 字节CMFFLG10 字节ID1/ID2、压缩方法、时间戳、可选文件名等无尾部4 字节 Adler-324 字节 CRC32无典型用途PNG、PDF 内部流、HTTP 响应.gz文件、HTTPContent-Encoding: gzip响应ZIP 存档条目内部元信息支持否是文件名、修改时间、操作系统否多流拼接是连续多个 zlib 流是cat a.gz b.gz c.gz有效否如何选择选 zlib当你在一个已有容器格式如 PNG内部使用压缩时。zlib 头部最紧凑仅 2 字节Adler-32 计算比 CRC32 快。PNG 规范明确要求使用 zlib 包裹的 Deflate。选 gzip当你需要压缩独立文件并希望保留原始文件名、时间戳等元信息时。gzip 的头部包含了原始文件名和修改时间解压后可以恢复。选原始 Deflate当你在 ZIP 文件内部或实现 HTTPContent-Encoding: deflate时使用。注意HTTP 的deflate历史上存在歧义——有些实现使用原始 Deflate有些使用 zlib 包裹的 Deflate。实践中HTTPgzip比 HTTPdeflate更可靠。在我们的 PNG 编码场景中PNG 规范明确要求使用 zlib 格式——所以我们必须输出 CMF、FLG 头部2 字节和 Adler-32 尾部4 字节将原始 Deflate 数据块包裹在中间。这也是为什么本章从 zlib 头部开始而不是直接写 Deflate 块。十二、什么时候从零压缩切换到真正的 Deflate零压缩方案工作得很好——前提是数据量小且只压缩一次。但有些场景下零压缩就力不从心了。以下是可以用来判断的切换信号1. 数据超过 50KB 时考虑切换当 zlib 负载如图像原始像素超过约 50KB 时零压缩的代价开始显现。一个 100KB 的原始数据经过真正的 Deflate 压缩后通常可以缩小到 30-40KB——省下 60% 的空间。2. 图像有大量重复区域图标、截图中的大面积纯色或渐变区域是 LZ77 的最爱。比如一个 512x512 的纯蓝色图标——零压缩存储了 262144 个相同的蓝色像素每个 4 字节 RGBA而 LZ77 只需几个字节就能描述重复上一行。压缩率可能达到 99% 以上。3. 批量生成场景如果一次生成 100 张不同尺寸的图标总零压缩大小可能超过 100MB。换成真正的 Deflate总体积轻松降到 30MB 以下——对存储和传输的影响是显著的。4. 网络传输瓶颈在移动网络环境中尤其是在 HarmonyOS 的 IoT 设备场景中每 KB 都影响加载速度。零压缩的 1MB 图标在弱网环境下可能需要 3-5 秒传输而 300KB 的压缩版本只需 1 秒。不想手写的替代方案如果确实需要真正的压缩但不想手写 2000 行代码有几种选择dart:io的ZLibEncoder一行代码搞定但依赖dart:ioFlutter Web 不可用。第三方纯 Dart 库如archive包提供了纯 Dart 的 GZipEncoder跨平台可用。FFI 调用系统 zlib通过dart:ffi调用系统的libz.so或zlib1.dll最快速但需要处理平台差异。在 E-Brufen 项目中我们选择了零压缩——512x512 的图标约 1MB仅在本地构建时使用一次完全不是问题。等到未来需要生成大尺寸分享图片或批量导出时再引入真正的 Deflate 也不迟。先上线后优化——这种渐进式工程策略在个人项目和早期产品中尤其高效。十三、如何验证你的 zlib 输出写完 zlib 编码器后你肯定想知道生成的数据是否正确。以下是从简单到完整的四种验证方法方法 1用 Python 一行验证Python 标准库自带了 zlib 支持importzlib# 读取你的 zlib 输出去掉 PNG chunk 头部后withopen(output_deflate.bin,rb)asf:compressedf.read()try:decompressedzlib.decompress(compressed)print(f验证通过解压后{len(decompressed)}字节)exceptExceptionase:print(f解压失败{e})# 常见错误# Error -3 while decompressing → 头部或 Adler-32 错误# invalid block type → BTYPE 字段值异常如果 zlib 头部CMF/FLG、Deflate 块结构或 Adler-32 校验和有任何问题Python 会抛出明确的异常信息直接定位问题区域。方法 2用 OpenSSL 命令行大多数系统都预装了 OpenSSL它内置了 zlib 解压功能# 将 zlib 数据写入文件echo-n-e\x78\x01...test.zl# OpenSSL 解压openssl zlib-dtest.zldecompressed.bin# 检查结果xxd decompressed.bin|head方法 3用十六进制编辑器手动检查对于零压缩模式输出的结构非常规律可以用xxd逐字节验证xxd output.zlib|head-10# 你应该看到类似这样的输出# 00000000: 7801 0100 0500 faff 4865 6c6c 6f00 0000 x.......Hello...# ^^ ^^ ^^ ^^^^ ^^^^# | | | | |# 78CMF | | NLEN0xFFFA (~5)# 01FLG |# 01BFINAL(1)BTYPE(00)# LEN5 (0x0005, little-endian)对于零压缩LEN 之后紧跟着原始数据最后 4 字节是 Adler-32。这种逐字节验证能帮你发现位级错误。方法 4单元测试强烈推荐将 zlib 编码器用单元测试覆盖起来确保未来的修改不会引入回归importpackage:test/test.dart;voidtest_zlibCompress(){finalinput[0x48,0x65,0x6C,0x6C,0x6F];// HellofinaloutputzlibCompress(input);// 1. 检查头部expect(output[0],equals(0x78));// CMFexpect(output[1],equals(0x01));// FLG// 2. 检查 BFINAL BTYPEexpect(output[2]0x01,equals(1));// BFINAL1expect((output[2]1)0x03,equals(0));// BTYPE00// 3. 用已知的参考输出做完整比对// 这个参考值可以提前用 Python zlib.compress(bHello) 生成finalexpected[0x78,0x01,// zlib header0x01,// BFINAL1, BTYPE000x05,0x00,// LEN 50xFA,0xFF,// NLEN ~50x48,0x65,0x6C,0x6C,0x6F,// Hello0x02,0x86,0x01,0x26// Adler-32];expect(output,equals(expected));}voidtest_zlibCompress_emptyData(){// 空数据边界测试finaloutputzlibCompress([]);expect(output[0],equals(0x78));expect(output[1],equals(0x01));// 空数据的 Adler-32 (s216)|s1 (016)|1 1expect(output[output.length-4],equals(0x00));expect(output[output.length-3],equals(0x00));expect(output[output.length-2],equals(0x00));expect(output[output.length-1],equals(0x01));}有了这些验证手段你就可以放心地使用手写的 zlib 编码器了。建议在 CI 中跑单元测试每次提交都确保 zlib 输出的正确性。十四、完整的数据流PNG IDAT 块数据流 Raw pixels (RGBA, filter byte 0) → _zlibCompress() → [0x78, 0x01] // zlib header → [1] // BFINAL1, BTYPE00 → [LEN_LO, LEN_HI] // 块长度 → [NLEN_LO, NLEN_HI] // 1补码 → [raw pixel data...] // 原始数据 → [ADLER32 bytes...] // 校验和 → 写入 IDAT chunk小结手写 zlib 压缩听起来吓人但零压缩模式将其简化为 ~30 行代码。这再次验证了 E-Brufen 的工程哲学理解本质后选择最简单的实现。真正的 Deflate 压缩可以等需要时再引入。从 LZ77 Huffman 的经典组合到 Adler-32 中 65521 这个精妙的质数选择到 zlib/gzip/Deflate 三者的定位差异——当我们打开了 zlib 这个黑盒里面处处是计算机科学和工程实践交织的智慧。希望这篇文章不仅帮你在鸿蒙 Flutter 中写出正确的 PNG 编码器也能让你对这一整套压缩技术体系有更深的理解。作者简介E-Brufen DevFlutter 鸿蒙开发者专注于跨平台移动应用开发与心理健康数字化,项目地址AtomGit - E-Brufen。