前几天有人把一张界面截图发给我,说同一块屏幕、同一个导出口,他存出来的 PNG 是 1.2MB,隔壁同事存的只有 180KB,对着屏幕翻来覆去比也看不出差别。更离谱的是,他后来用某个在线工具又压了一遍,体积降到 140KB,结果图标边缘的圆角开始出现一圈脏边。这类问题我在做前端资源打包和截图工具链的时候遇到过太多次,绝大多数都不是"工具不行",而是对 PNG 这个格式内部到底存了什么、哪些字节是可以省的、哪些字节一省就出事没有概念。
PNG 在图像处理里属于那种"天天用、很少有人真拆开看"的格式。它1996年定下第一版规范,到今天依然是界面截图、图标、带透明通道素材、需要反复编辑的中间产物的默认选择。它适合谁?写前端、做客户端、搞图像批处理、写自动化截图脚本、做设计交付规范的人,都应该把它摸透一遍。下面我按照自己实际排查问题的顺序,从设计取舍、块结构、像素编码、压缩链路、手写解析器、优化实战到踩过的坑,一层层拆。看完你至少能做到两件事:拿到一张体积异常的 PNG 知道该往哪个方向查,以及知道什么时候该果断放弃 PNG 换别的格式。
1. PNG 的体积为什么能差出十倍:先看清它的设计取舍
1.1 无损不是"什么都不丢",而是"只丢冗余"
很多人对 PNG 的第一个误解是把"无损"理解成"原封不动存像素",于是得出"无损必然大"的结论。实际上 PNG 的无损指的是解码后得到的像素值与原像素值逐位相等,中间过程做了大量减法:它先用行内预测把相邻像素的差值算出来,再用 DEFLATE 对差值序列做字典压缩,最后对重复的字节串做游程编码。一张纯色背景的界面截图,相邻像素差值几乎全是 0,压缩率能到几十比一;而一张噪点密集的照片,差值全是随机数,压缩率可能连 1.2 都比不上。
这就解释了一个常见现象:同一套导出参数,截代码编辑器能压到 200KB,截一张带渐变阴影的插画就飙到 3MB。不是你参数设错了,是内容本身的可压缩性天差地别。我在做资源体积预算的时候,从来不给"PNG 平均压缩率"设一个固定系数,而是按内容类型分开看:纯色 UI、文字截图、带 alpha 的图标、实景照片,这四类的体积量级完全不在一个档位上。
另一个关键取舍是过滤器只做行内和上一行的参考。PNG 的预测只用左边一个像素、上面一个像素、左上角一个像素,没有跨更多行的建模能力。JPEG 会把图切成 8×8 块做频域变换,WebP 有更复杂的帧内预测模式。PNG 选择了一条简单到可以在 1996 年的机器上快速解码的路,代价就是对复杂纹理不友好。理解了这一点,你就不会再纠结"为什么 PNG 压不过 WebP"——它们从设计目标上就不是一路的。
1.2 索引色与真彩色两条路线的分叉
PNG 规范里有一件事经常被忽略:它其实内置了两套完全不同的存图模式。一套是真彩色(RGB / RGBA),每个像素直接存三个或四个分量;另一套是索引色,像素里存的是调色板下标,真正的颜色放在一个叫 PLTE 的块里,最多 256 种。
这两条路线的体积差距可以非常夸张。一张 512×512 的纯图标,用 RGBA 存,每像素 4 字节,原始数据 1MB;改成 8 位索引色,每像素 1 字节,原始数据 256KB,如果只有十几种颜色,还能用 4 位甚至 2 位位深,直接降到 128KB 或 64KB。再叠上 DEFLATE,最终文件可能只有几 KB。这就是为什么大量图标资源在交付时会被转成索引色。
代价也很明确:索引色最多 256 色,超过就得做量化,量化就是有损。而且索引色的透明只能靠 tRNS 块给每个调色板项指定一个 alpha 值,做不了真彩色那种逐像素的平滑半透明渐变。所以我的判断标准很粗暴:
| 素材类型 | 推荐颜色模式 | 原因 |
|---|---|---|
| 纯色图标、单色线条图 | 索引色(1/2/4/8 位) | 颜色数少,位深能压到极低 |
| 带柔和阴影的图标 | 灰度+alpha 或 RGBA | 半透明渐变用 tRNS 会出硬边 |
| 界面截图、文字截图 | RGB(无 alpha) | 没有透明需求就别多存一个通道 |
| 需要后期合成的素材 | RGBA 16 位(视情况) | 保留色深余量,避免多次编辑失真 |
这张表我贴在团队文档里很久了,实测下来按它分类,同一批素材平均能省 40% 以上的体积,而且不需要引入任何有损步骤。
2. 块结构:把一个 PNG 拆到字节级别
2.1 8 字节签名与块的三段式结构
PNG 文件开头固定是 8 个字节:89 50 4E 47 0D 0A 1A 0A。这 8 个字节不是随便定的:第一个字节 0x89 最高位为 1,用来检测那些会把高位剥掉的七位传输通道;后面的50 4E 47是 ASCII 的 "PNG";0D 0A是一对回车换行,专门用来抓那些会做换行转换的文本模式传输;1A是文件结束符,防止文件被截断后还继续往下读;最后一个0A再补一个换行。我第一次看到这段设计说明的时候觉得挺有意思——一个二进制格式,为了在各种不靠谱的传输管线上活下来,把能想到的坑都堵了一遍。
签名之后全是块。每个块的结构是固定的四段:4 字节长度(只算数据段,不算自己、不算类型、不算校验)、4 字节类型码、数据、4 字节 CRC32。CRC 的计算范围是"类型码 + 数据",不含长度字段,这一点很多人第一次写解析器时会算错,导致所有块都校验失败。
块类型码的四个字母有约定俗成的含义:第一个字母大写表示关键块,小写表示辅助块;第二个字母大写表示公有块,小写表示私有块;第三个字母必须大写(保留位);第四个字母小写表示这个块可以被安全复制到不认识它的解码器里。比如IDAT全大写是关键块,tEXt首字母小写是辅助块,iCCP第二字母小写是私有扩展。
这个设计带来的实际好处是:解析器遇到不认识的辅助块可以直接跳过,长度字段保证你能准确跳到下一个块。所以你在 PNG 里塞自定义元数据,理论上不会破坏任何标准解码器的兼容性。
2.2 关键块与辅助块的分工
关键块只有四个,缺一个文件就不合法:
- IHDR:固定 13 字节,包含宽度、高度、位深、颜色类型、压缩方法、滤波方法、隔行方式。必须第一个出现。
- PLTE:调色板,颜色类型 3 必需、2 和 6 可选、0 和 4 禁止。每项 3 字节 RGB。
- IDAT:图像数据,可以拆成多个连续块,解码时把它们的数据段按顺序拼起来再解压。
- IEND:空数据块,标记结束。
辅助块里我觉得最值得掌握的有这么几个:tRNS提供透明度(索引色模式下是每个调色板项的 alpha,真彩色模式下是单个透明色键);gAMA记录 gamma 值,单位是真实值乘 100000;sRGB声明图像符合 sRGB 色彩空间,附带一个渲染意图字节;iCCP可以嵌入完整 ICC 配置文件;pHYs记录像素密度,决定打印尺寸;tEXt/zTXt/iTXt存文本元数据;bKGD给背景色建议。
这里有一个非常容易踩的坑:gAMA和sRGB同时存在时,规范要求解码器优先信sRGB,但很多老工具会拿gAMA去做显示校正。如果你从某个图像编辑软件导出时带了奇怪的 gamma 值,再在浏览器里打开,颜色就会偏。我在排查"同一张图在设计和前端两边的颜色对不上"时,第一件事就是把这些块的十六进制打出来看。
3. 像素编解码:颜色类型、位深与那五行滤波器
3.1 五种颜色类型与位深组合
颜色类型只有 5 个合法值,组合起来是:
| 颜色类型 | 含义 | 允许位深 | 每像素通道数 |
|---|---|---|---|
| 0 | 灰度 | 1、2、4、8、16 | 1 |
| 2 | 真彩 RGB | 8、16 | 3 |
| 3 | 索引色 | 1、2、4、8 | 1(下标) |
| 4 | 灰度 + alpha | 8、16 | 2 |
| 6 | 真彩 RGBA | 8、16 | 4 |
位深指的是每个通道的位数,不是每像素。所以 8 位 RGBA 是每像素 32 位,16 位 RGBA 是每像素 64 位。这一点在算内存占用的时候经常被搞混。一张 4000×3000 的 16 位 RGBA 图,解码后原始缓冲区是 4000×3000×8 = 96MB,如果你在上传服务里不加限制地解码,几张图就能把进程内存打爆。我在写图片处理服务时,第一道防线就是在读 IHDR 之后立刻按公式估算内存,超过阈值直接拒掉,绝不去碰后续的 IDAT。
还有一个细节:1、2、4 位位深是按字节打包的,一行里多个像素共享一个字节,高位在前。而且每一行的字节数必须是整数,如果位深 1、宽度 5,那一行只用了 5 位,剩下的 3 位要用 0 补齐到 1 个字节。计算行字节数的公式是ceil(width × bits_per_pixel / 8)。很多手写解析器在这里翻车,扫完第一行后面全错位,表现就是解出来的图变成斜条纹。
3.2 滤波器的预测逻辑与选行策略
IDAT 解压出来的数据不是直接的像素,而是每行前面多一个字节的滤波类型,后面才是经过预测后的字节。滤波类型有五种:
- 0 None:不预测,直接存。
- 1 Sub:减去左边像素。
- 2 Up:减去上一行同位置像素。
- 3 Average:减去左边和上边像素的平均值(向下取整)。
- 4 Paeth:用一个线性预测函数选左边、上边、左上三个里最接近的那个。
Paeth 的公式值得单独记一下:设 a 为左、b 为上、c 为左上,先算p = a + b - c,再分别算pa = |p - a|、pb = |p - b|、pc = |p - c|,谁最小就选谁。这个预测器对渐变和斜线特别有效,是编码器默认的首选。
关键在于,滤波类型是逐行独立选择的。编码器可以对每一行试五种滤波,算一下哪种产生的残差绝对值之和最小就选哪种。这就解释了为什么不同软件导出的同一个画面,体积能差出一截——滤波策略不一样。一些编码器为了速度只做启发式选择,甚至全用类型 0,体积自然大。
注意:解码时滤波器必须严格按照行顺序处理,因为 Up 和 Paeth 都依赖上一行已经还原的像素值,不是依赖上一行的残差。这一点写循环的时候特别容易写错。
4. DEFLATE 在 PNG 里的真实表现
4.1 32KB 窗口与游程编码的配合
IDAT 里的数据是 zlib 格式(2 字节头 + DEFLATE 数据流 + 4 字节 Adler-32 校验)。DEFLATE 的核心是两层:先用 LZ77 在最近 32KB的数据里找重复串,用"距离 + 长度"代替原文;再对字面量和长度、距离做两套霍夫曼编码。
32KB 这个窗口大小对 PNG 来说是一个非常实际的约束。一张 1080 宽、每像素 4 字节的图,一行就是 4320 字节,32KB 大约覆盖 7 到 8 行。这意味着如果一张图里存在"每隔十几行重复一次"的图案,LZ77 是抓不到的。这也解释了为什么把一张长图切成多个 IDAT 块,对压缩率基本没影响——DEFLATE 的窗口是跨块连续的,切块只影响流式解码的粒度,不影响压缩效果。
真正影响压缩率的是行与行之间的相关性。这也是为什么滤波这么重要:滤波把"这一行和上一行很像"这个事实,转化成了"残差大部分是 0"这个更容易被压缩的事实。全 0 的字节串在 DEFLATE 里会被游程编码吃掉,长度 258 的匹配只需要几个 bit。
4.2 压缩等级调到头为什么还是没变
很多人试过把导出工具的压缩等级从 6 调到 9,结果体积只降了 2%。原因有两个。
一是 DEFLATE 的等级只影响搜索重复串的贪心程度和霍夫曼表的构造方式,它不会改变数据的可压缩性上限。如果图像本身滤波做得差、残差全是随机字节,等级 9 也只能眼睁睁看着。
二是很多导出工具在调用压缩库之前,已经把数据写得比较"规整"了,比如强制每行用同一个滤波类型,这时候再高的压缩等级也救不回来。真正有效的手段是换滤波策略 + 换压缩实现。像 zopfli 这类实现会用更长的计算时间换取几个百分点的体积,对最终交付的静态资源值得,对构建过程中的中间产物就不值得。
我自己的一条经验线是:交互式导出用默认等级,跑 CI 的资源流水线用高强度重压缩,两者分开配置。曾经有次把全站图标的构建步骤统一改成高强度压缩,构建时间从 40 秒涨到 6 分钟,体积省了 11%。值不值取决于你的缓存策略,别一刀切。
5. 手写一个块解析器:验证理解的最快方式
5.1 读块、算 CRC、打表
理解了结构之后,最快的验证方式是自己写三十行代码把块打出来。我用 Python 写过一个,放在工具箱里用了好几年,排查体积异常非常顺手:
import struct, binascii SIG = b'\x89PNG\r\n\x1a\n' def walk_chunks(path): with open(path, 'rb') as f: data = f.read() if data[:8] != SIG: raise ValueError('not a png') pos = 8 while pos < len(data): (length,) = struct.unpack('>I', data[pos:pos + 4]) ctype = data[pos + 4:pos + 8] body = data[pos + 8:pos + 8 + length] (crc,) = struct.unpack('>I', data[pos + 8 + length:pos + 12 + length]) calc = binascii.crc32(ctype + body) & 0xffffffff yield ctype.decode('latin-1'), length, crc == calc, body pos += 12 + length打印出来的表长这样(示意):
| 块类型 | 数据长度 | CRC 校验 | 说明 |
|---|---|---|---|
| IHDR | 13 | 通过 | 图像头信息 |
| iCCP | 3142 | 通过 | 嵌入了 ICC 配置 |
| pHYs | 9 | 通过 | 含像素密度 |
| IDAT | 65524 | 通过 | 第一段图像数据 |
| IDAT | 41230 | 通过 | 第二段图像数据 |
| IEND | 0 | 通过 | 结束 |
我第一次跑出这张表的时候才意识到,一张 200KB 的图里居然有 3KB 是 ICC 配置。如果你的图只是用在网页上,而目标环境已经假定 sRGB,这块配置大概率可以去掉,省下来的体积比调压缩等级明显得多。但反过来,如果这张图要进印刷流程或者要跨设备保色,砍掉它就是事故。
5.2 从 IDAT 还原像素的完整链条
完整还原像素的步骤我整理成一条链,任何一环错了都会出怪图:
- 按顺序拼接所有 IDAT 的数据段。
- 用 zlib 解压得到原始扫描线数据。
- 按 IHDR 算出每像素位数 bpp 和每行字节数。
- 如果隔行标志为 1,按 Adam7 的七遍顺序重排(这一条最容易漏)。
- 逐行读第一个字节作为滤波类型,按公式还原。
- 按颜色类型解释通道,索引色还要查 PLTE 和 tRNS。
第 4 步的 Adam7 是七遍扫描:每遍取一个 8×8 网格里固定位置的像素,第一遍取 (0,0),第二遍取 (4,0),第三遍取 (0,4),以此类推,越往后间隔越密。每遍自己的数据也是按行带滤波字节的。手写解码器时如果没处理隔行标志,得到的图会像被撕成碎块又拼错位。我建议新手先别碰隔行,直接把标志读出来,遇到 1 就抛异常,逼自己用工具转成非隔行再处理,能省掉大半天调试。
6. 优化实战:一张 1.2MB 截图压到 180KB 的完整链路
6.1 逐层排查:先分清是"结构冗余"还是"信息冗余"
回到开头那个 1.2MB 的案例。我的排查顺序固定是三步:先看块组成,再看颜色模式,最后才动压缩参数。
第一步打块表,发现里面有两个iCCP(有些工具会重复写)、一个tEXt里塞了完整的软件版本和导出时间、还有一个pHYs。这些辅助块加起来接近 40KB,对一张放在网页上的截图来说纯属浪费。
第二步算理论下限。这张图 1440×900,8 位 RGB,无 alpha,原始数据 1440×900×3 = 3.888MB,加每行 1 字节滤波字节,约 3.89MB。当前文件 1.2MB,压缩比约 3.2:1。对于一个界面截图来说,这个比例偏低——正常应该能到 8:1 以上。说明要么滤波策略差,要么压缩实现太保守。
第三步才是动手。我先用无损重压缩工具跑一遍,看纯压缩层面的空间:
# 只做无损重压缩,尝试不同的滤波与压缩组合 oxipng -o 4 --strip safe input.png -o step1.png--strip safe的意思是只删掉规范允许安全删除的辅助块,不会误删影响显示色彩的关键信息。这一步跑完从 1.2MB 降到 640KB 左右。
6.2 参数选择与验证方法
接下来判断能不能上索引色。我先把图里的唯一颜色数统计一下——如果是一张界面截图,往往只有几百种颜色,量化到 256 色肉眼几乎看不出差别。这时用有损量化:
# 量化到 256 色以内,带抖动,速度档位调低换取质量 pngquant --quality=70-90 --speed 1 --strip --output step2.png step1.png--quality=70-90是允许算法在达不到 70 的质量下限时放弃量化,避免把细节压烂。--speed 1是最慢档,换来更好的调色板选择。跑完体积到 190KB。
最后一步是收尾重压缩,把量化后重新写出的数据再压一遍:
oxipng -o 6 --strip safe --alpha step2.png -o final.png最终 178KB,从我肉眼看和原图没区别。
验证环节我从来不看"感觉行不行",而是固定做三件事:一是拿原始图和结果图做逐像素差异统计,算一下最大通道偏差和平均偏差;二是在深色和浅色两种背景下各看一眼(透明素材必须这么做);三是把结果图放回实际页面,在 100% 缩放下用不同缩放的屏幕看一遍,因为量化的瑕疵在缩放插值后才会暴露。
提示:有损量化的图不能作为源文件保留。我见过团队把量化后的图当素材继续二次编辑,反复几次后边缘全是噪点。规范做法是源文件走无损,交付产物走量化,两条线分开存。
7. 那些年踩过的坑:透明、色彩配置与格式误用
7.1 透明边缘发灰与预乘
带 alpha 的 PNG 最容易出的问题,是素材放在深色背景上时边缘出现一圈灰白的脏边。原因通常不是 PNG 本身,而是生成过程做了 alpha 预乘却没有正确反预乘。预乘的做法是把每个颜色分量乘上 alpha 值,好处是合成时省一次乘法,坏处是低 alpha 区域的颜色信息被压缩了,一旦再被某个工具当作普通 RGBA 读取,边缘就会发灰。
判断方法很直接:把图片放到纯黑背景上看边缘。如果边缘亮了一圈,就是预乘/反预乘出了问题。修复办法是回源头重新导出,或者在生成阶段明确声明——不要在中间环节反复转格式。我一般要求设计交付时明确标注"未预乘",代码侧统一按直通 alpha 处理。
另一个透明相关的坑是索引色图标的 tRNS。索引色的透明度是逐调色板项的,一个颜色要么全透明要么全不透明(alpha 只能是 0 或 255,规范里允许中间值但很多老解码器不支持)。所以带柔和阴影的图标一旦被转成索引色,阴影会变成一圈硬边。这就是为什么我的交付规范里明确写了:带渐变阴影的图标不许量化成索引色。
7.2 元数据、色彩配置与平台差异
第三个高频问题是色彩对不上。同一条色值#3A6EA5,在设计软件里导出 PNG 时如果带了 ICC 配置,浏览器认这个配置做转换,屏幕上看起来就会和你填的值有偏差;如果导出时又顺手删了配置但保留了gAMA,另一批软件会按那个 gamma 值去调,又是另一种偏差。最终表现就是"同一个色值,三个地方三种颜色"。
我的处理原则:面向屏幕显示的资源,统一在导出时转换到 sRGB,写入sRGB块,不嵌大体积 ICC;面向印刷或跨设备保色的,保留完整 ICC,并且明确记录转换链路。两个批次不混用,也就不会出现"某些图上颜色偏了"这种玄学问题。
还有一类坑属于格式误用:把照片存成 PNG。一张 4000×3000 的照片存 PNG 轻轻松松 20MB 起,换 JPEG 质量 90 只要 2MB,肉眼看不出差别。反过来,把带文字的截图存成 JPEG,文字边缘立刻出现一圈彩色的振铃噪点,怎么调质量都救不回来。这条线其实很清楚:需要逐像素精确、需要透明、颜色数少、需要反复编辑的走 PNG;连续色调、体积敏感、不要求透明的走有损格式。真正难的是那种"既要透明又要体积"的场景,这时候该考虑的是支持 alpha 的现代格式,或者干脆把图和背景合并后转成不透明格式,别硬扛。
最后分享一个小技巧,也是我这些年养成的习惯:拿到一张体积异常的 PNG,先别急着上工具,花三十秒把块表打出来,看看 IHDR 的颜色类型和位深、看看有几个 IDAT、有没有多余的辅助块。这三项信息基本就能定位问题出在"存法不对"还是"压法不对",剩下的只是照方抓药。工具永远有更快的,但知道该用哪个工具、为什么用它,才是省时间的地方。