淺談移動端圖片壓縮(iOS & Android)
在 App 中,如果分享、釋出、上傳功能涉及到圖片,必不可少會對圖片進行一定程度的壓縮。筆者最近在公司專案中恰好重構了雙端(iOS&Android)的圖片壓縮模組。本文會非常基礎的講解一些圖片壓縮的方式和思路。
圖片格式基礎
點陣圖&向量圖
- 點陣圖:也叫點陣圖。用畫素為單位,畫素儲存顏色資訊,排列畫素實現顯示。
- 向量圖:記錄元素形狀和顏色的演算法,顯示時展示演算法運算的結果。
顏色
表示顏色時,有兩種形式,一種為索引色(Index Color),一種為直接色(Direct Color)
- 索引色:用一個數字索引代表一種顏色,在影象資訊中儲存數字到顏色的對映關係表(調色盤 Palette)。每個畫素儲存該畫素顏色對應的數字索引。一般調色盤只能儲存有限種類的顏色,通常為 256 種。所以每個畫素的數字佔用 1 位元組(8 bit)大小。
- 直接色:用四個數字來代表一種顏色,數字分別對應顏色中紅色,綠色,藍色,透明度(RGBA)。每個畫素儲存這四個緯度的資訊來代表該畫素的顏色。根據色彩深度(每個畫素儲存顏色資訊的 bit 數不同),最多可以支援的顏色種類也不同,常見的有 8 位(R3+G3+B2)、16 位(R5+G6+B5)、24 位(R8+G8+B8)、32 位(A8+R8+G8+B8)。所以每個畫素佔用 1~4 位元組大小。
移動端常用圖片格式
圖片格式中一般分為靜態圖和動態圖
靜態圖
- JPG:是支援 JPEG( 一種有失真壓縮方法)標準中最常用的圖片格式。採用點陣圖。常見的是使用 24 位的顏色深度的直接色(不支援透明)。
- PNG:是支援無失真壓縮的圖片格式。採用點陣圖。PNG 有 5 種顏色選項:索引色、灰度、灰度透明、真彩色(24 位直接色)、真彩色透明(32 位直接色)。
- WebP:是同時支援有失真壓縮和無所壓縮的的圖片格式。採用點陣圖。支援 32 位直接色。移動端支援情況如下:
系統 | 原生 | WebView | 瀏覽器 |
---|---|---|---|
iOS | 第三方庫支援 | 不支援 | 不支援 |
Android | 4.3 後支援完整功能 | 支援 | 支援 |
動態圖
- GIF:是支援無失真壓縮的圖片格式。採用點陣圖。使用索引色,並有 1 位透明度通道(透明與否)。
- APNG:基於 PNG 格式擴充套件的格式,加入動態圖支援。採用點陣圖。使用 32 位直接色。但沒有被官方 PNG 接納。移動端支援情況如下:
系統 | 原生 | WebView | 瀏覽器 |
---|---|---|---|
iOS | 支援 | 支援 | 支援 |
Android | 第三方庫支援 | 不支援 | 不支援 |
- Animated Webp:Webp 的動圖形式,實際上是檔案中打包了多個單幀 Webp,在 libwebp 0.4 後開始支援。移動端支援情況如下:
系統 | 原生 | WebView | 系統瀏覽器 |
---|---|---|---|
iOS | 第三方庫支援 | 不支援 | 不支援 |
Android | 第三方庫支援 | 不支援 | 不支援 |
而由於一般專案需要相容三端(iOS、Android、Web 的關係),最簡單就是支援 JPG、PNG、GIF 這三種通用的格式。所以本文暫不討論其餘圖片格式的壓縮。
移動端系統圖片處理架構
根據我的瞭解,畫了一下 iOS&Android 圖片處理架構。iOS 這邊,也是可以直接呼叫底層一點的框架的。

iOS 的 ImageIO
本文 iOS 端處理圖片主要用 ImageIO 框架,使用的原因主要是靜態圖動態圖 API 呼叫保持一致,且不會因為 UIImage 轉換時會丟失一部分資料的資訊。
ImageIO 主要提供了圖片編解碼功能,封裝了一套 C 語言介面。在 Swift 中不需要對 C 物件進行記憶體管理,會比 Objective-C 中使用方便不少,但 api 結果返回都是 Optional(實際上非空),需要用 guard/if,或者 !進行轉換。
解碼
1. 建立 CGImageSource
CGImageSource 相當於 ImageIO 資料來源的抽象類。通用的使用方式 CGImageSourceCreateWithDataProvider:
需要提供一個 DataProvider,可以指定檔案、URL、Data 等輸入。也有通過傳入 CFData 來進行建立的便捷方法 CGImageSourceCreateWithData:
。方法的第二個引數 options 傳入一個字典進行配置。根據 Apple 在 WWDC 2018 上的 Image and Graphics Best Practices 上的例子,當不需要解碼僅需要建立 CGImageSource 的時候,應該將 kCGImageSourceShouldCache 設為 false。

2. 解碼得到 CGImage
用 CGImageSourceCreateImageAtIndex:
或者 CGImageSourceCreateThumbnailAtIndex:
來獲取生成的 CGImage,這裡引數的 Index 就是第幾幀圖片,靜態圖傳入 0 即可。
編碼
1. 建立 CGImageDestination
CGImageDestination 相當於 ImageIO 資料輸出的抽象類。通用的使用方式 CGImageDestinationCreateWithDataConsumer:
需要提供一個 DataConsumer,可以置頂 URL、Data 等輸入。也有通過傳入 CFData 來進行建立的便捷方法 CGImageDestinationCreateWithData:
,輸出會寫入到傳入的 Data 中。方法還需要提供圖片型別,圖片幀數。
2. 新增 CGImage
新增 CGImage 使用 CGImageDestinationAddImage:
方法,動圖的話,按順序多次呼叫就行了。
而且還有一個特別的 CGImageDestinationAddImageFromSource:
方法,新增的其實是一個 CGImageSource,有什麼用呢,通過 options 引數,達到改變影象設定的作用。比如改變 JPG 的壓縮引數,用上這個功能後,就不需要轉換成更頂層的物件(比如 UIImage),減少了轉換時的編解碼的損耗,達到效能更優的目的。
3. 進行編碼
呼叫 CGImageDestinationFinalize:
,表示開始編碼,完成後會返回一個 Bool 值,並將資料寫入 CGImageDestination 提供的 DataConsumer 中。
壓縮思路分析
點陣圖佔用的空間大小,其實就是畫素數量x單畫素佔用空間x幀數。所以減小圖片空間大小,其實就從這三個方向下手。其中單畫素佔用空間,在直接色的情況下,主要和色彩深度相關。在實際專案中,改變色彩深度會導致圖片顏色和原圖沒有保持完全一致,筆者並不建議對色彩深度進行更改。而畫素數量就是平時非常常用的圖片解析度縮放。除此之外,JPG 格式還有特有的通過指定壓縮係數來進行有失真壓縮。
- JPG:壓縮係數 + 解析度縮放 + 色彩深度降低
- PNG: 解析度縮放 + 降低色彩深度
- GIF:減少幀數 + 每幀解析度縮放 + 減小調色盤
判斷圖片格式
字尾副檔名來判斷其實並不保險,真實的判斷方式應該是通過檔案頭裡的資訊進行判斷。
JPG | PNG | GIF |
---|---|---|
開頭:FF D8 + 結尾:FF D9 | 89 50 4E 47 0D 0A 1A 0A | 47 49 46 38 39/37 61 |
簡單判斷用前三個位元組來判斷
iOS
extension Data{ enum ImageFormat { case jpg, png, gif, unknown } var imageFormat:ImageFormat { var headerData = [UInt8](repeating: 0, count: 3) self.copyBytes(to: &headerData, from:(0..<3)) let hexString = headerData.reduce("") { $0 + String(($1&0xFF), radix:16) }.uppercased() var imageFormat = ImageFormat.unknown switch hexString { case "FFD8FF": imageFormat = .jpg case "89504E": imageFormat = .png case "474946": imageFormat = .gif default:break } return imageFormat } } 複製程式碼
iOS 中除了可以用檔案頭資訊以外,還可以將 Data 轉成 CGImageSource,然後用 CGImageSourceGetType 這個 API,這樣會獲取到 ImageIO 框架支援的圖片格式的的 UTI 標識的字串。對應的識別符號常量定義在 MobileCoreServices 框架下的 UTCoreTypes 中。
字串常量 | UTI 格式(字串原始值) |
---|---|
kUTTypePNG | public.png |
kUTTypeJPEG | public.jpeg |
kUTTypeGIF | com.compuserve.gif |
Andorid
enum class ImageFormat{ JPG, PNG, GIF, UNKNOWN } fun ByteArray.imageFormat(): ImageFormat { val headerData = this.slice(0..2) val hexString = headerData.fold(StringBuilder("")) { result, byte -> result.append( (byte.toInt() and 0xFF).toString(16) ) }.toString().toUpperCase() var imageFormat = ImageFormat.UNKNOWN when (hexString) { "FFD8FF" -> { imageFormat = ImageFormat.JPG } "89504E" -> { imageFormat = ImageFormat.PNG } "474946" -> { imageFormat = ImageFormat.GIF } } return imageFormat } 複製程式碼
色彩深度改變
實際上,減少深度一般也就是從 32 位減少至 16 位,但顏色的改變並一定能讓產品、使用者、設計接受,所以筆者在壓縮過程並沒有實際使用改變色彩深度的方法,僅僅研究了做法。
iOS
在 iOS 中,改變色彩深度,原生的 CGImage 庫中,沒有簡單的方法。需要自己設定引數,重新生成 CGImage。
public init?(width: Int, height: Int, bitsPerComponent: Int, bitsPerPixel: Int, bytesPerRow: Int, space: CGColorSpace, bitmapInfo: CGBitmapInfo, provider: CGDataProvider, decode: UnsafePointer<CGFloat>?, shouldInterpolate: Bool, intent: CGColorRenderingIntent) 複製程式碼
- bitsPerComponent 每個通道佔用位數
- bitsPerPixel 每個畫素佔用位數,相當於所有通道加起來的位數,也就是色彩深度
- bytesPerRow 傳入 0 即可,系統會自動計算
- space 色彩空間
- bitmapInfo 這個是一個很重要的東西,其中常用的資訊有 CGImageAlphaInfo,代表是否有透明通道,透明通道在前還是後面(ARGB 還是 RGBA),是否有浮點數(floatComponents),CGImageByteOrderInfo,代表位元組順序,採用大端還是小端,以及資料單位寬度,iOS 一般採用 32 位小端模式,一般用 orderDefault 就好。
那麼對於常用的色彩深度,就可以用這些引數的組合來完成。同時筆者在檢視更底層的 vImage 框架的 vImage_CGImageFormat 結構體時(CGImage 底層也是使用 vImage,具體可檢視 Accelerate 框架 vImage 庫的 vImage_Utilities 檔案),發現了 Apple 的註釋,裡面也包含了常用的色彩深度用的引數。

這一塊為了和 Android 保持一致,筆者封裝了 Android 常用的色彩深度引數對應的列舉值。
public enum ColorConfig{ case alpha8 case rgb565 case argb8888 case rgbaF16 case unknown // 其餘色彩配置 } 複製程式碼
CGBitmapInfo 由於是 Optional Set,可以封裝用到的屬性的便捷方法。
extension CGBitmapInfo { init(_ alphaInfo:CGImageAlphaInfo, _ isFloatComponents:Bool = false) { var array = [ CGBitmapInfo(rawValue: alphaInfo.rawValue), CGBitmapInfo(rawValue: CGImageByteOrderInfo.orderDefault.rawValue) ] if isFloatComponents { array.append(.floatComponents) } self.init(array) } } 複製程式碼
那麼 ColorConfig 對應的 CGImage 引數也可以對應起來了。
extension ColorConfig{ struct CGImageConfig{ let bitsPerComponent:Int let bitsPerPixel:Int let bitmapInfo: CGBitmapInfo } var imageConfig:CGImageConfig?{ switch self { case .alpha8: return CGImageConfig(bitsPerComponent: 8, bitsPerPixel: 8, bitmapInfo: CGBitmapInfo(.alphaOnly)) case .rgb565: return CGImageConfig(bitsPerComponent: 5, bitsPerPixel: 16, bitmapInfo: CGBitmapInfo(.noneSkipFirst)) case .argb8888: return CGImageConfig(bitsPerComponent: 8, bitsPerPixel: 32, bitmapInfo: CGBitmapInfo(.premultipliedFirst)) case .rgbaF16: return CGImageConfig(bitsPerComponent: 16, bitsPerPixel: 64, bitmapInfo: CGBitmapInfo(.premultipliedLast, true)) case .unknown: return nil } } } 複製程式碼
反過來,判斷 CGImage 的 ColorConfig 的方法。
extension CGImage{ var colorConfig:ColorConfig{ if isColorConfig(.alpha8) { return .alpha8 } else if isColorConfig(.rgb565) { return .rgb565 } else if isColorConfig(.argb8888) { return .argb8888 } else if isColorConfig(.rgbaF16) { return .rgbaF16 } else { return .unknown } } func isColorConfig(_ colorConfig:ColorConfig) -> Bool{ guard let imageConfig = colorConfig.imageConfig else { return false } if bitsPerComponent == imageConfig.bitsPerComponent && bitsPerPixel == imageConfig.bitsPerPixel && imageConfig.bitmapInfo.contains(CGBitmapInfo(alphaInfo)) && imageConfig.bitmapInfo.contains(.floatComponents) { return true } else { return false } } } 複製程式碼
對外封裝的 Api,也就是直接介紹的 ImageIO 的使用步驟,只是引數不一樣。
/// 改變圖片到指定的色彩配置 /// /// - Parameters: ///- rawData: 原始圖片資料 ///- config: 色彩配置 /// - Returns: 處理後資料 public static func changeColorWithImageData(_ rawData:Data, config:ColorConfig) -> Data?{ guard let imageConfig = config.imageConfig else { return rawData } guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), let writeData = CFDataCreateMutable(nil, 0), let imageType = CGImageSourceGetType(imageSource), let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil), let rawDataProvider = CGDataProvider(data: rawData as CFData), let imageFrame = CGImage(width: Int(rawData.imageSize.width), height: Int(rawData.imageSize.height), bitsPerComponent: imageConfig.bitsPerComponent, bitsPerPixel: imageConfig.bitsPerPixel, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: imageConfig.bitmapInfo, provider: rawDataProvider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil } CGImageDestinationAddImage(imageDestination, imageFrame, nil) guard CGImageDestinationFinalize(imageDestination) else { return nil } return writeData as Data } /// 獲取圖片的色彩配置 /// /// - Parameter rawData: 原始圖片資料 /// - Returns: 色彩配置 public static func getColorConfigWithImageData(_ rawData:Data) -> ColorConfig{ guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), let imageFrame = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { return .unknown } return imageFrame.colorConfig } 複製程式碼
Android
對於 Android 來說,其原生的 Bitmap 庫有相當方便的轉換色彩深度的方法,只需要傳入 Config 就好。
public Bitmap copy(Config config, boolean isMutable) { checkRecycled("Can't copy a recycled bitmap"); if (config == Config.HARDWARE && isMutable) { throw new IllegalArgumentException("Hardware bitmaps are always immutable"); } noteHardwareBitmapSlowCall(); Bitmap b = nativeCopy(mNativePtr, config.nativeInt, isMutable); if (b != null) { b.setPremultiplied(mRequestPremultiplied); b.mDensity = mDensity; } return b; } 複製程式碼
iOS 的 CGImage 引數和 Android 的 Bitmap.Config 以及色彩深度對應關係如下表:
色彩深度 | iOS | Android |
---|---|---|
8 位灰度(只有透明度) | bitsPerComponent: 8 bitsPerPixel: 8 bitmapInfo: CGImageAlphaInfo.alphaOnly | Bitmap.Config.ALPHA_8 |
16 位色(R5+G6+R5) | bitsPerComponent: 5 bitsPerPixel: 16 bitmapInfo: CGImageAlphaInfo.noneSkipFirst | Bitmap.Config.RGB_565 |
32 位色(A8+R8+G8+B8) | bitsPerComponent: 8 bitsPerPixel: 32 bitmapInfo: CGImageAlphaInfo.premultipliedFirst | Bitmap.Config.ARGB_8888 |
64 位色(R16+G16+B16+A16 但使用半精度減少一半儲存空間)用於寬色域或HDR | bitsPerComponent: 16 bitsPerPixel: 64 bitmapInfo: CGImageAlphaInfo.premultipliedLast + .floatComponents | Bitmap.Config.RGBA_F16 |
JPG 的壓縮係數改變
JPG 的壓縮演算法相當複雜,以至於主流使用均是用libjpeg 這個廣泛的庫進行編解碼(在 Android 7.0 上開始使用效能更好的libjpeg-turbo,iOS 則是用 Apple 自己開發未開源的 AppleJPEG)。而在 iOS 和 Android 上,都有 Api 輸入壓縮係數,來壓縮 JPG。但具體壓縮係數如何影響壓縮大小,筆者並未深究。這裡只能簡單給出使用方法。
iOS
iOS 裡面壓縮係數為 0-1 之間的數值,據說 iOS 相簿中採用的壓縮係數是 0.9。同時,png 不支援有失真壓縮,所以 kCGImageDestinationLossyCompressionQuality 這個引數是無效。
static func compressImageData(_ rawData:Data, compression:Double) -> Data?{ guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), let writeData = CFDataCreateMutable(nil, 0), let imageType = CGImageSourceGetType(imageSource), let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil) else { return nil } let frameProperties = [kCGImageDestinationLossyCompressionQuality: compression] as CFDictionary CGImageDestinationAddImageFromSource(imageDestination, imageSource, 0, frameProperties) guard CGImageDestinationFinalize(imageDestination) else { return nil } return writeData as Data } 複製程式碼
Andoid
Andoird 用 Bitmap 自帶的介面,並輸出到流中。壓縮係數是 0-100 之間的數值。這裡的引數雖然可以填 Bitmap.CompressFormat.PNG,但當然也是無效的。
val outputStream = ByteArrayOutputStream() val image = BitmapFactory.decodeByteArray(rawData,0,rawData.count()) image.compress(Bitmap.CompressFormat.JPEG, compression, outputStream) resultData = outputStream.toByteArray() 複製程式碼
GIF 的壓縮
GIF 壓縮上有很多種思路。參考開源專案 gifsicle 和 ImageMagick 中的做法,大概有以下幾種。
-
由於 GIF 支援全域性調色盤和區域性調色盤,在沒有區域性調色盤的時候會用放在檔案頭中的全域性調色盤。所以對於顏色變化不大的 GIF,可以將顏色放入全域性調色盤中,去除區域性調色盤。
-
對於顏色較少的 GIF,將調色盤大小減少,比如從 256 種減少到 128 種等。
-
對於背景一致,畫面中有一部分元素在變化的 GIF,可以將多個元素和背景分開儲存,然後加上如何還原的資訊
-
對於背景一致,畫面中有一部分元素在動的 GIF,可以和前面一幀比較,將不動的部分透明化
-
對於幀數很多的 GIF,可以抽取中間部分的幀,減少幀數
-
對於每幀解析度很高的 GIF,將每幀的解析度減小
對於動畫的 GIF,3、4 是很實用的,因為背景一般是不變的,但對於拍攝的視訊轉成的 GIF,就沒那麼實用了,因為存在輕微抖動,很難做到背景不變。但在移動端,除非將 ImageMagick 或者 gifsicle 移植到 iOS&Android 上,要實現前面 4 個方法是比較困難的。筆者這裡只實現了抽幀,和每幀解析度壓縮。
至於抽幀的間隔,參考了文章中的數值。
幀數 | 每 x 幀使用 1 幀 |
---|---|
<9 | x = 2 |
9 - 20 | x = 3 |
21 - 30 | x = 4 |
31 - 40 | x = 5 |
>40 | x = 6 |
這裡還有一個問題,抽幀的時候,原來的幀可能使用了 3、4 的方法進行壓縮過,但還原的時候需要還原成完整的影象幀,再重新編碼時,就沒有辦法再用 3、4 進行優化了。雖然幀減少了,但實際上會將幀還原成未做 3、4 優化的狀態,一增一減,壓縮的效果就沒那麼好了(所以這種壓縮還是儘量在伺服器做)。抽幀後記得將中間被抽取的幀的時間累加在剩下的幀的時間上,不然幀速度就變快了,而且不要用抽取數x幀時間偷懶來計算,因為不一定所有幀的時間是一樣的。
iOS
iOS 上的實現比較簡單,用 ImageIO 的函式即可實現,效能也比較好。
先定義從 ImageSource 獲取每幀的時間的便捷擴充套件方法,幀時長會存在 kCGImagePropertyGIFUnclampedDelayTime 或者 kCGImagePropertyGIFDelayTime 中,兩個 key 不同之處在於後者有最小值的限制,正確的獲取方法參考蘋果在 WebKit 中的使用方法。
extension CGImageSource { func frameDurationAtIndex(_ index: Int) -> Double{ var frameDuration = Double(0.1) guard let frameProperties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) as? [AnyHashable:Any], let gifProperties = frameProperties[kCGImagePropertyGIFDictionary] as? [AnyHashable:Any] else { return frameDuration } if let unclampedDuration = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? NSNumber { frameDuration = unclampedDuration.doubleValue } else { if let clampedDuration = gifProperties[kCGImagePropertyGIFDelayTime] as? NSNumber { frameDuration = clampedDuration.doubleValue } } if frameDuration < 0.011 { frameDuration = 0.1 } return frameDuration } var frameDurations:[Double]{ let frameCount = CGImageSourceGetCount(self) return (0..<frameCount).map{ self.frameDurationAtIndex($0) } } } 複製程式碼
先去掉不要的幀,合併幀的時間,再重新生成幀就完成了。注意幀不要被拖得太長,不然體驗不好,我這裡給的最大值是 200ms。
/// 同步壓縮圖片抽取幀數,僅支援 GIF /// /// - Parameters: ///- rawData: 原始圖片資料 ///- sampleCount: 取樣頻率,比如 3 則每三張用第一張,然後延長時間 /// - Returns: 處理後資料 static func compressImageData(_ rawData:Data, sampleCount:Int) -> Data?{ guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), let writeData = CFDataCreateMutable(nil, 0), let imageType = CGImageSourceGetType(imageSource) else { return nil } // 計算幀的間隔 let frameDurations = imageSource.frameDurations // 合併幀的時間,最長不可高於 200ms let mergeFrameDurations = (0..<frameDurations.count).filter{ $0 % sampleCount == 0 }.map{ min(frameDurations[$0..<min($0 + sampleCount, frameDurations.count)].reduce(0.0) { $0 + $1 }, 0.2) } // 抽取幀 每 n 幀使用 1 幀 let sampleImageFrames = (0..<frameDurations.count).filter{ $0 % sampleCount == 0 }.compactMap{ CGImageSourceCreateImageAtIndex(imageSource, $0, nil) } guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, sampleImageFrames.count, nil) else{ return nil } // 每一幀圖片都進行重新編碼 zip(sampleImageFrames, mergeFrameDurations).forEach{ // 設定幀間隔 let frameProperties = [kCGImagePropertyGIFDictionary : [kCGImagePropertyGIFDelayTime: $1, kCGImagePropertyGIFUnclampedDelayTime: $1]] CGImageDestinationAddImage(imageDestination, $0, frameProperties as CFDictionary) } guard CGImageDestinationFinalize(imageDestination) else { return nil } return writeData as Data } 複製程式碼
壓縮解析度也是類似的,每幀按解析度壓縮再重新編碼就好。
Android
Android 原生對於 GIF 的支援就不怎麼友好了,由於筆者 Android 研究不深,暫時先用 Glide 中的 GIF 編解碼元件來完成。編碼的效能比較一般,比不上 iOS,但除非換用更底層 C++ 庫實現的編碼庫,Java 寫的效能都很普通。先用 Gradle 匯入 Glide,注意解碼器是預設的,但編碼器需要另外匯入。
api 'com.github.bumptech.glide:glide:4.8.0' api 'com.github.bumptech.glide:gifencoder-integration:4.8.0' 複製程式碼
抽幀思路和 iOS 一樣,只是 Glide 的這個 GIF 解碼器沒辦法按指定的 index 取讀取某一幀,只能一幀幀讀取,呼叫 advance 方法往後讀取。先從 GIF 讀出頭部資訊,然後在讀真正的幀資訊。
/** * 返回同步壓縮 gif 圖片 Byte 資料 [rawData] 的按 [sampleCount] 取樣後的 Byte 資料 */ private fun compressGifDataWithSampleCount(context: Context, rawData: ByteArray, sampleCount: Int): ByteArray? { if (sampleCount <= 1) { return rawData } val gifDecoder = StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool)) val headerParser = GifHeaderParser() headerParser.setData(rawData) val header = headerParser.parseHeader() gifDecoder.setData(header, rawData) val frameCount = gifDecoder.frameCount // 計算幀的間隔 val frameDurations = (0 until frameCount).map { gifDecoder.getDelay(it) } // 合併幀的時間,最長不可高於 200ms val mergeFrameDurations = (0 until frameCount).filter { it % sampleCount == 0 }.map { min( frameDurations.subList( it, min(it + sampleCount, frameCount) ).fold(0) { acc, duration -> acc + duration }, 200 ) } // 抽取幀 val sampleImageFrames = (0 until frameCount).mapNotNull { gifDecoder.advance() var imageFrame: Bitmap? = null if (it % sampleCount == 0) { imageFrame = gifDecoder.nextFrame } imageFrame } val gifEncoder = AnimatedGifEncoder() var resultData: ByteArray? = null try { val outputStream = ByteArrayOutputStream() gifEncoder.start(outputStream) gifEncoder.setRepeat(0) // 每一幀圖片都進行重新編碼 sampleImageFrames.zip(mergeFrameDurations).forEach { // 設定幀間隔 gifEncoder.setDelay(it.second) gifEncoder.addFrame(it.first) it.first.recycle() } gifEncoder.finish() resultData = outputStream.toByteArray() outputStream.close() } catch (e: IOException) { e.printStackTrace() } return resultData } 複製程式碼
壓縮解析度的時候要注意,解析度太大編碼容易出現 Crash(應該是 OOM),這裡設定為 512。
/** * 返回同步壓縮 gif 圖片 Byte 資料 [rawData] 每一幀長邊到 [limitLongWidth] 後的 Byte 資料 */ private fun compressGifDataWithLongWidth(context: Context, rawData: ByteArray, limitLongWidth: Int): ByteArray? { val gifDecoder = StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool)) val headerParser = GifHeaderParser() headerParser.setData(rawData) val header = headerParser.parseHeader() gifDecoder.setData(header, rawData) val frameCount = gifDecoder.frameCount // 計算幀的間隔 val frameDurations = (0..(frameCount - 1)).map { gifDecoder.getDelay(it) } // 計算調整後大小 val longSideWidth = max(header.width, header.height) val ratio = limitLongWidth.toFloat() / longSideWidth.toFloat() val resizeWidth = (header.width.toFloat() * ratio).toInt() val resizeHeight = (header.height.toFloat() * ratio).toInt() // 每一幀進行縮放 val resizeImageFrames = (0 until frameCount).mapNotNull { gifDecoder.advance() var imageFrame = gifDecoder.nextFrame if (imageFrame != null) { imageFrame = Bitmap.createScaledBitmap(imageFrame, resizeWidth, resizeHeight, true) } imageFrame } val gifEncoder = AnimatedGifEncoder() var resultData: ByteArray? = null try { val outputStream = ByteArrayOutputStream() gifEncoder.start(outputStream) gifEncoder.setRepeat(0) // 每一幀都進行重新編碼 resizeImageFrames.zip(frameDurations).forEach { // 設定幀間隔 gifEncoder.setDelay(it.second) gifEncoder.addFrame(it.first) it.first.recycle() } gifEncoder.finish() resultData = outputStream.toByteArray() outputStream.close() return resultData } catch (e: IOException) { e.printStackTrace() } return resultData } 複製程式碼
解析度壓縮
這個是最常用的,而且也比較簡單。
iOS
iOS 的 ImageIO 提供了 CGImageSourceCreateThumbnailAtIndex 的 API 來建立縮放的縮圖。在 options 中新增需要縮放的長邊引數即可。
/// 同步壓縮圖片資料長邊到指定數值 /// /// - Parameters: ///- rawData: 原始圖片資料 ///- limitLongWidth: 長邊限制 /// - Returns: 處理後資料 public static func compressImageData(_ rawData:Data, limitLongWidth:CGFloat) -> Data?{ guard max(rawData.imageSize.height, rawData.imageSize.width) > limitLongWidth else { return rawData } guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), let writeData = CFDataCreateMutable(nil, 0), let imageType = CGImageSourceGetType(imageSource) else { return nil } let frameCount = CGImageSourceGetCount(imageSource) guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, frameCount, nil) else{ return nil } // 設定縮圖引數,kCGImageSourceThumbnailMaxPixelSize 為生成縮圖的大小。當設定為 800,如果圖片本身大於 800*600,則生成後圖片大小為 800*600,如果源圖片為 700*500,則生成圖片為 800*500 let options = [kCGImageSourceThumbnailMaxPixelSize: limitLongWidth, kCGImageSourceCreateThumbnailWithTransform:true, kCGImageSourceCreateThumbnailFromImageIfAbsent:true] as CFDictionary if frameCount > 1 { // 計算幀的間隔 let frameDurations = imageSource.frameDurations // 每一幀都進行縮放 let resizedImageFrames = (0..<frameCount).compactMap{ CGImageSourceCreateThumbnailAtIndex(imageSource, $0, options) } // 每一幀都進行重新編碼 zip(resizedImageFrames, frameDurations).forEach { // 設定幀間隔 let frameProperties = [kCGImagePropertyGIFDictionary : [kCGImagePropertyGIFDelayTime: $1, kCGImagePropertyGIFUnclampedDelayTime: $1]] CGImageDestinationAddImage(imageDestination, $0, frameProperties as CFDictionary) } } else { guard let resizedImageFrame = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options) else { return nil } CGImageDestinationAddImage(imageDestination, resizedImageFrame, nil) } guard CGImageDestinationFinalize(imageDestination) else { return nil } return writeData as Data } 複製程式碼
Android
Android 靜態圖用 Bitmap 裡面的 createScaleBitmap API 就好了,GIF 上文已經講了。
/** * 返回同步壓縮圖片 Byte 資料 [rawData] 的長邊到 [limitLongWidth] 後的 Byte 資料,Gif 目標長邊最大壓縮到 512,超過用 512 */ fun compressImageDataWithLongWidth(context: Context, rawData: ByteArray, limitLongWidth: Int): ByteArray? { val format = rawData.imageFormat() if (format == ImageFormat.UNKNOWN) { return null } val (imageWidth, imageHeight) = rawData.imageSize() val longSideWidth = max(imageWidth, imageHeight) if (longSideWidth <= limitLongWidth) { return rawData } if (format == ImageFormat.GIF) { // 壓縮 Gif 解析度太大編碼時容易崩潰 return compressGifDataWithLongWidth(context, rawData, max(512, longSideWidth)) } else { val image = BitmapFactory.decodeByteArray(rawData, 0, rawData.size) val ratio = limitLongWidth.toDouble() / longSideWidth.toDouble() val resizeImageFrame = Bitmap.createScaledBitmap( image, (image.width.toDouble() * ratio).toInt(), (image.height.toDouble() * ratio).toInt(), true ) image.recycle() var resultData: ByteArray? = null when (format) { ImageFormat.PNG -> { resultData = resizeImageFrame.toByteArray(Bitmap.CompressFormat.PNG) } ImageFormat.JPG -> { resultData = resizeImageFrame.toByteArray(Bitmap.CompressFormat.JPEG) } else -> { } } resizeImageFrame.recycle() return resultData } } 複製程式碼
限制大小的壓縮方式
也就是將前面講的方法綜合起來,筆者這邊給出一種方案,沒有對色彩進行改變,JPG 先用二分法減少最多 6 次的壓縮係數,GIF 先抽幀,抽幀間隔參考前文,最後採用逼近目標大小縮小解析度。
iOS
/// 同步壓縮圖片到指定檔案大小 /// /// - Parameters: ///- rawData: 原始圖片資料 ///- limitDataSize: 限制檔案大小,單位位元組 /// - Returns: 處理後資料 public static func compressImageData(_ rawData:Data, limitDataSize:Int) -> Data?{ guard rawData.count > limitDataSize else { return rawData } var resultData = rawData // 若是 JPG,先用壓縮係數壓縮 6 次,二分法 if resultData.imageFormat == .jpg { var compression: Double = 1 var maxCompression: Double = 1 var minCompression: Double = 0 for _ in 0..<6 { compression = (maxCompression + minCompression) / 2 if let data = compressImageData(resultData, compression: compression){ resultData = data } else { return nil } if resultData.count < Int(CGFloat(limitDataSize) * 0.9) { minCompression = compression } else if resultData.count > limitDataSize { maxCompression = compression } else { break } } if resultData.count <= limitDataSize { return resultData } } // 若是 GIF,先用抽幀減少大小 if resultData.imageFormat == .gif { let sampleCount = resultData.fitSampleCount if let data = compressImageData(resultData, sampleCount: sampleCount){ resultData = data } else { return nil } if resultData.count <= limitDataSize { return resultData } } var longSideWidth = max(resultData.imageSize.height, resultData.imageSize.width) // 圖片尺寸按比率縮小,比率按位元組比例逼近 while resultData.count > limitDataSize{ let ratio = sqrt(CGFloat(limitDataSize) / CGFloat(resultData.count)) longSideWidth *= ratio if let data = compressImageData(resultData, limitLongWidth: longSideWidth) { resultData = data } else { return nil } } return resultData } 複製程式碼
Android
/** * 返回同步壓縮圖片 Byte 資料 [rawData] 的資料大小到 [limitDataSize] 後的 Byte 資料 */ fun compressImageDataWithSize(context: Context, rawData: ByteArray, limitDataSize: Int): ByteArray? { if (rawData.size <= limitDataSize) { return rawData } val format = rawData.imageFormat() if (format == ImageFormat.UNKNOWN) { return null } var resultData = rawData // 若是 JPG,先用壓縮係數壓縮 6 次,二分法 if (format == ImageFormat.JPG) { var compression = 100 var maxCompression = 100 var minCompression = 0 try { val outputStream = ByteArrayOutputStream() for (index in 0..6) { compression = (maxCompression + minCompression) / 2 outputStream.reset() val image = BitmapFactory.decodeByteArray(rawData, 0, rawData.size) image.compress(Bitmap.CompressFormat.JPEG, compression, outputStream) image.recycle() resultData = outputStream.toByteArray() if (resultData.size < (limitDataSize.toDouble() * 0.9).toInt()) { minCompression = compression } else if (resultData.size > limitDataSize) { maxCompression = compression } else { break } } outputStream.close() } catch (e: IOException) { e.printStackTrace() } if (resultData.size <= limitDataSize) { return resultData } } // 若是 GIF,先用抽幀減少大小 if (format == ImageFormat.GIF) { val sampleCount = resultData.fitSampleCount() val data = compressGifDataWithSampleCount(context, resultData, sampleCount) if (data != null) { resultData = data } else { return null } if (resultData.size <= limitDataSize) { return resultData } } val (imageWidth, imageHeight) = resultData.imageSize() var longSideWidth = max(imageWidth, imageHeight) // 圖片尺寸按比率縮小,比率按位元組比例逼近 while (resultData.size > limitDataSize) { val ratio = Math.sqrt(limitDataSize.toDouble() / resultData.size.toDouble()) longSideWidth = (longSideWidth.toDouble() * ratio).toInt() val data = compressImageDataWithLongWidth(context, resultData, longSideWidth) if (data != null) { resultData = data } else { return null } } return resultData } 複製程式碼
注意在非同步執行緒中使用,畢竟是耗時操作。