淺談移動端圖片壓縮(iOS & Android)

在 App 中,如果分享、發(fā)布、上傳功能涉及到圖片,必不可少會對圖片進行一定程度的壓縮。筆者最近在公司項目中恰好重構了雙端(iOS&Android)的圖片壓縮模塊。本文會非常基礎的講解一些圖片壓縮的方式和思路。

圖片格式基礎

點陣圖&矢量圖

  • 點陣圖:也叫位圖。用像素為單位,像素保存顏色信息,排列像素實現(xiàn)顯示。
  • 矢量圖:記錄元素形狀和顏色的算法,顯示時展示算法運算的結果。

顏色

表示顏色時,有兩種形式,一種為索引色(Index Color),一種為直接色(Direct Color)

  • 索引色:用一個數(shù)字索引代表一種顏色,在圖像信息中存儲數(shù)字到顏色的映射關系表(調色盤 Palette)。每個像素保存該像素顏色對應的數(shù)字索引。一般調色盤只能存儲有限種類的顏色,通常為 256 種。所以每個像素的數(shù)字占用 1 字節(jié)(8 bit)大小。
  • 直接色:用四個數(shù)字來代表一種顏色,數(shù)字分別對應顏色中紅色,綠色,藍色,透明度(RGBA)。每個像素保存這四個緯度的信息來代表該像素的顏色。根據(jù)色彩深度(每個像素存儲顏色信息的 bit 數(shù)不同),最多可以支持的顏色種類也不同,常見的有 8 位(R3+G3+B2)、16 位(R5+G6+B5)、24 位(R8+G8+B8)、32 位(A8+R8+G8+B8)。所以每個像素占用 1~4 字節(jié)大小。

移動端常用圖片格式

圖片格式中一般分為靜態(tài)圖和動態(tài)圖

靜態(tài)圖
  • JPG:是支持 JPEG( 一種有損壓縮方法)標準中最常用的圖片格式。采用點陣圖。常見的是使用 24 位的顏色深度的直接色(不支持透明)。

  • PNG:是支持無損壓縮的圖片格式。采用點陣圖。PNG 有 5 種顏色選項:索引色、灰度、灰度透明、真彩色(24 位直接色)、真彩色透明(32 位直接色)。

  • WebP:是同時支持有損壓縮和無所壓縮的的圖片格式。采用點陣圖。支持 32 位直接色。移動端支持情況如下:

系統(tǒng) 原生 WebView 瀏覽器
iOS 第三方庫支持 不支持 不支持
Android 4.3 后支持完整功能 支持 支持
動態(tài)圖
  • GIF:是支持無損壓縮的圖片格式。采用點陣圖。使用索引色,并有 1 位透明度通道(透明與否)。

  • APNG:基于 PNG 格式擴展的格式,加入動態(tài)圖支持。采用點陣圖。使用 32 位直接色。但沒有被官方 PNG 接納。移動端支持情況如下:

系統(tǒng) 原生 WebView 瀏覽器
iOS 支持 支持 支持
Android 第三方庫支持 不支持 不支持
  • Animated Webp:Webp 的動圖形式,實際上是文件中打包了多個單幀 Webp,在 libwebp 0.4 后開始支持。移動端支持情況如下:
系統(tǒng) 原生 WebView 系統(tǒng)瀏覽器
iOS 第三方庫支持 不支持 不支持
Android 第三方庫支持 不支持 不支持

而由于一般項目需要兼容三端(iOS、Android、Web 的關系),最簡單就是支持 JPG、PNG、GIF 這三種通用的格式。所以本文暫不討論其余圖片格式的壓縮。

移動端系統(tǒng)圖片處理架構

根據(jù)我的了解,畫了一下 iOS&Android 圖片處理架構。iOS 這邊,也是可以直接調用底層一點的框架的。

屏幕快照 2019-01-13 下午9.37.00

iOS 的 ImageIO

本文 iOS 端處理圖片主要用 ImageIO 框架,使用的原因主要是靜態(tài)圖動態(tài)圖 API 調用保持一致,且不會因為 UIImage 轉換時會丟失一部分數(shù)據(jù)的信息。

