你处理好了图像,现在需要把它保存成文件,或者上传到服务器。
选 PNG 还是 JPEG?质量参数设多少?如果文件太大怎么办?
这一天我们来谈谈工业级项目里真正面对的问题。
一、PNG vs JPEG:格式选择的决策树
PNG(Portable Network Graphics)
- 压缩方式:无损压缩(Deflate/LZ77)
- 支持透明度:✅ 支持 Alpha 通道
- 适合场景:截图、UI 元素、图标、有透明区域的图
- 文件大小:比 JPEG 大 5~10 倍(对照片而言)
- 质量:无损,像素级完美还原
JPEG(Joint Photographic Experts Group)
- 压缩方式:有损压缩(DCT + 量化)
- 支持透明度:❌ 不支持(透明区域会被合成为白色)
- 适合场景:照片、现实场景图像
- 文件大小:比 PNG 小 5~10 倍
- 质量:有损,quality 参数控制损失程度
决策树
图像有透明像素(Alpha < 255)?
├── 是 → PNG(必须,JPEG 会破坏透明)
└── 否 → 需要无损存档?
├── 是 → PNG
└── 否 → JPEG(体积更小)
└── quality 如何选?→ 见下文
二、JPEG 的压缩原理(概览)
JPEG 之所以能大幅压缩照片,依赖一个关键洞察:人眼对亮度细节敏感,对颜色细节不敏感。
JPEG 压缩步骤:
- 颜色空间转换:RGB → YCbCr(Y=亮度,Cb/Cr=色差)
- 色度下采样:Cb 和 Cr 通道降低到原来的 1/2(人眼感知不到)
- 分块:将图像分成 8×8 的小块
- DCT(离散余弦变换):每个 8×8 块转换到频域
- 量化:高频系数(细节)除以量化矩阵(有损!这步丢掉细节)
- Huffman 编码:量化后的系数做无损熵编码
quality 参数影响量化步骤:
- quality 高(如 0.95)→ 量化矩阵系数小 → 高频信息保留更多 → 文件更大
- quality 低(如 0.5)→ 量化矩阵系数大 → 高频信息大量丢弃 → 文件更小,有明显块状伪影
三、quality 参数的选择
没有"最好的 quality",只有"满足需求的 quality":
| 场景 | 推荐 quality | 典型文件大小(1080p) |
|---|---|---|
| 存档备份 | 0.95+ | 2~4 MB |
| 高质量分享 | 0.85~0.92 | 800KB~2MB |
| 社交媒体 | 0.75~0.85 | 400~800KB |
| 网页缩略图 | 0.60~0.75 | 200~400KB |
| 极限压缩 | 0.3~0.5 | 50~200KB(有明显失真) |
经验法则:
- quality 0.85 是"肉眼几乎无损"和"体积合理"的黄金平衡点
- 对于大多数照片,0.85 与 0.95 的视觉差异极小,但文件大小差异 50%+
四、内容复杂度与动态质量
固定 quality 对所有图片用同一参数,是粗糙的做法。工业级系统会根据图像内容复杂度动态调整质量。
原理
- 细节丰富的图(风景、人像、纹理):需要较高 quality 才能保留细节
- 细节稀少的图(纯色、渐变、图标):低 quality 也看不出区别,不需要浪费空间
复杂度估算算法
swift
// 计算相邻像素的感知亮度差,作为复杂度指标
func estimateQuality(for bitmap: MLBitmap, range: ClosedRange<CGFloat>) -> CGFloat {
var totalDiff = 0
var sampleCount = 0
// 采样:每隔 8 像素取一对相邻像素
for y in stride(from: 0, to: bitmap.height, by: 8) {
for x in stride(from: 0, to: bitmap.width - 1, by: 8) {
let i1 = bitmap.index(x: x, y: y)
let i2 = bitmap.index(x: x + 1, y: y)
// BT.709 感知亮度(避免只看 R 通道导致蓝/绿图估算失准)
let lum1 = (2126 * Int(bitmap.pixels[i1]) +
7152 * Int(bitmap.pixels[i1+1]) +
722 * Int(bitmap.pixels[i1+2])) / 10000
let lum2 = (2126 * Int(bitmap.pixels[i2]) +
7152 * Int(bitmap.pixels[i2+1]) +
722 * Int(bitmap.pixels[i2+2])) / 10000
totalDiff += abs(lum1 - lum2)
sampleCount += 1
}
}
let avgDiff = CGFloat(totalDiff) / CGFloat(sampleCount)
let complexity = min(avgDiff / 30.0, 1.0) // 归一化
// 低复杂度 → 区间下限质量;高复杂度 → 区间上限质量
return range.lowerBound + complexity * (range.upperBound - range.lowerBound)
}
这是 Netflix 提出的 per-title encoding 思想在单帧图像上的简化应用:不同内容用不同参数编码。
五、体积约束:二分法逼近
需求:"文件不能超过 3 MB"。挑战:quality 和文件大小之间的关系不是线性的,没有公式直接算出"3 MB 对应 quality 多少"。
二分法(Binary Search) 解决这个问题:
swift
func retryIfTooLarge(image: UIImage, maxBytes: Int, qRange: ClosedRange<CGFloat>) -> Data? {
var lo: CGFloat = 0.3 // 最低质量
var hi: CGFloat = qRange.upperBound // 最高质量
var bestData: Data? = nil
for _ in 0..<6 { // 最多迭代 6 次,精度约 1%(2^6 = 64 个等分)
let mid = (lo + hi) / 2
guard let data = image.jpegData(compressionQuality: mid) else { continue }
if data.count <= maxBytes {
bestData = data // 满足约束,记录,尝试更高质量
lo = mid
} else {
hi = mid // 不满足约束,降低质量
}
}
return bestData
}
收敛过程示例:
maxBytes = 1 MB
迭代 1:quality = 0.65,大小 = 1.5 MB > 1 MB → hi = 0.65
迭代 2:quality = 0.475,大小 = 0.8 MB ≤ 1 MB → lo = 0.475,记录
迭代 3:quality = 0.5625,大小 = 1.1 MB > 1 MB → hi = 0.5625
迭代 4:quality = 0.519,大小 = 0.95 MB ≤ 1 MB → lo = 0.519,记录
迭代 5:quality = 0.541,大小 = 1.02 MB > 1 MB → hi = 0.541
迭代 6:quality = 0.530,大小 = 0.98 MB ≤ 1 MB → lo = 0.530,记录
最终选 quality ≈ 0.530,大小 0.98 MB < 1 MB ✅
六、回退链(Fallback Chain)设计
工业级导出不只有"成功"和"失败"两种状态,而是一个优先级降级链:
swift
// 优先无损,失败则降级有损,最终极限压缩兜底
ImageExporter.saveWithFallback(bitmap, to: url, formats: [
.png, // 优先:无损(适合有透明度)
.jpeg(quality: 0.85), // 次选:高质量 JPEG
.jpeg(quality: 0.5), // 再次:中等质量
.jpeg(quality: 0.3), // 兜底:最低质量
])
swift
public static func saveWithFallback(
_ bitmap: MLBitmap,
to url: URL,
formats: [ExportFormat]
) -> ExportResult {
for format in formats {
let targetURL = url.deletingPathExtension()
.appendingPathExtension(ext(for: format))
let result = save(bitmap, to: targetURL, format: format)
switch result {
case .success:
return result // 第一个成功即返回
case .failure(let err):
logger.warning("格式[\(label(format))]失败:\(err),尝试下一个...")
}
}
return .failure(.allFormatsFailed)
}
失败的常见原因(不是格式本身的问题):
- 磁盘空间不足(写文件失败)
- 内存不足(大图编码时 OOM)
- 权限问题(目标路径不可写)
七、场景预设(Scenario Preset)
真实项目中,调用方不应该每次都手动设置 policy,而是用预设场景:
swift
public enum ExportScenario {
case socialShare // 社交分享(微信/微博/Instagram)
case ecommerce // 电商主图(淘宝/京东)
case thumbnail(maxSize: Int) // 缩略图
case archive // 存档备份
case custom(policy: ProcessingPolicy) // 完全自定义
}
// 使用:
ImageProcessor.process(bitmap, to: url, scenario: .socialShare)
每个场景对应一套 policy(最大尺寸、格式、质量范围、体积上限):
| 场景 | 长边限制 | 格式 | 质量范围 | 体积上限 |
|---|---|---|---|---|
| socialShare | 1920 | auto | 0.75~0.88 | 3 MB |
| ecommerce | 2048 | JPEG | 0.85~0.92 | 无 |
| thumbnail | 自定义 | JPEG 0.7 | 0.6~0.75 | 无 |
| archive | 无限 | PNG | 0.92~0.95 | 无 |
八、尺寸重采样
如果图像超过最大长边限制,需要等比缩放:
swift
private static func resampleIfNeeded(_ bitmap: MLBitmap, policy: ProcessingPolicy) -> MLBitmap {
guard let maxEdge = policy.maxLongEdge else { return bitmap }
let longEdge = max(bitmap.width, bitmap.height)
guard longEdge > maxEdge else { return bitmap } // 未超限
let scale = CGFloat(maxEdge) / CGFloat(longEdge)
let newW = max(1, Int(CGFloat(bitmap.width) * scale))
let newH = max(1, Int(CGFloat(bitmap.height) * scale))
return resample(bitmap, to: newW, by: newH)
}
private static func resample(_ bitmap: MLBitmap, to newW: Int, by newH: Int) -> MLBitmap {
guard let uiImage = ImageExporter.toUIImage(bitmap) else { return bitmap }
// ⚠️ 关键:scale = 1.0 → 1 point = 1 pixel
// 默认跟随屏幕 Retina 缩放(2x/3x),会导致输出尺寸翻倍
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
let renderer = UIGraphicsImageRenderer(size: CGSize(width: newW, height: newH), format: format)
let resized = renderer.image { _ in
uiImage.draw(in: CGRect(x: 0, y: 0, width: newW, height: newH))
}
return (try? ImageLoader.load(from: resized)) ?? bitmap
}
九、日志系统:os.log vs print
生产库不应该使用 print:
| 比较 | print |
os.log / Logger |
|---|---|---|
| 性能 | 同步,阻塞 | 异步,几乎无开销 |
| 级别控制 | 无 | debug/info/warning/error |
| 过滤 | 无法过滤 | Console.app 可过滤 |
| 符号记录 | 不记录 | 自动记录调用栈 |
| 隐私 | 无保护 | 可标记 sensitive 数据 |
swift
// 生产代码
private let logger = Logger(subsystem: "com.mlimage.core", category: "ImageProcessor")
logger.debug("重采样:\(bitmap.width)×\(bitmap.height) → \(newW)×\(newH)")
logger.warning("文件体积超限,启动降级重试")
logger.error("导出失败:\(error.localizedDescription)")
十、ExportResult:结构化结果
不要用 Bool 表示成功/失败,结构化结果提供更多信息:
swift
public enum ExportResult {
case success(format: ExportFormat, url: URL)
case failure(ExportError)
}
public enum ExportError: Error {
case encodingFailed(format: ExportFormat) // 编码失败
case writeFailed(url: URL, error: Error) // 写磁盘失败
case allFormatsFailed // 回退链全部失败
}
调用方可以精确地判断失败原因:
swift
switch result {
case .success(let format, let url):
// 知道最终用了什么格式,保存在哪里
print("成功:\(format) → \(url)")
case .failure(.writeFailed(let url, let error)):
// 知道哪个路径写失败了,可以提示用户清理空间
showAlert("存储空间不足:\(error.localizedDescription)")
case .failure(.encodingFailed):
// 极少见,通常是内存不足
showAlert("图像编码失败,请重试")
case .failure(.allFormatsFailed):
// 回退链全部失败,情况很严重
reportCrash()
}
十一、小结
| 知识点 | 核心内容 |
|---|---|
| PNG vs JPEG | 透明度 → PNG;照片/体积优先 → JPEG |
| JPEG 原理 | DCT + 量化,quality 控制高频细节保留程度 |
| 动态质量 | 根据图像复杂度(梯度均值)在质量区间内选择 |
| 二分法 | 满足体积约束的最优 quality 寻找 |
| 回退链 | 按优先级依次尝试,降级而非直接失败 |
| 场景预设 | 调用方使用预设,而非手动配置每个参数 |
| 等比缩放 | scale = 1.0 避免 Retina 2x/3x 的 Points vs Pixels 陷阱 |
| 日志系统 | os.log 替代 print,支持级别控制和过滤 |
思考题
- JPEG 重新保存问题:如果对同一张 JPEG 图片反复"打开 → 保存"(不做任何修改),每次保存后图像质量会变化吗?为什么?
- 某电商平台规定商品图不超过 2 MB,但必须保证 2048×2048 尺寸。你会如何设计自动化的导出策略?
- WebP 格式是 Google 推出的现代图像格式,相比 JPEG 能在相同质量下减少约 25~34% 的文件大小,也支持透明度。iOS 14+ 支持显示 WebP,但 UIKit 不支持直接导出。如果要在 Phase 2 添加 WebP 支持,需要引入哪些依赖?
答案:1. 会变差,因为每次 JPEG 保存都做一次有损压缩,高频信息每次都损失一点,多次后会出现明显的块状伪影;2. 先缩放到 2048×2048,然后从 quality=0.92 开始二分搜索,找到满足 2 MB 约束的最高质量;3. 需要引入 libwebp(Google 的 C 库),或者用 SDWebImage / Kingfisher 等支持 WebP 的第三方库,也可以用 ImageIO 框架(iOS 14+ 支持读取,但写入仍需 libwebp)。
♥️喜欢我的内容,欢迎大家点赞、转发、关注。
♥️本人专注于技术+投资+认知三位一体的内容分享。
往期推荐: