ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

图片发糊的真相:从DPR到压缩格式的适配指南

图片发糊的真相:从DPR到压缩格式的适配指南 前几天在群里又看到有人甩截图问设计师在 Figma 里把图放大到 400% 都还挺利索怎么一上线到手机上边缘就发毛、字就发虚、渐变还起条带这类问题我这些年被问过不下几十次而且提问的人覆盖了前端、UI、客户端、甚至做小游戏的同学。绝大多数人第一反应是压缩压狠了第二反应是换个格式试试然后换来换去还是糊最后归因成安卓手机就这样。真实情况是一张图从设计稿到用户手机屏幕上中间要经过像素密度换算、编码器有损压缩、色度子采样、格式解码、GPU 纹理采样这五道关卡任何一道没对齐都会糊。你把图片压缩当成一个动作其实它是一整条流水线。这篇文章我打算把 DPR、压缩、格式选择这三件事拆开讲清楚每一环它到底动了什么、为什么动、动了之后会在屏幕上表现成什么样子最后给一套我自己在项目里用了挺久的落地流程和自查清单。适合做 H5、小程序、App、前端页面的同学也适合天天切图交付的设计同学——很多糊图其实是设计和开发之间没对齐参数造成的。1. 一张设计稿的清晰度是在哪一步被吃掉的1.1 物理像素、CSS 像素、DPR先把三个词理清楚手机屏幕的物理像素是硬件决定的比如一块屏横向有 1080 个发光点。但系统给开发者暴露的不是这个数而是 CSS 像素逻辑像素。iPhone 上一句width: 375px指的从来不是 375 个物理点而是 375 个逻辑单位实际会被系统乘上 DPR 再映射到物理像素上。DPR 就是这两者之间的比值。iPhone 4 第一次把这个概念带到大众面前那时候 DPR 是 2现在主流手机在 2 到 3.5 之间晃悠不少安卓旗舰是 3某些折叠屏甚至更高。也就是说你写 1px 的 CSS 宽度在 DPR3 的机器上要占 3 个物理像素去渲染。这里有个反直觉的点DPR 不是放大而是精细度预算。CSS 像素是坐标系物理像素是画布。同一个坐标系画布越密你能表达的细节就越多。反过来如果你给了一张只有 375 像素宽的位图系统要把它铺到 1125 个物理像素上它就只能靠插值算法凭空造出三分之二的像素——造出来的东西必然是模糊的。这不是压缩的锅这是分辨率不够属于最底层的信息缺失事后无论怎么调质量参数都救不回来。1.2 设计稿标的2x3x到底在说什么设计师交付时说的这是 2x 图或者750 宽的稿子指的是设计稿的像素尺寸是按某个基准 DPR 画的。老一代 iOS 设计稿习惯用 750×1334那是 iPhone 6 的物理分辨率逻辑宽度 375DPR2。后来安卓碎片化大家改口叫按 375 逻辑宽度出图导出 2 倍和 3 倍。问题就出在交付环节的沟通上。设计师在 Figma 里看到的画布是矢量图层和位图混排的结果矢量部分无论放多大都锐利位图部分如果原素材是高分辨率的缩放预览时由设计软件的渲染引擎做平滑处理看起来也很顺。于是设计师会形成我的稿子很清晰的直觉。但切图导出那一刻导出倍数选错或者导出时勾了压缩清晰度就掉了一个台阶而后面的流程只会继续往下掉不会往回涨。我见过最典型的一个案例设计稿按 375 逻辑宽度做图标是 24×24 的矢量。开发切图时按 1x 导出拿到一张 24×24 的 PNG。放到 DPR3 的手机上这个图标要占 72×72 物理像素被拉伸了 9 倍面积边缘自然是软的。修复方式不是压缩质量调高而是重新导出 72×72 甚至 96×96 的图。这类问题的判断方法很简单把图片放到系统相册里双指放到最大如果放大过程中糊的程度随倍数线性恶化那基本就是分辨率不足。1.3 文件大就等于清晰是个很贵的误会很多人调清晰度的方式是别压了保持原图。这个思路在 DPR 这一环上是对的但在后面几环上是错的而且代价很大。一张 3000×4000 的手机拍摄照片未压缩的位图在内存里占 48MB直接塞进页面解码耗时会明显拉长首屏用户看到的是白一下再刷出来体验反而更差。而且很多平台尤其是小程序和部分安卓 WebView对单张图片的解码尺寸有硬限制超了会被系统强制降采样降采样算法通常比你自己的压缩算法更粗暴。所以正确的目标不是不压而是在目标 DPR 下恰好够用的分辨率 在视觉无损前提下的最小体积。这两个约束是联立的缺一个都会出问题。下一章我把糊这件事按症状分类因为不同症状对应的病因完全不同乱调参数只会浪费时间。2. 图片发糊的四种症状对应四种完全不同的病因2.1 整体发虚、边缘像蒙了层雾分辨率与 DPR 没对齐症状特征整张图均匀地软没有局部块状放大看边缘有一圈渐变的灰边。这种基本都是位图尺寸小于渲染尺寸导致的。常见触发场景有三种。第一种是 1x 切图直接用在 2x/3x 屏上这个前面说过了。第二种是background-size或者img的宽高写死成了一个比图片本身更大的值浏览器做了放大。第三种比较隐蔽用了transform: scale()做动画动画结束后没还原图片被永久定格在放大状态。判断方法打开浏览器开发者工具看图片元素的渲染尺寸getBoundingClientRect拿到的宽高乘以window.devicePixelRatio再和图片的naturalWidth比一比。如果前者明显大于后者就是这个问题不用怀疑别的。注意naturalWidth读的是图片的固有像素宽度不受 CSS 影响这是排查这类问题最可靠的一个数。2.2 局部出现方块状马赛克、色块粘连有损压缩过头了症状特征不是整体软而是某些区域尤其是高频细节比如草地、头发、文字边缘出现 8×8 或者 16×16 的小方块相邻颜色被抹到一起。这是 JPEG 系编码器的典型表现。JPEG 把图切成 8×8 的块做 DCT 变换然后对变换系数做量化——量化步长越大压缩率越高细节丢得越多而且丢的方式是按块丢的所以看起来是方块。质量参数从 80 降到 60肉眼感知到的差异可能不明显但从 60 降到 40块效应会突然变得扎眼因为量化表在低质量区间是非线性加重的。这里要说一个很多人不知道的参数色度子采样chroma subsampling。人眼对亮度敏感、对色度不敏感所以编码器会把色度通道的分辨率砍一半甚至砍到四分之一最常见的配置是 4:2:0意味着每 2×2 个亮度像素共享一组色度信息。对于灰度图形、黑白文字、细线框图这个操作的影响不大但对于彩色细线、彩色小字、彩色图标色度被砍会直接导致边缘出现彩色晕染看起来发虚且带脏边。解决办法有两个方向一是把质量参数提上去但体积涨得快二是对含细线、文字的图改用无损格式或者无损 WebP。我自己的经验阈值是如果图里有 1px 级别的彩色细线或者小字号文字别用有损 JPEG直接上 PNG 或者无损 WebP省下来的调试时间远比省下来的那几十 KB 值钱。2.3 渐变区域出现条带、颜色断层色深与量化的问题症状特征天空、光晕、半透明遮罩这类平滑渐变区域出现一圈一圈的同心条纹或者颜色过渡不平滑一块一块的。这个成因和前两种都不一样。它往往是 8 位色深 有损量化 显示端抖动dithering叠加的结果。8 位每通道只有 256 级如果渐变跨越的范围很窄比如从 #1a1a1a 到 #2a2a2a可用色阶可能只有十几级本来就容易出现断层。有损压缩的量化会进一步抹平细微差异断层的边界会变得更硬。另外还有一个容易被忽略的因素色彩空间。设计稿在 P3 色域下做的渐变导出时如果没有正确处理 ICC Profile或者压缩工具把 ICC Profile 剥掉了浏览器会按 sRGB 解释这份数据颜色会偏过渡带也会变化。很多明明没压多少但颜色不对的问题都是这个原因。2.4 纹理糊成一团、细节变成噪点GPU 纹理压缩与采样这一条是做小游戏、3D 场景、地图瓦片或者用 WebGL/Flutter 渲染的同学才会碰到的。JPEG、PNG、WebP 这些格式是给存储和传输用的GPU 没法直接解码它们。图片要显示必须先解码成未压缩的位图再作为纹理上传到显存。为了省显存带宽移动 GPU 普遍支持一组硬件纹理压缩格式安卓侧常用 ETC2iOS 侧用 ASTC 或者更老的 PVRTC。这些格式的压缩比是固定的比如 ASTC 4×4 到 12×12 是可选的块大小块越大压缩率越高、画质越差。如果你在做 Unity 或者自研引擎的项目贴图在导入时被自动转成了某一档纹理压缩而这一档的块尺寸偏大或者贴图分辨率本身不是 2 的幂次导致被硬件拉伸采样就会出现纹理整体糊、细节变成噪声的现象。这跟 Web 端的图片压缩完全不是一个层面的问题排查时要把这两条线分开看。3. 格式选择JPEG、PNG、WebP、AVIF 各自的地盘在哪3.1 四种格式的压缩原理与适用边界先把结论摆出来再解释为什么。格式压缩类型典型体积同画质透明通道最适合的场景JPEG有损 DCT基准 100%不支持照片、复杂渐变、无透明的大图PNG无损 DEFLATE200%~400%支持8位alpha图标、线框图、含文字截图、需要透明WebP有损 VP8 / 无损有损约为 JPEG 的 65%~80%支持通吃型目前兼容性已经很好AVIF有损 AV1 帧内有损约为 JPEG 的 50%~70%支持大图、高质量需求但编码慢JPEG 用的是 DCT 分块变换属于频域压缩天生适合有平滑过渡的自然图像遇到锐利边缘就露馅。PNG 用的是 DEFLATE 无损压缩靠的是找出像素数据的重复模式对纯色块、规则图案压缩率极高对照片几乎没辙这就是为什么同一个图标 PNG 只有 2KB同一张照片 PNG 能到 3MB。WebP 有理和无损两套模式有损那套在编码时会做更聪明的预测所以同画质下通常比 JPEG 小四分之一左右。AVIF 基于 AV1 的帧内编码用了更大的变换块和更先进的预测压缩效率最高代价是编码时间可能是 WebP 的好几倍服务端批量处理时要有心理准备。3.2 质量参数不是线性的别拿数字直觉调几乎所有有损编码器的 quality 参数都是非线性的而且不同工具对同一个数字的解释还不一样。比如 sharp 的 webp quality 80 和 cwebp 的 -q 80 出来的结果就可能有差异因为默认的压缩方法method和预处理配置不同。我自己的经验区间是这样的照片类图片JPEG 落在 75~85 是甜点区低于 70 块效应就开始能看出来了高于 90 体积涨得飞快而人眼几乎分辨不出来WebP 有损可以稍微低一点70~80 通常就够AVIF 因为本身效率高可以开到 60~70 还能保持不错的效果。但这些都是起点真正该做的是拿你自己的图跑一遍用肉眼比对而不是信任何一篇博客给的固定数值——包括我这篇。还有一个坑要说有损压缩是会累积的。如果你先把 PNG 转成 JPEG再从 JPEG 转 WebP两次有损叠加画质损失不是相加而是相乘。项目里一定要保留一份无损的源图PNG、TIFF 或者高质量 JPEG所有派生格式都从源图直接生成不要拿派生图再派生。3.3 透明、动图、渐进加载这三类特殊需求透明通道的处理经常被忽略。JPEG 完全不支持透明你如果用工具把带透明的 PNG 转 JPEG透明区域会被填成白色或者黑色边缘还会出现一圈难看的杂边。半透明alpha 值介于 0 和 1 之间的图PNG 支持得很好有损 WebP 也支持它会单独用一个 alpha 通道编码但质量参数要单独调很多工具默认的 alpha 质量是 100体积会偏大可以调到 80 左右。动图方面GIF 的调色板只有 256 色颜色渐变的动图基本没法看。Animated WebP 可以做到有损加透明体积通常比 GIF 小很多AVIF 序列也可以但兼容性还在爬坡。如果你的动图有透明背景加渐变优先考虑用视频编码比如用 video 标签配一个透明视频而不是 GIF。渐进式加载progressive JPEG是个老技巧先出低清全图再逐层补细节观感上比基线 JPEG 从上到下一行行刷出来要舒服。但它对首屏快的感知帮助有限如果追求真正的首屏速度更有效的做法是先放一张 20×20 的缩略图做模糊占位再替换成高清图。这个方案我在列表页和详情页都用过效果比任何编码参数都明显。4. 从设计稿到线上资源的完整落地流程4.1 导出环节切图倍数与命名规范这一环的目标是保证分辨率够且信息可追溯。我一般的做法是要求设计侧统一按逻辑宽度出图同时导出 2x 和 3x 两档命名上带后缀区分比如icon-search2x.png、icon-search3x.png。如果项目只支持一种那就按目标机型里最高 DPR 出比如面向主流安卓机就按 3x。导出的时候有两个开关要特别注意一是压缩选项很多设计工具默认会给 PNG 做一次有损压缩比如转成 8 位调色板导出的图看起来没问题但已经掉过一层画质了源文件务必关掉它二是色彩配置如果设计稿是 P3 色域导出时要么统一转成 sRGB要么携带 ICC Profile别让浏览器猜。一个小习惯在切图目录里放一个 README写清楚源稿逻辑宽度、导出倍数、色彩空间、允许的压缩工具与参数。交接给新同学时能省掉大量来回确认。4.2 压缩环节命令行与构建集成我喜欢把压缩做成构建流程的一部分而不是手动用在线工具一张张点。手动压缩最大的问题是不可复现别人接手时不知道你当时用了什么参数。用 sharp 做批量处理是现在比较省心的选择它底层是 libvips速度快支持 WebP、AVIF。一个典型的处理脚本大概是这样const sharp require(sharp); const fs require(fs); const path require(path); const SRC ./raw; const OUT ./dist; async function build(file) { const name path.parse(file).name; const input path.join(SRC, file); // 同时产出 webp 与 avif 两档都是从源图直接生成 await sharp(input) .resize({ width: 1200, withoutEnlargement: true }) .webp({ quality: 78, effort: 4, smartSubsample: true }) .toFile(path.join(OUT, ${name}.webp)); await sharp(input) .resize({ width: 1200, withoutEnlargement: true }) .avif({ quality: 62, effort: 4 }) .toFile(path.join(OUT, ${name}.avif)); // 保留一份 JPEG 兜底 await sharp(input) .resize({ width: 1200, withoutEnlargement: true }) .jpeg({ quality: 82, mozjpeg: true, chromaSubsampling: 4:4:4 }) .toFile(path.join(OUT, ${name}.jpg)); } fs.readdirSync(SRC).forEach(build);几个参数的意图说明一下。withoutEnlargement是防止把小图放大这个在批量处理里很重要否则一张 400px 宽的图会被你处理成 1200px 的糊图。smartSubsample让编码器智能决定色度子采样模式对含细线的图会更友好。chromaSubsampling: 4:4:4表示不做色度降采样对文字和线条图是必要的代价是体积会涨一些。effort控制编码耗时数值越高压缩越充分但越慢构建流水线里 4 是个平衡点。如果要批量处理成百上千张图建议加一个并发控制别一次全丢进去libvips 虽然高效但内存还是会吃紧。另外把处理结果做个哈希缓存源图没变就跳过二次构建能快很多。4.3 交付环节srcset 与 DPR 适配的正确写法有了多档尺寸的图怎么让浏览器自己挑答案是srcset配合sizes。img srchero-800.jpg srcsethero-800.jpg 1x, hero-1600.jpg 2x, hero-2400.jpg 3x sizes(max-width: 600px) 100vw, 800px width800 height450 alt示例图 decodingasync /srcset里的1x/2x/3x描述符告诉浏览器这张图适合什么 DPR 的设备。浏览器会结合sizes声明的布局宽度、当前视口宽度和 DPR算出一个需要的物理像素数然后挑最接近且不小于它的那一档。很多人只写srcset不写sizes浏览器会默认按 100vw 算在小屏幕上可能给你挑了过大的图白白浪费带宽。width和height属性一定要写。这不是为了布局而是为了让浏览器在图片加载完成前就能算出宽高比、预留出空间避免布局偏移。这个属性还有个副作用如果它和图片固有尺寸的比例不一致图片会被拉伸变形所以写完要检查一遍。CSS 背景图用image-set()也能做类似的适配.hero { background-image: image-set( url(hero-800.avif) type(image/avif) 1x, url(hero-1600.avif) type(image/avif) 2x ); }如果要做格式降级优先 AVIF不支持就 WebP再不支持就 JPEG用picture更可控picture source srcsethero.avif typeimage/avif / source srcsethero.webp typeimage/webp / img srchero.jpg alt示例图 width800 height450 / /picture提示picture里浏览器只会下载第一个匹配成功的 source不会全下所以不用担心多格式造成的带宽浪费。4.4 验证环节把看起来清楚变成可量化的事上线前的验证我一般分三步走。第一步是尺寸核对。用脚本读出每个图片元素的渲染宽高和 DPR乘以之后和历史资源清单比对看看有没有明显偏小的。这一步能拦住八成的 DPR 类问题。第二步是真机比对。浏览器模拟器里的 DPR 模拟和真机不完全一致尤其是安卓各家的渲染管线差异最终一定要在目标机型上看。看的时候注意把手机亮度调到中间值太亮或太暗都会影响你对锐度的判断。第三步是体积和加载时间的监控。压缩参数调高之后别只看画质要同时看 LCP 之类的指标有没有恶化。我见过有人为了消除一点几乎看不出来的块效应把质量从 80 提到 95图片体积翻了一倍首屏时间多了 400 毫秒这笔账非常不划算。清晰度是个够用就好的指标不是越高越好。5. 我在实际项目里踩过的坑和一套自查清单5.1 几个印象深刻的坑第一个坑是二次压缩。项目里有个老图被反复处理过好几轮PNG 转 JPEG 转 WebP 转回来画质已经烂得不成样子但因为每次都在看起来还行的阈值上一直没人发现。后来我做资源审计时把所有源图和处理脚本对了一遍才发现有十几张图的历史路径不对。教训是资源目录里永远保留一份不可变的无损源所有派生图都从它生成用哈希命名而不是靠人工维护版本号。第二个坑是 ICC Profile 被剥。一批产品图在设计侧是 P3 色域做的压缩脚本用了默认参数把 Profile 剥掉了。结果在 P3 屏上显示偏淡在 sRGB 屏上又正常测试的时候两边机器不一样谁都说不清是谁的问题。后来在脚本里显式加了withMetadata()才解决。第三个坑跟纹理压缩有关。做一个小游戏的时候UI 里一张带细节点缀的贴图在编辑器里很清楚打包到安卓上就糊了。查了半天发现是打包时自动转成了 ETC2 的某个高压缩比档位细节被块压缩抹平了。解决办法是把这张贴图单独设成更高的压缩质量档或者干脆放弃纹理压缩、用未压缩格式代价是显存占用上升。这跟 Web 端的图片压缩是完全两套逻辑别混在一起查。第四个坑是图片本身没问题但被 CSS 搞糊了。有个页面的卡片图用了will-change: transform配合缩放动画动画结束后合成层没释放图片一直停留在被栅格化的低分辨率版本上。这种情况的表现是刷新一下清楚滑一下又糊很有迷惑性。检查方法是看有没有意外的合成层以及动画结束后是否移除了will-change。5.2 一张我常用的快速自查清单遇到图糊了的问题时我会按下面这个顺序过一遍通常前三条就能定位到原因。图片的固有像素宽高 × 1 是否小于渲染尺寸 × DPR是的话问题在分辨率重新切图。用工具看一眼这张图当前的编码质量参数和色度子采样模式有没有低于 70 或者用了 4:2:0 的细线图图里有没有透明区域被填色、有没有颜色明显偏离设计稿有的话查色彩空间和 alpha 处理。是不是经过了两轮以上的有损转换从源图重新生成一遍。是不是跑在 3D 引擎或者小游戏里那要去看纹理压缩档位和贴图分辨率是否为 2 的幂次。上面都排除了再去看 CSS有没有放大、有没有残留的 transform、有没有意外的合成层。这份清单我用了两年多能把大部分问题在十分钟内定位到具体环节而不是一顿瞎调参数。还有一个习惯值得分享我会在项目里留一个image-debug.html里面放几种典型的测试图——细线、渐变色块、小字号文字、半透明叠加——改完压缩参数或者换完格式先在这个页面上过一遍比拿真实业务图去试要快得多。这几张测试图其实就是我的标尺看久了你会对质量参数掉到多少会出问题形成肌肉记忆以后再遇到类似的图脑子里大概就有数了。另外说个容易被忽略的点如果你同时在处理本地资源和云端资源两边的压缩参数很容易不知不觉跑偏。我就遇到过本地脚本用的参数和 CDN 侧图片处理服务默认参数不一致同一个小区的图有的清晰有的糊排查了半天才发现是两套参数在打架。后来我在项目文档里把两边参数对齐写死这类问题就再没出现过。参数这件事能被写进文档就别留在脑子里脑子会忘文档不会。
返回列表