你處理好了圖像,現(xiàn)在需要把它保存成文件,或者上傳到服務(wù)器。
選 PNG 還是 JPEG?質(zhì)量參數(shù)設(shè)多少?如果文件太大怎么辦?
這一天我們來談?wù)劰I(yè)級項目里真正面對的問題。
一、PNG vs JPEG:格式選擇的決策樹
PNG(Portable Network Graphics)
- 壓縮方式:無損壓縮(Deflate/LZ77)
- 支持透明度:? 支持 Alpha 通道
- 適合場景:截圖、UI 元素、圖標(biāo)、有透明區(qū)域的圖
- 文件大小:比 JPEG 大 5~10 倍(對照片而言)
- 質(zhì)量:無損,像素級完美還原
JPEG(Joint Photographic Experts Group)
- 壓縮方式:有損壓縮(DCT + 量化)
- 支持透明度:? 不支持(透明區(qū)域會被合成為白色)
- 適合場景:照片、現(xiàn)實場景圖像
- 文件大小:比 PNG 小 5~10 倍
- 質(zhì)量:有損,quality 參數(shù)控制損失程度
決策樹
圖像有透明像素(Alpha < 255)?
├── 是 → PNG(必須,JPEG 會破壞透明)
└── 否 → 需要無損存檔?
├── 是 → PNG
└── 否 → JPEG(體積更小)
└── quality 如何選?→ 見下文
二、JPEG 的壓縮原理(概覽)
JPEG 之所以能大幅壓縮照片,依賴一個關(guān)鍵洞察:人眼對亮度細(xì)節(jié)敏感,對顏色細(xì)節(jié)不敏感。
JPEG 壓縮步驟:
- 顏色空間轉(zhuǎn)換:RGB → YCbCr(Y=亮度,Cb/Cr=色差)
- 色度下采樣:Cb 和 Cr 通道降低到原來的 1/2(人眼感知不到)
- 分塊:將圖像分成 8×8 的小塊
- DCT(離散余弦變換):每個 8×8 塊轉(zhuǎn)換到頻域
- 量化:高頻系數(shù)(細(xì)節(jié))除以量化矩陣(有損!這步丟掉細(xì)節(jié))
- Huffman 編碼:量化后的系數(shù)做無損熵編碼
quality 參數(shù)影響量化步驟:
- quality 高(如 0.95)→ 量化矩陣系數(shù)小 → 高頻信息保留更多 → 文件更大
- quality 低(如 0.5)→ 量化矩陣系數(shù)大 → 高頻信息大量丟棄 → 文件更小,有明顯塊狀偽影
三、quality 參數(shù)的選擇
沒有"最好的 quality",只有"滿足需求的 quality":
| 場景 | 推薦 quality | 典型文件大?。?080p) |
|---|---|---|
| 存檔備份 | 0.95+ | 2~4 MB |
| 高質(zhì)量分享 | 0.85~0.92 | 800KB~2MB |
| 社交媒體 | 0.75~0.85 | 400~800KB |
| 網(wǎng)頁縮略圖 | 0.60~0.75 | 200~400KB |
| 極限壓縮 | 0.3~0.5 | 50~200KB(有明顯失真) |
經(jīng)驗法則:
- quality 0.85 是"肉眼幾乎無損"和"體積合理"的黃金平衡點
- 對于大多數(shù)照片,0.85 與 0.95 的視覺差異極小,但文件大小差異 50%+
四、內(nèi)容復(fù)雜度與動態(tài)質(zhì)量
固定 quality 對所有圖片用同一參數(shù),是粗糙的做法。工業(yè)級系統(tǒng)會根據(jù)圖像內(nèi)容復(fù)雜度動態(tài)調(diào)整質(zhì)量。
原理
- 細(xì)節(jié)豐富的圖(風(fēng)景、人像、紋理):需要較高 quality 才能保留細(xì)節(jié)
- 細(xì)節(jié)稀少的圖(純色、漸變、圖標(biāo)):低 quality 也看不出區(qū)別,不需要浪費空間
復(fù)雜度估算算法
// 計算相鄰像素的感知亮度差,作為復(fù)雜度指標(biāo)
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 通道導(dǎo)致藍(lán)/綠圖估算失準(zhǔn))
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) // 歸一化
// 低復(fù)雜度 → 區(qū)間下限質(zhì)量;高復(fù)雜度 → 區(qū)間上限質(zhì)量
return range.lowerBound + complexity * (range.upperBound - range.lowerBound)
}
這是 Netflix 提出的 per-title encoding 思想在單幀圖像上的簡化應(yīng)用:不同內(nèi)容用不同參數(shù)編碼。
五、體積約束:二分法逼近
需求:"文件不能超過 3 MB"。挑戰(zhàn):quality 和文件大小之間的關(guān)系不是線性的,沒有公式直接算出"3 MB 對應(yīng) quality 多少"。
二分法(Binary Search) 解決這個問題:
func retryIfTooLarge(image: UIImage, maxBytes: Int, qRange: ClosedRange<CGFloat>) -> Data? {
var lo: CGFloat = 0.3 // 最低質(zhì)量
var hi: CGFloat = qRange.upperBound // 最高質(zhì)量
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 // 滿足約束,記錄,嘗試更高質(zhì)量
lo = mid
} else {
hi = mid // 不滿足約束,降低質(zhì)量
}
}
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)設(shè)計
工業(yè)級導(dǎo)出不只有"成功"和"失敗"兩種狀態(tài),而是一個優(yōu)先級降級鏈:
// 優(yōu)先無損,失敗則降級有損,最終極限壓縮兜底
ImageExporter.saveWithFallback(bitmap, to: url, formats: [
.png, // 優(yōu)先:無損(適合有透明度)
.jpeg(quality: 0.85), // 次選:高質(zhì)量 JPEG
.jpeg(quality: 0.5), // 再次:中等質(zhì)量
.jpeg(quality: 0.3), // 兜底:最低質(zhì)量
])
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)
}
失敗的常見原因(不是格式本身的問題):
- 磁盤空間不足(寫文件失?。?/li>
- 內(nèi)存不足(大圖編碼時 OOM)
- 權(quán)限問題(目標(biāo)路徑不可寫)
七、場景預(yù)設(shè)(Scenario Preset)
真實項目中,調(diào)用方不應(yīng)該每次都手動設(shè)置 policy,而是用預(yù)設(shè)場景:
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)
每個場景對應(yīng)一套 policy(最大尺寸、格式、質(zhì)量范圍、體積上限):
| 場景 | 長邊限制 | 格式 | 質(zhì)量范圍 | 體積上限 |
|---|---|---|---|---|
| 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 | 無 |
八、尺寸重采樣
如果圖像超過最大長邊限制,需要等比縮放:
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 }
// ?? 關(guān)鍵:scale = 1.0 → 1 point = 1 pixel
// 默認(rèn)跟隨屏幕 Retina 縮放(2x/3x),會導(dǎo)致輸出尺寸翻倍
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
}
九、日志系統(tǒng):os.log vs print
生產(chǎn)庫不應(yīng)該使用 print:
| 比較 | print |
os.log / Logger |
|---|---|---|
| 性能 | 同步,阻塞 | 異步,幾乎無開銷 |
| 級別控制 | 無 | debug/info/warning/error |
| 過濾 | 無法過濾 | Console.app 可過濾 |
| 符號記錄 | 不記錄 | 自動記錄調(diào)用棧 |
| 隱私 | 無保護(hù) | 可標(biāo)記 sensitive 數(shù)據(jù) |
// 生產(chǎn)代碼
private let logger = Logger(subsystem: "com.mlimage.core", category: "ImageProcessor")
logger.debug("重采樣:\(bitmap.width)×\(bitmap.height) → \(newW)×\(newH)")
logger.warning("文件體積超限,啟動降級重試")
logger.error("導(dǎo)出失?。篭(error.localizedDescription)")
十、ExportResult:結(jié)構(gòu)化結(jié)果
不要用 Bool 表示成功/失敗,結(jié)構(gòu)化結(jié)果提供更多信息:
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 // 回退鏈全部失敗
}
調(diào)用方可以精確地判斷失敗原因:
switch result {
case .success(let format, let url):
// 知道最終用了什么格式,保存在哪里
print("成功:\(format) → \(url)")
case .failure(.writeFailed(let url, let error)):
// 知道哪個路徑寫失敗了,可以提示用戶清理空間
showAlert("存儲空間不足:\(error.localizedDescription)")
case .failure(.encodingFailed):
// 極少見,通常是內(nèi)存不足
showAlert("圖像編碼失敗,請重試")
case .failure(.allFormatsFailed):
// 回退鏈全部失敗,情況很嚴(yán)重
reportCrash()
}
十一、小結(jié)
| 知識點 | 核心內(nèi)容 |
|---|---|
| PNG vs JPEG | 透明度 → PNG;照片/體積優(yōu)先 → JPEG |
| JPEG 原理 | DCT + 量化,quality 控制高頻細(xì)節(jié)保留程度 |
| 動態(tài)質(zhì)量 | 根據(jù)圖像復(fù)雜度(梯度均值)在質(zhì)量區(qū)間內(nèi)選擇 |
| 二分法 | 滿足體積約束的最優(yōu) quality 尋找 |
| 回退鏈 | 按優(yōu)先級依次嘗試,降級而非直接失敗 |
| 場景預(yù)設(shè) | 調(diào)用方使用預(yù)設(shè),而非手動配置每個參數(shù) |
| 等比縮放 |
scale = 1.0 避免 Retina 2x/3x 的 Points vs Pixels 陷阱 |
| 日志系統(tǒng) |
os.log 替代 print,支持級別控制和過濾 |
思考題
- JPEG 重新保存問題:如果對同一張 JPEG 圖片反復(fù)"打開 → 保存"(不做任何修改),每次保存后圖像質(zhì)量會變化嗎?為什么?
- 某電商平臺規(guī)定商品圖不超過 2 MB,但必須保證 2048×2048 尺寸。你會如何設(shè)計自動化的導(dǎo)出策略?
- WebP 格式是 Google 推出的現(xiàn)代圖像格式,相比 JPEG 能在相同質(zhì)量下減少約 25~34% 的文件大小,也支持透明度。iOS 14+ 支持顯示 WebP,但 UIKit 不支持直接導(dǎo)出。如果要在 Phase 2 添加 WebP 支持,需要引入哪些依賴?
答案:1. 會變差,因為每次 JPEG 保存都做一次有損壓縮,高頻信息每次都損失一點,多次后會出現(xiàn)明顯的塊狀偽影;2. 先縮放到 2048×2048,然后從 quality=0.92 開始二分搜索,找到滿足 2 MB 約束的最高質(zhì)量;3. 需要引入 libwebp(Google 的 C 庫),或者用 SDWebImage / Kingfisher 等支持 WebP 的第三方庫,也可以用 ImageIO 框架(iOS 14+ 支持讀取,但寫入仍需 libwebp)。
??喜歡我的內(nèi)容,歡迎大家點贊、轉(zhuǎn)發(fā)、關(guān)注。
??本人專注于技術(shù)+投資+認(rèn)知三位一體的內(nèi)容分享。
往期推薦:
一圖了解OCR的處理流程及相關(guān)圖像處理技術(shù)