PNG工程解析:块结构、无损压缩、Alpha通道与体积优化
1. 先把 PNG 这个概念拆开看
1.1 从一次截图“发虚”的排查说起
前阵子帮一个做电商详情页的朋友看问题,他截了一张后台数据面板的图,粘到设计稿里,放大一看字全是糊的,还带一圈灰蒙蒙的噪点。他第一反应是显示器坏了,第二反应是截图工具不行。我把图拖进十六进制编辑器看了眼前八个字节——FF D8 FF E0,JPEG。这就是典型的“截图工具默认给你存了有损格式”,跟显示器半点关系没有。让他改存 PNG,同样的画面,文字边缘立刻锐利,色块边界干净,文件体积还比那张 JPEG 小了将近一半,因为满屏纯色和文字线条正好是 PNG 最擅长的场景。
这件事挺有代表性:PNG(Portable Network Graphics)在图像处理链路里出现的频率极高,但真正能把它讲清楚的人不多。大多数人只知道“PNG 是无损的、支持透明”,再往下问压缩怎么做、Alpha 通道为什么会出黑边、为什么同样的图导出体积能差三倍,就说不下去了。而这些问题恰恰是日常工作中最容易踩坑的地方——切图体积超标、合成边缘发脏、16 位图被悄悄截成 8 位、明明是无损格式却越优化越大。
所以这篇内容我打算从工程实现的视角把 PNG 掰开:它内部长什么样、无损压缩到底靠什么生效、Alpha 通道有哪些隐含约定、怎么用几十行代码自己解析一个 PNG 文件、以及在实际项目里控制体积时哪些手段真有效、哪些纯属安慰剂。适合前端、客户端、图像算法、设计交付链路上的同学看,也适合只是想搞明白“为什么我这图一放大就糊”的非技术岗位。不需要你懂信息论,但看完之后你去改一行导出参数,心里会有底。
1.2 PNG 的四层结构:签名、数据块、压缩流、校验
很多人以为 PNG 是个“压缩过的位图”,其实它更像一个容器。一个 PNG 文件由四层东西叠起来,从上往下依次是:
第一层是 8 字节的文件签名,固定为89 50 4E 47 0D 0A 1A 0A。这串东西不是随便拍的:50 4E 47就是 ASCII 的“PNG”,前面 0x89 用来检测传输过程中高位丢失(老式 7 位通道会把它吃掉),中间的0D 0A是 CRLF,用来识别文本模式下的换行符转换,末尾的1A是 DOS 的 EOF 标记,防止在命令行里type一下就刷屏。一个格式的稳健性往往就藏在这些细节里,这段签名能同时挡住三类不同的传输损坏。
第二层是一串数据块(chunk),这是 PNG 真正的骨架。每个块的排布非常规整:4 字节长度(只算块内容,不含长度字段本身、类型字段和 CRC)、4 字节类型(ASCII 大写小写组合,大小写是有语义的)、块数据、4 字节 CRC32 校验值(覆盖“类型 + 数据”)。长度字段最大允许 2³¹-1,实际使用中单个 IDAT 一般控制在几十 KB 到几 MB 之间。
第三层是真正装像素的地方——IDAT 块,里面是zlib/deflate压缩流。注意,IDAT 里装的不是裸 deflate,而是带 2 字节头和 4 字节 Adler-32 校验的 zlib 格式。图片数据可以被切成多个 IDAT,解码时拼接起来视作一个连续的流,这是为了流式编码和网络分块传输设计的。
第四层是辅助信息块,颜色空间(sRGB、iCCP、gAMA、cHRM)、物理尺寸(pHYs)、文本(tEXt、zTXt、iTXt)、透明信息(tRNS)、背景色(bKGD)、动图控制(acTL、fcTL、fdAT)都在这层,后面会展开。
块类型的大小写是有讲究的,这点很多人不知道。首字母大写表示关键块(critical),解码器不认识就得报错;小写表示辅助块,不认识可以安全跳过。第二字母大写表示公开块,小写表示私有块。第三字母必须大写(保留给未来扩展)。第四字母小写表示可以安全复制,大写表示不可安全复制(比如IDAT,复制它是没意义的,因为依赖前面的状态)。你看IHDR全大写,所以它不可复制不可忽略;tEXt首字母小写、第四字母大写,所以它是辅助块,但复制过去也没意义(文本跟着图走就行)。
三个关键块的地位是硬性的:IHDR必须是第一个块,IEND必须是最后一个块,所有IDAT必须连续排列,中间不能插入任何其他块。这条“IDAT 必须连续”的规则,是后面排查“图显示一半是灰的”这类问题的钥匙。
1.3 各种图片格式的取舍,别死磕 PNG
选格式这件事,我的经验是先看内容类型再看需求,而不是先看“哪个更高级”。下面这张表是我自己总结的对照,平时做方案评审直接拿出来用:
| 格式 | 压缩性质 | 透明支持 | 典型体积(同画面) | 适合内容 | 不适合内容 |
|---|---|---|---|---|---|
| PNG | 无损 | 完整 Alpha(8/16 位) | 中到大 | UI 截图、图标、线条图、需要二次编辑的素材 | 照片、大面积渐变 |
| JPEG | 有损 | 不支持 | 小 | 照片、复杂色彩渐变 | 文字、锐利边缘、需要透明的图 |
| GIF | 无损但仅 256 色 | 二值透明 | 小到中 | 老式简单动图 | 色彩丰富的内容 |
| WebP | 无损/有损双模 | 完整 Alpha | 通常小于 PNG | 网页图、需要兼顾体积和透明 | 部分老工具链不支持 |
| 索引 PNG | 无损但量化到 256 色 | 支持 | 小 | 图标集、简单插画 | 渐变、照片 |
这张表里最容易误判的是“照片能不能用 PNG”。答案是技术上完全可以,但没必要——一张 3000×2000 的照片,PNG 无损压缩后常常在 8~15MB,JPEG 质量 85 只要 800KB 左右,而肉眼看不出差别。反过来,一张 UI 截图用 JPEG,文字边缘会产生振铃伪影(ringing),再怎么调高质量也救不回来,因为文字的高频信息正好落在 JPEG 量化表最先砍掉的那部分。
还有一个常见误区是把“无损”理解成“体积大”。其实对 UI 截图、纯色背景、重复图案这类内容,PNG 的压缩率能到 90% 以上——我测过一张 1440×900 的纯色管理后台截图,原始像素数据约 5.2MB,存成 PNG 只有 60 多 KB。真正压不动的是照片那种逐像素随机性强的数据,这才是 PNG 的短板。所以判断标准不是“PNG 无 unfaithful”,而是“这张图里有多少可以被预测的冗余”。
2. PNG 的无损压缩到底做了什么
2.1 滤波:压缩之前先把数据“捋平”
这是 PNG 最容易被忽略、也最关键的一步。如果直接把像素字节丢给 deflate,压缩效果会很差,因为相邻像素之间明明高度相关,但字节流里看不出这种规律。PNG 的做法是先做一次预测编码——把每个字节换成“它和预测值的差”,差值集中在 0 附近,字节流里就会出现大量重复的 0x00,deflate 这时才压得动。
具体做法是按行处理,每行开头放一个字节表示本行用的滤波类型,剩下的是真实数据。规范定义了 5 种:
- 类型 0(None):不预测,原样输出。
- 类型 1(Sub):用左边那个像素做预测,存
当前 - 左。 - 类型 2(Up):用上一行同位置的像素做预测,存
当前 - 上。 - 类型 3(Average):用左和上的平均值预测,存
当前 - floor((左+上)/2)。 - 类型 4(Paeth):这行最聪明,取左(a)、上(b)、左上(c)三个参考值,算
p = a + b - c,然后看 a、b、c 里谁离 p 最近就用谁当预测值。
为什么 Paeth 常被选为最优?因为它相当于在判断“这个位置的梯度更偏向水平还是垂直”。如果左边和上边都跟左上角接近,说明这里是平坦区域,三个候选差不多;如果左边明显比左上亮、上边和左上一样,说明存在垂直边缘,这时选左边的值更准。这个逻辑用一行代码就能表达:
def paeth(a, b, c): p = a + b - c pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) if pa <= pb and pa <= pc: return a elif pb <= pc: return b return c注意这里的 a、b、c 都是单个字节通道,不是整个像素。也就是说 RGBA 图里,R 通道的预测只参考左边像素的 R 通道、上边像素的 R 通道,不会跨通道借值。这一点和很多人的直觉相反,但如果跨通道参考,反而会破坏通道内部的连续性。
滤波器怎么选?规范只要求编码器自己挑,解码器必须全都支持。实践中编码器会做一次启发式尝试:对每一行,用全部 5 种滤波都算一遍,选绝对值和最小的那个。这个指标叫“最小绝对差之和(Minimum Sum of Absolute Differences)”,原理是差值越靠近 0,熵越低,压得越好。所以有些编码器慢,不是因为它压缩算法复杂,而是它在逐行做这道选择题。
这解释了一个实际现象:同一张图,用不同工具导出,PNG 体积能差 20% 以上。差别往往不在 deflate 层,而在滤波策略的选择上。你手动把一行滤波类型从 0 改成 4,体积可能就变了一个档。
2.2 DEFLATE 与 LZ77:压缩的真正内核
滤波之后的数据进入 deflate。deflate 是 LZ77 加哈夫曼编码的组合:先用滑动窗口找重复字符串,把重复部分换成“往回退 N 字节、复制 M 字节”的引用;剩下的字面量和新长度信息再用哈夫曼树编码,频率高的符号用短码。
PNG 用的窗口是 32KB,这是个固定值,不是可调的。所以你会看到一个现象:图片宽度很大时,同一行内部的距离可能超过 32KB,滑动窗口就够不着了,压缩率下降。举个例子,一张 4000 像素宽的 RGBA8 图,一行数据就是 16000 字节,窗口只能覆盖两行;而一张 800 宽的图,一行 3200 字节,窗口能覆盖十行,跨行重复的图案更容易被抓住。这也是为什么“把长图切成几段”有时候能减小总体积——不是绝对的,但值得一试。
deflate 的压缩级别是 0~9,级别越高搜索越彻底,代价是时间。PNG 规范里压缩级别不是文件属性,纯属编码器行为,解码端完全无感。所以我给的建议是:交付给用户的资源,用最高级别跑一遍,反正只算一次;构建流水线里为了速度用中等级别,最后产物再压一次。
还有一层常被混淆:zlib 头部的 2 个字节里包含了窗口大小和压缩级别信息(FLEVEL),但这东西对解码没影响,唯一的用处是某些工具会根据它判断“这图是不是已经压过了”。某些无损优化工具会重写这两个字节来“显得”压过了,实际数据没变——这种优化纯属数字游戏,别被报告里的“节省 0.3%”骗了。
2.3 位深与色彩类型的组合矩阵
IHDR的 13 个字节里,除了宽高,还有 5 个各占 1 字节的字段:位深、色彩类型、压缩方法、滤波方法、交错方法。后三个在现行规范里都被固定为 0(压缩方法只有 0 号 deflate,滤波方法只有 0 号那 5 种,交错只有 0 无交错的 1 号 Adam7),真正的变化在位深和色彩类型上。
合法组合是有严格限制的,不是任意搭配:
| 色彩类型 | 含义 | 通道数 | 允许位深 | 单像素最大位数 |
|---|---|---|---|---|
| 0 | 灰度 | 1 | 1, 2, 4, 8, 16 | 16 |
| 2 | 真彩 RGB | 3 | 8, 16 | 48 |
| 3 | 索引色 | 1 | 1, 2, 4, 8 | 8 |
| 4 | 灰度 + Alpha | 2 | 8, 16 | 32 |
| 6 | 真彩 + Alpha | 4 | 8, 16 | 64 |
看这张表要抓住两个点。第一,类型 3(索引色)的位深指的是调色板索引的位数,8 位意味着最多 256 种颜色,跟“每个通道 8 位”是两回事。索引色图必须配一个PLTE块,最多 256 项,每项 3 字节 RGB。第二,类型 2 和 6 不允许位深低于 8,你不能有 4 位的 RGB 图,因为那需要打包两个像素进一个字节,规范嫌麻烦没做。
估算内存时用得上这张表。比如一张 1920×1080 的 RGBA8 图,bits_per_pixel = 4 × 8 = 32,每行字节数ceil(1920 × 32 / 8) = 7680,加上每行 1 字节滤波标记是 7681,乘 1080 行等于8,295,480 字节,约 7.91MB——这是解码后需要的内存,跟磁盘上的文件大小没关系。很多移动端 OOM 就是按文件大小估内存导致的,一张 300KB 的 PNG 解出来可能是 8MB,差了两个数量级。
2.4 Adam7 交错:那个“先模糊后清晰”的老设计
如果你见过网页上图片加载时先出一个马赛克版本、再逐层清晰的效果,那就是交错(interlace)在起作用。PNG 的 Adam7 把图像分成 7 个 pass,每个 pass 只取特定的像素子集,解码器可以逐层叠加出完整图像。
七个 pass 的采样规律是固定的:第一个 pass 每隔 8 个像素取一个,形成 1/64 的缩略图;第二个 pass 横向还是 8、纵向也是 8 但偏移 4;后面依次加密。完整的参数是(x 起点, y 起点, x 步长, y 步长):(0,0,8,8)、(4,0,8,8)、(0,4,4,8)、(2,0,4,4)、(0,2,2,4)、(1,0,2,2)、(0,1,1,2)。
这个东西在拨号上网时代很有价值,现在基本是负资产。原因有三个:交错的图压缩率通常比不交错低(因为破坏了相邻像素的连续性,滤波效果变差),文件反而更大;解码要跑 7 遍,移动端解析开销明显;现代网络带宽足够,渐进加载有更好的方案(比如低质量占位图、响应式多尺寸)。所以除非你的目标链路明确要求,导出时关掉交错是更划算的选择。这个选项在很多导出面板里叫“交错”或“渐进式”,默认可能是开着的,值得检查一下。
3. 动手解析一个 PNG,别只停留在概念
3.1 用三十行代码把块结构读出来
光看规范容易虚,我习惯直接写代码验证。Python 标准库里的struct和zlib就够了,不需要任何第三方依赖:
import struct, zlib def iter_chunks(path): with open(path, 'rb') as f: data = f.read() assert data[:8] == b'\x89PNG\r\n\x1a\n', '不是合法的 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]) yield ctype.decode('ascii'), body, crc pos += 12 + length if ctype == b'IEND': break跑一下for t, b, c in iter_chunks('test.png'): print(t, len(b)),输出大概是这样:IHDR 13、sRGB 1、IDAT 65536、IDAT 32768、IEND 0。这一眼就能看出几个信息:IHDR 必然是 13 字节(因为它字段固定),IDAT 被切成了多块,sRGB 块只有 1 字节数据。
CRC 校验值可以顺手验证一下,这也是排查文件损坏的直接手段:
def check_crc(ctype: bytes, body: bytes, crc: int) -> bool: return zlib.crc32(ctype + body) & 0xFFFFFFFF == crc注意 CRC 覆盖范围是类型字段加数据,不含长度字段。我见过有人算 CRC 时把长度也算进去,结果永远对不上,然后怀疑是文件坏了。如果某一块的 CRC 不对,说明文件在传输或存储过程中被改动了,这时候解码器可以报警也可以忽略(很多浏览器选择忽略,所以你会看到“图能显示但颜色不对”这种诡异现象)。
3.2 把 IHDR 的每个字节拆开看
IHDR的 13 字节用一次struct.unpack就能全解出来:
w, h, depth, ctype, comp, filt, inter = struct.unpack('>IIBBBBB', ihdr_body)这里要注意字节序是大端(网络序),>II表示两个 4 字节无符号整数。拿到的七个值可以直接对照前面那张表检查合法性。我写个自检函数:
VALID_DEPTH = { 0: {1, 2, 4, 8, 16}, 2: {8, 16}, 3: {1, 2, 4, 8}, 4: {8, 16}, 6: {8, 16}, } def validate_ihdr(w, h, depth, ctype, comp, filt, inter): assert 0 < w <= 2**31 - 1 and 0 < h <= 2**31 - 1, '尺寸越界' assert ctype in VALID_DEPTH, f'未知色彩类型 {ctype}' assert depth in VALID_DEPTH[ctype], f'类型 {ctype} 不支持位深 {depth}' assert comp == 0 and filt == 0 and inter in (0, 1), '方法字段非法' return True这个自检在实际工作中有用。某些老工具会生成色彩类型 3 但位深 16 的非法文件,正规解码器直接拒收,而宽松解码器会“猜”成 8 位处理,结果颜色全乱。你自己解析一遍,就能定位到底是谁的锅。
顺便算一下每像素字节数,这个值后面反滤波要用:
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[ctype] bits_per_pixel = channels * depth bpp = max(1, (bits_per_pixel + 7) // 8) # 滤波偏移量,至少 1 row_bytes = (w * bits_per_pixel + 7) // 8 # 每行有效字节数bpp那里为什么要max(1, ...)?因为位深小于 8 时(比如 1 位灰度),一个字节里装 8 个像素,滤波偏移按规范要当成 1 处理,不然会越界。这个细节在实现解码器时非常容易漏,漏了就是段错误。
3.3 解压 IDAT 并还原滤波
把所有 IDAT 的 body 按顺序拼起来,用zlib.decompress解一次,得到的就是“每行前面带一个滤波标记字节”的原始数据。接下来的反滤波是整个流程里最需要小心的部分:
def unfilter(raw, h, row_bytes, bpp): out = bytearray() prev = bytearray(row_bytes) pos = 0 for _ in range(h): ftype = raw[pos]; pos += 1 line = bytearray(raw[pos:pos + row_bytes]); pos += row_bytes if ftype == 1: for i in range(bpp, row_bytes): line[i] = (line[i] + line[i - bpp]) & 0xFF elif ftype == 2: for i in range(row_bytes): line[i] = (line[i] + prev[i]) & 0xFF elif ftype == 3: for i in range(row_bytes): a = line[i - bpp] if i >= bpp else 0 line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF elif ftype == 4: for i in range(row_bytes): a = line[i - bpp] if i >= bpp else 0 b = prev[i] c = prev[i - bpp] if i >= bpp else 0 line[i] = (line[i] + paeth(a, b, c)) & 0xFF out += line prev = line return bytes(out)有三个坑我必须点出来。第一,Sub 和 Paeth 用的“左边像素”是已经还原过的,不是原始编码值。因为反滤波是逐字节顺序进行的,左边字节在算当前字节之前已经修好了,所以直接读line[i - bpp]就对。第二,Average 里的a也是还原后的值,同理。第三,每一行的prev必须更新成还原后的行,不能拿原始带差值的数据当上一行,否则第二行开始全错。我最早写这个函数时就栽在第三条上,出来的图上半部分正常、下半部分逐渐崩坏,像被水泡过一样。
还原完再看具体像素,RGBA8 就是每 4 字节一组:
for y in range(h): row = pixels[y * row_bytes:(y + 1) * row_bytes] for x in range(w): r, g, b, a = row[x*4:x*4+4]3.4 用现成库交叉验证
手写的解析器适合理解原理,生产环境当然用成熟库。用 Pillow 读同一张图,把像素值和手写结果对比,能快速验证实现对不对:
from PIL import Image im = Image.open('test.png') print(im.mode, im.size, im.info.get('interlace')) pixels = im.convert('RGBA').tobytes()im.info里能看到gamma、srgb、icc_profile这些元数据,这正是下一步要聊的重点。如果手写结果和 Pillow 对不上,优先怀疑两处:滤波偏移量bpp算错,或者多个 IDAT 拼接顺序不对。
也可以顺手看一下文件里有没有“多余”的块。很多设计工具导出时会塞进大量tEXt(软件名、作者、时间戳),有的能占几十 KB。用--strip类参数去掉元数据,是无损优化里性价比最高的一步,风险为零,收益立竿见影。
4. Alpha 通道的那些隐式约定
4.1 直通 Alpha 与预乘 Alpha,差别在哪
PNG 规范明确规定使用直通 Alpha(straight alpha):RGB 存的是“完全不含透明度影响”的颜色值,Alpha 单独存。这跟某些渲染管线内部用的**预乘 Alpha(premultiplied alpha)**是不同的——预乘状态下 RGB 已经乘过 Alpha,好处是合成时只需要一次加法,坏处是颜色和透明度耦合在一起,改透明度会丢精度。
为什么要强调这个?因为很多图像处理库在缩放、模糊、旋转时,内部会转换成预乘,处理完再转回来。如果转换环节漏了,或者来回转换精度不够,就会出现经典故障:透明背景的图标边缘出现一圈黑边或白边。
原因很直观:一个边缘像素可能是“半透明白色”(RGB=255,255,255, A=128)。缩放时如果直接对 RGB 求平均,把周围全透明像素的 RGB=0,0,0 也混进来,平均出来的 RGB 就变暗了,再配上半透明,边缘就发灰发黑。正确做法是先把所有像素预乘(RGB×A),在预乘空间里做插值,最后再除回去。这个坑在图标切图、UI 动效、贴图合成里反复出现,而且往往要等视觉评审才被发现。
判断有没有踩坑的方法很简单:把图放在深色和浅色两种背景上分别看。如果边缘在深色背景上发白、在浅色背景上发黑,说明 RGB 和 Alpha 不匹配;如果两种背景下都发黑,那是预乘被当成直通用了。
4.2 tRNS:索引色的无奈补丁
除了完整的 Alpha 通道,PNG 还有一个tRNS块,专门给三种色彩类型提供“简易透明”。对类型 0(灰度),它指定一个灰度值作为全透明;对类型 2(RGB),指定一个 RGB 三元组作为全透明;对类型 3(索引色),它是一个字节数组,按索引顺序标记每个调色板项的 Alpha 值。
这个设计的历史包袱很明显:它只能表达“全透明”和“不透明”,没法做半透明羽化。类型 0 和 2 更惨,整个图只能有一个透明色,多一个都不行。所以如果你的图标有柔和阴影边缘,用索引色加tRNS一定会出现锯齿——边缘要么全透明要么全不透明,没有过渡。
实践中的判断标准:需要半透明就用类型 6(RGBA),只有硬边透明(比如剪纸风格图标)才考虑索引色。索引色的优势在体积,256 色图比 RGBA 图小得多,但这点优势在需要羽化时完全不成立。
4.3 Gamma 与色彩配置:跨设备偏色的根源
这部分是最容易被忽略、排查起来最费劲的。PNG 有三个块在管颜色语义:gAMA(存 gamma 值乘 100000,比如 45455 表示 0.45455)、sRGB(1 字节,表示渲染意图,通常写 0)、iCCP(内嵌完整 ICC 配置文件,压缩存放)。
问题在于,不同软件对这些块的处理策略完全不同。有的浏览器在检测到sRGB块时直接按 sRGB 渲染;有的会优先用iCCP;有的干脆全忽略,按显示器原始值输出。结果就是同一张图,在设计工具里看是一个颜色,在浏览器里看是另一个颜色,在手机上又是第三种。
我的处理原则是:要么明确写sRGB,要么什么都不写。混着写反而危险——比如同时存在gAMA和iCCP,不同解码器的优先级规则不一致,就会出现诡异结果。导出时如果工具问“是否嵌入颜色配置”,面向 Web 的图建议嵌入 sRGB 但不嵌大体积 ICC;面向印刷或需要严格色彩管理的素材,老老实实嵌 ICC,并且明确告知下游“这图带配置,处理时要保留”。
还有一个隐蔽的坑:16 位图在部分链路里会被静默截断成 8 位。某些设计工具的导出面板写的是“16 位”,但实际保存时压缩选项没开对,或者中间某个处理步骤用 8 位缓冲跑了一遍,出来的文件头写 16 位、数据却是 8 位精度。验证方法是拿一张有明显渐变的 16 位图,放大到 400% 看色阶有没有断层,有断层就是被截断了。
5. 体积优化:哪些手段真有效
5.1 先做减法:像素总量和色彩深度
优化 PNG 体积,很多人第一反应是找工具压,其实收益最大的一步在导出之前。按收益从大到小排,我的顺序是:
第一,把尺寸做对。一张 2000 宽的图缩到 1000 宽,像素总量降到四分之一,体积基本同比例下降。这条听起来废话,但实际情况里大量资源是“先按 2 倍图导出,再用 CSS 缩一半”,白白多传了三倍数据。正确做法是按目标显示尺寸出 1x 和 2x 两套,用响应式加载分发。
第二,降低位深。如果图里只有 200 种颜色,用 8 位索引色(每像素 8 位)比 RGBA8(每像素 32 位)省一大截。索引色的判定方法很简单:统计唯一颜色数,不超过 256 就值得试。注意统计时要把 Alpha 也算进去,(R,G,B,A)四元组的唯一值才是真实颜色数。
第三,去掉元数据。tEXt、zTXt、tIME、pHYs这些块对显示没用,去掉纯赚。有些设计工具会写进完整的软件版本号和导出参数,几 KB 就这么飘走了。
第四,关掉交错。前面讲过,交错的图压缩率通常更差。
这四步做完,往往已经能减掉一半以上,而且完全没有画质损失。做完这些再考虑量化。
5.2 有损量化:pngquant 那类工具怎么用
当颜色数超过 256,又想保住 PNG 格式(比如需要透明通道、格式兼容性),就得用颜色量化——把 24 位或 32 位色降到 8 位索引色,用调色板表达。这一步是有损的,所以必须做视觉验收。
开源工具里常用的是 pngquant 这一类,典型命令:
pngquant --quality=65-80 --speed 1 --strip --output out.png in.png参数怎么理解:
--quality=65-80是允许的质量区间。工具会尝试不同的调色板规模,落在区间内的接受,压不到就放弃并返回非零退出码。区间下限给太低(比如 40)会出现明显色带,上限太高会导致体积压不下来甚至退出。65-80 是我在 UI 素材上比较稳的区间。--speed 1是最慢也最准的档位,1 到 11。批量处理时可以放到 3,肉眼差别很小。--strip去掉元数据,前面说过这是纯赚的操作。
量化之后一定要看三个地方:大面积渐变有没有出现色带、细线条有没有断、半透明边缘有没有出现硬边。前两个是调色板不够用的典型症状,第三个是 Alpha 被量化到少量档位的结果。如果出现,就把质量下限往上提,或者干脆放弃量化改用无损。
量化收益的量级可以参考:一张 1200×800 的插画,RGBA8 无损出来 400KB 左右,量化到 256 色常常能压到 120~180KB,视觉上除非并排对比否则看不出差别。但同样操作放到带复杂渐变的背景图上,色带会非常明显——判断标准是内容类型,不是文件大小。
5.3 无损重压:能抠出来的空间很有限
无损优化工具(比如基于 zopfli 重压、重排滤波策略的那些)原理是:用更彻底的搜索重新决定每行的滤波类型,再用更优的 deflate 实现重压一次。理论上不改变任何像素,只改变压缩表示。
实际收益我测下来大概是:已经用最高级别导出的图,再无损重压能省 3%~12%;用低级别导出的图,能省 15%~25%。前者是常见情况,所以别指望无损工具能救大体积资源。它们的真正价值在于构建流水线里统一跑一遍,白捡一点是一点,代价是时间——重度搜索模式对一张大图可能跑几十秒。
这里有个反直觉的现象值得记一下:对已经量化过的索引色 PNG,再跑无损优化,收益经常是负的——有的工具会把调色板重排或加入额外块,体积反而变大。所以流水线里要加一步校验:优化后体积没减小就保留原图。这个判断用一行 shell 就能做,别偷懒。
5.4 什么情况下该果断放弃 PNG
说句实在话,PNG 不是万能的。以下几种情况我会直接换格式:
需要照片级画质又要控制体积,用有损格式。复杂渐变背景、模糊阴影、真实照片素材,PNG 的体积劣势非常明显,这时候纠结几个百分点没有意义。
需要动画,用 APNG 或视频格式。PNG 本身是静态格式,动图靠acTL、fcTL、fdAT这几个块扩展出来(也就是 APNG),兼容性比想象中好,但编码工具链不如视频成熟。帧数多、尺寸大的动效,用视频格式体积小一个数量级。
需要大量小图标,考虑雪碧图或字体图标或矢量格式。几十个小 PNG 每个都要走一次 HTTP 请求(哪怕有 HTTP/2),合并成一张雪碧图能减少请求数;纯色图标用矢量格式,任意缩放不糊,体积还小。PNG 在这些场景里不是最优解,只是最省事的解。
真正该用 PNG 的场景,我总结成一句话:内容是“人工构造的、边界锐利的、颜色数量有限的、需要无损或透明的”。UI 截图、图表、线稿、图标、需要二次编辑的素材,都在这个范围里。
6. 排查实录:那些年踩过的坑
6.1 常见问题速查表
下面这张表是我这些年攒下来的,遇到问题先对照一下,能省不少时间:
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 图只显示上半部分,下半部分是灰的 | IDAT 块不连续,中间插了其他块 | 打印块序列,看 IDAT 是否连续 | 重新导出,或重排块顺序 |
| 浏览器正常,老软件偏色 | gAMA/iCCP处理策略不一致 | 检查是否同时存在多个颜色块 | 只保留sRGB或全部去掉 |
| 透明图边缘发黑或发白 | 缩放/合成时用了直通 Alpha 当预乘 | 换深浅背景分别观察 | 在预乘空间做插值后再转回 |
| 优化工具跑完体积反而变大 | 索引色图被重排了调色板 | 对比优化前后大小 | 加判断,没变小就保留原图 |
| 16 位图出现色阶断层 | 中间环节被截断成 8 位 | 放大看渐变是否平滑 | 逐环节确认位深设置 |
| 图片在某个平台变成不透明 | 平台自动转成了不支持透明的格式 | 下载回来查文件头 | 换格式或改用平台支持的方案 |
| 同一图不同工具导出体积差很多 | 滤波策略和压缩级别不同 | 对比块结构和 IDAT 大小 | 统一用一套导出参数 |
| 打开报“文件损坏” | CRC 校验失败或签名被改 | 逐块验 CRC | 重新获取原始文件 |
这张表里最值得展开的是第一条。IDAT 必须连续这条规则,很多自研的编码器会违反——比如写完第一个 IDAT 顺手插一个tEXt,再继续写 IDAT。宽容的解码器会容忍,严格的直接报错,浏览器多半宽容,所以表现为“浏览器能看,其他软件打不开”。遇到这种“有的能开有的不能开”的问题,第一件事就是打印块序列。
6.2 我自己的几条硬规矩
最后分享几条我在项目里固定执行的习惯,都是踩过坑之后定下来的。
导出参数写进文档,别靠记忆。团队里每个人用的工具版本不同,默认参数也不同,同一套设计稿两个人导出体积差 30% 是常事。我会把“位深、色彩类型、是否交错、是否去元数据、压缩级别”这五项写成检查清单放进交付规范,比口头交代管用得多。
构建流水线里加体积门禁。给每类资源定一个体积上限,超标就让构建失败。这个办法倒逼得很有效——一开始会有一堆超标,逼着大家回头去改导出方式,几轮之后新资源就不会再超标了。门禁阈值别定太死,留 10% 左右余量,否则会为了几个字节反复折腾。
优化前后一定做像素级对比。有损量化的工具都号称“视觉无损”,但“视觉”是谁的视觉?我的做法是解码成像素数组,算一下差异像素占比和最大通道误差,超过阈值就人工看图。这套流程跑一次就固化成脚本,比人眼盯一整天靠谱。
别为了省体积牺牲可编辑性。有些团队为了压体积,把带图层的源文件也一并压掉了元数据,后面要改一个颜色得重新做图。我的原则是:交付给用户的运行资源可以激进优化,存档的源文件保持完整。这两条链路分开走,别混。
遇到“这图怎么这么大”先看内容。一张 2MB 的 PNG,八成是因为它是照片或者带复杂渐变,这时候应该换格式,而不是继续在 PNG 上找压缩空间。反过来,一张 UI 截图的 PNG 如果超过 500KB,那一定是导出参数有问题,值得查。判断的依据永远是内容类型,不是绝对值。
尺寸和体积的估算我给个经验公式,方便快速心里有数:无损 PNG 的体积大致等于宽 × 高 × 每像素字节数 × 压缩率,压缩率对 UI 截图在 0.05~0.15 之间,对线条图在 0.1~0.3 之间,对照片在 0.6~0.9 之间。拿一张 1920×1080 的 RGBA8 后台截图算,原始 7.9MB,乘 0.1 大约是 790KB——如果你导出的文件远大于这个数,说明还有优化空间;远小于,那可能是内容特别简单或者被量化过了。这个心算用久了,扫一眼文件大小就知道有没有问题。