ImageIO 主要提供了圖片編解碼功能,封裝了一套 C 語言接口。在 Swift 中不需要對 C 對象進行內(nèi)存管理,會比 Objective-C 中使用方便不少,但 api 結果返回都是 Optional(實際上非空),需要用 guard/if,或者 !進行轉換。

解碼

1. 創(chuàng)建 CGImageSource

CGImageSource 相當于 ImageIO 數(shù)據(jù)來源的抽象類。通用的使用方式 CGImageSourceCreateWithDataProvider: 需要提供一個 DataProvider,可以指定文件、URL、Data 等輸入。也有通過傳入 CFData 來進行創(chuàng)建的便捷方法 CGImageSourceCreateWithData:。方法的第二個參數(shù) options 傳入一個字典進行配置。根據(jù) Apple 在 WWDC 2018 上的 Image and Graphics Best Practices 上的例子,當不需要解碼僅需要創(chuàng)建 CGImageSource 的時候,應該將 kCGImageSourceShouldCache 設為 false。

11994763-6f25c32bd4d3b427
2. 解碼得到 CGImage

CGImageSourceCreateImageAtIndex: 或者 CGImageSourceCreateThumbnailAtIndex: 來獲取生成的 CGImage,這里參數(shù)的 Index 就是第幾幀圖片,靜態(tài)圖傳入 0 即可。

編碼

1. 創(chuàng)建 CGImageDestination

CGImageDestination 相當于 ImageIO 數(shù)據(jù)輸出的抽象類。通用的使用方式 CGImageDestinationCreateWithDataConsumer: 需要提供一個 DataConsumer,可以置頂 URL、Data 等輸入。也有通過傳入 CFData 來進行創(chuàng)建的便捷方法 CGImageDestinationCreateWithData:,輸出會寫入到傳入的 Data 中。方法還需要提供圖片類型,圖片幀數(shù)。

2. 添加 CGImage

添加 CGImage 使用 CGImageDestinationAddImage: 方法,動圖的話,按順序多次調用就行了。

而且還有一個特別的 CGImageDestinationAddImageFromSource: 方法,添加的其實是一個 CGImageSource,有什么用呢,通過 options 參數(shù),達到改變圖像設置的作用。比如改變 JPG 的壓縮參數(shù),用上這個功能后,就不需要轉換成更頂層的對象(比如 UIImage),減少了轉換時的編解碼的損耗,達到性能更優(yōu)的目的。

3. 進行編碼

調用 CGImageDestinationFinalize: ,表示開始編碼,完成后會返回一個 Bool 值,并將數(shù)據(jù)寫入 CGImageDestination 提供的 DataConsumer 中。

壓縮思路分析

位圖占用的空間大小,其實就是像素數(shù)量x單像素占用空間x幀數(shù)。所以減小圖片空間大小,其實就從這三個方向下手。其中單像素占用空間,在直接色的情況下,主要和色彩深度相關。在實際項目中,改變色彩深度會導致圖片顏色和原圖沒有保持完全一致,筆者并不建議對色彩深度進行更改。而像素數(shù)量就是平時非常常用的圖片分辨率縮放。除此之外,JPG 格式還有特有的通過指定壓縮系數(shù)來進行有損壓縮。

  • JPG:壓縮系數(shù) + 分辨率縮放 + 色彩深度降低
  • PNG: 分辨率縮放 + 降低色彩深度
  • GIF:減少幀數(shù) + 每幀分辨率縮放 + 減小調色盤

判斷圖片格式

后綴擴展名來判斷其實并不保險,真實的判斷方式應該是通過文件頭里的信息進行判斷。

JPG PNG GIF
開頭:FF D8 + 結尾:FF D9 89 50 4E 47 0D 0A 1A 0A 47 49 46 38 39/37 61

簡單判斷用前三個字節(jié)來判斷

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 位,但顏色的改變并一定能讓產(chǎn)品、用戶、設計接受,所以筆者在壓縮過程并沒有實際使用改變色彩深度的方法,僅僅研究了做法。

iOS

在 iOS 中,改變色彩深度,原生的 CGImage 庫中,沒有簡單的方法。需要自己設置參數(shù),重新生成 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 每個通道占用位數(shù)
  • bitsPerPixel 每個像素占用位數(shù),相當于所有通道加起來的位數(shù),也就是色彩深度
  • bytesPerRow 傳入 0 即可,系統(tǒng)會自動計算
  • space 色彩空間
  • bitmapInfo 這個是一個很重要的東西,其中常用的信息有 CGImageAlphaInfo,代表是否有透明通道,透明通道在前還是后面(ARGB 還是 RGBA),是否有浮點數(shù)(floatComponents),CGImageByteOrderInfo,代表字節(jié)順序,采用大端還是小端,以及數(shù)據(jù)單位寬度,iOS 一般采用 32 位小端模式,一般用 orderDefault 就好。

那么對于常用的色彩深度,就可以用這些參數(shù)的組合來完成。同時筆者在查看更底層的 vImage 框架的 vImage_CGImageFormat 結構體時(CGImage 底層也是使用 vImage,具體可查看 Accelerate 框架 vImage 庫的 vImage_Utilities 文件),發(fā)現(xiàn)了 Apple 的注釋,里面也包含了常用的色彩深度用的參數(shù)。

屏幕快照 2019-01-15 下午9.16.40

這一塊為了和 Android 保持一致,筆者封裝了 Android 常用的色彩深度參數(shù)對應的枚舉值。

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 參數(shù)也可以對應起來了。

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 的使用步驟,只是參數(shù)不一樣。

    /// 改變圖片到指定的色彩配置
    ///
    /// - Parameters:
    ///   - rawData: 原始圖片數(shù)據(jù)
    ///   - config: 色彩配置
    /// - Returns: 處理后數(shù)據(jù)
    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: 原始圖片數(shù)據(jù)
    /// - 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 參數(shù)和 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 的壓縮系數(shù)改變

JPG 的壓縮算法相當復雜,以至于主流使用均是用 libjpeg 這個廣泛的庫進行編解碼(在 Android 7.0 上開始使用性能更好的 libjpeg-turbo,iOS 則是用 Apple 自己開發(fā)未開源的 AppleJPEG)。而在 iOS 和 Android 上,都有 Api 輸入壓縮系數(shù),來壓縮 JPG。但具體壓縮系數(shù)如何影響壓縮大小,筆者并未深究。這里只能簡單給出使用方法。

iOS

iOS 里面壓縮系數(shù)為 0-1 之間的數(shù)值,據(jù)說 iOS 相冊中采用的壓縮系數(shù)是 0.9。同時,png 不支持有損壓縮,所以 kCGImageDestinationLossyCompressionQuality 這個參數(shù)是無效。

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 自帶的接口,并輸出到流中。壓縮系數(shù)是 0-100 之間的數(shù)值。這里的參數(shù)雖然可以填 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 壓縮上有很多種思路。參考開源項目 gifsicleImageMagick 中的做法,大概有以下幾種。

  1. 由于 GIF 支持全局調色盤和局部調色盤,在沒有局部調色盤的時候會用放在文件頭中的全局調色盤。所以對于顏色變化不大的 GIF,可以將顏色放入全局調色盤中,去除局部調色盤。

  2. 對于顏色較少的 GIF,將調色盤大小減少,比如從 256 種減少到 128 種等。

    1490353055438_2367_1490353055781
    1490353098026_7360_1490353098210
  3. 對于背景一致,畫面中有一部分元素在變化的 GIF,可以將多個元素和背景分開存儲,然后加上如何還原的信息

    b522ac7896b320b4a9ee1eed1034e4fe_articlex
    9e9fe93459fe7117909eb27771bdc182_articlex
    433b41c29c6a70e64631a3d4c363e468_articlex
  4. 對于背景一致,畫面中有一部分元素在動的 GIF,可以和前面一幀比較,將不動的部分透明化

    d3c7444d59eed11d98abbb7c4e1da7ec_articlex
    e50b7f75feebb9bd056bb8dca9964873_articlex
    704d70c65d22fb240cb5f6f7be5bbf86_articlex
  5. 對于幀數(shù)很多的 GIF,可以抽取中間部分的幀,減少幀數(shù)

  6. 對于每幀分辨率很高的 GIF,將每幀的分辨率減小

對于動畫的 GIF,3、4 是很實用的,因為背景一般是不變的,但對于拍攝的視頻轉成的 GIF,就沒那么實用了,因為存在輕微抖動,很難做到背景不變。但在移動端,除非將 ImageMagick 或者 gifsicle 移植到 iOS&Android 上,要實現(xiàn)前面 4 個方法是比較困難的。筆者這里只實現(xiàn)了抽幀,和每幀分辨率壓縮。

至于抽幀的間隔,參考了文章中的數(shù)值。

幀數(shù) 每 x 幀使用 1 幀
<9 x = 2
9 - 20 x = 3
21 - 30 x = 4
31 - 40 x = 5
>40 x = 6

這里還有一個問題,抽幀的時候,原來的幀可能使用了 3、4 的方法進行壓縮過,但還原的時候需要還原成完整的圖像幀,再重新編碼時,就沒有辦法再用 3、4 進行優(yōu)化了。雖然幀減少了,但實際上會將幀還原成未做 3、4 優(yōu)化的狀態(tài),一增一減,壓縮的效果就沒那么好了(所以這種壓縮還是盡量在服務器做)。抽幀后記得將中間被抽取的幀的時間累加在剩下的幀的時間上,不然幀速度就變快了,而且不要用抽取數(shù)x幀時間偷懶來計算,因為不一定所有幀的時間是一樣的。

iOS

iOS 上的實現(xiàn)比較簡單,用 ImageIO 的函數(shù)即可實現(xiàn),性能也比較好。

先定義從 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。

    /// 同步壓縮圖片抽取幀數(shù),僅支持 GIF
    ///
    /// - Parameters:
    ///   - rawData: 原始圖片數(shù)據(jù)
    ///   - sampleCount: 采樣頻率,比如 3 則每三張用第一張,然后延長時間
    /// - Returns: 處理后數(shù)據(jù)
    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++ 庫實現(xiàn)的編碼庫,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 數(shù)據(jù) [rawData] 的按 [sampleCount] 采樣后的 Byte 數(shù)據(jù)
     */
    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
    }

壓縮分辨率的時候要注意,分辨率太大編碼容易出現(xiàn) Crash(應該是 OOM),這里設置為 512。

    /**
     * 返回同步壓縮 gif 圖片 Byte 數(shù)據(jù) [rawData] 每一幀長邊到 [limitLongWidth] 后的 Byte 數(shù)據(jù)
     */
    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 來創(chuàng)建縮放的縮略圖。在 options 中添加需要縮放的長邊參數(shù)即可。

    /// 同步壓縮圖片數(shù)據(jù)長邊到指定數(shù)值
    ///
    /// - Parameters:
    ///   - rawData: 原始圖片數(shù)據(jù)
    ///   - limitLongWidth: 長邊限制
    /// - Returns: 處理后數(shù)據(jù)
    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
        }
        
        // 設置縮略圖參數(shù),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 靜態(tài)圖用 Bitmap 里面的 createScaleBitmap API 就好了,GIF 上文已經(jīng)講了。

   /**
     * 返回同步壓縮圖片 Byte 數(shù)據(jù) [rawData] 的長邊到 [limitLongWidth] 后的 Byte 數(shù)據(jù),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 次的壓縮系數(shù),GIF 先抽幀,抽幀間隔參考前文,最后采用逼近目標大小縮小分辨率。

iOS

    /// 同步壓縮圖片到指定文件大小
    ///
    /// - Parameters:
    ///   - rawData: 原始圖片數(shù)據(jù)
    ///   - limitDataSize: 限制文件大小,單位字節(jié)
    /// - Returns: 處理后數(shù)據(jù)
    public static func compressImageData(_ rawData:Data, limitDataSize:Int) -> Data?{
        guard rawData.count > limitDataSize else {
            return rawData
        }
        
        var resultData = rawData
        
        // 若是 JPG,先用壓縮系數(shù)壓縮 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)
        // 圖片尺寸按比率縮小,比率按字節(jié)比例逼近
        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 數(shù)據(jù) [rawData] 的數(shù)據(jù)大小到 [limitDataSize] 后的 Byte 數(shù)據(jù)
     */
    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,先用壓縮系數(shù)壓縮 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)

        // 圖片尺寸按比率縮小,比率按字節(jié)比例逼近
        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
    }

注意在異步線程中使用,畢竟是耗時操作。

最后

所有代碼均封裝成文件在 iOSAndroid 中了,如有錯誤和建議,歡迎指出。

Reference

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容