在 App 中,若是分享、發佈、上傳功能涉及到圖片,必不可少會對圖片進行必定程度的壓縮。筆者最近在公司項目中剛好重構了雙端(iOS&Android)的圖片壓縮模塊。本文會很是基礎的講解一些圖片壓縮的方式和思路。java
表示顏色時,有兩種形式,一種爲索引色(Index Color),一種爲直接色(Direct Color)ios
圖片格式中通常分爲靜態圖和動態圖git
系統 | 原生 | WebView | 瀏覽器 |
---|---|---|---|
iOS | 第三方庫支持 | 不支持 | 不支持 |
Android | 4.3 後支持完整功能 | 支持 | 支持 |
系統 | 原生 | WebView | 瀏覽器 |
---|---|---|---|
iOS | 支持 | 支持 | 支持 |
Android | 第三方庫支持 | 不支持 | 不支持 |
系統 | 原生 | WebView | 系統瀏覽器 |
---|---|---|---|
iOS | 第三方庫支持 | 不支持 | 不支持 |
Android | 第三方庫支持 | 不支持 | 不支持 |
而因爲通常項目須要兼容三端(iOS、Android、Web 的關係),最簡單就是支持 JPG、PNG、GIF 這三種通用的格式。因此本文暫不討論其他圖片格式的壓縮。github
根據個人瞭解,畫了一下 iOS&Android 圖片處理架構。iOS 這邊,也是能夠直接調用底層一點的框架的。web
本文 iOS 端處理圖片主要用 ImageIO 框架,使用的緣由主要是靜態圖動態圖 API 調用保持一致,且不會由於 UIImage 轉換時會丟失一部分數據的信息。算法
ImageIO 主要提供了圖片編解碼功能,封裝了一套 C 語言接口。在 Swift 中不須要對 C 對象進行內存管理,會比 Objective-C 中使用方便很多,但 api 結果返回都是 Optional(實際上非空),須要用 guard/if,或者 !進行轉換。swift
CGImageSource 至關於 ImageIO 數據來源的抽象類。通用的使用方式 CGImageSourceCreateWithDataProvider:
須要提供一個 DataProvider,能夠指定文件、URL、Data 等輸入。也有經過傳入 CFData 來進行建立的便捷方法 CGImageSourceCreateWithData:
。方法的第二個參數 options 傳入一個字典進行配置。根據 Apple 在 WWDC 2018 上的 Image and Graphics Best Practices 上的例子,當不須要解碼僅須要建立 CGImageSource 的時候,應該將 kCGImageSourceShouldCache 設爲 false。segmentfault
用 CGImageSourceCreateImageAtIndex:
或者 CGImageSourceCreateThumbnailAtIndex:
來獲取生成的 CGImage,這裏參數的 Index 就是第幾幀圖片,靜態圖傳入 0 便可。api
CGImageDestination 至關於 ImageIO 數據輸出的抽象類。通用的使用方式 CGImageDestinationCreateWithDataConsumer:
須要提供一個 DataConsumer,能夠置頂 URL、Data 等輸入。也有經過傳入 CFData 來進行建立的便捷方法 CGImageDestinationCreateWithData:
,輸出會寫入到傳入的 Data 中。方法還須要提供圖片類型,圖片幀數。瀏覽器
添加 CGImage 使用 CGImageDestinationAddImage:
方法,動圖的話,按順序屢次調用就好了。
並且還有一個特別的 CGImageDestinationAddImageFromSource:
方法,添加的實際上是一個 CGImageSource,有什麼用呢,經過 options 參數,達到改變圖像設置的做用。好比改變 JPG 的壓縮參數,用上這個功能後,就不須要轉換成更頂層的對象(好比 UIImage),減小了轉換時的編解碼的損耗,達到性能更優的目的。
調用 CGImageDestinationFinalize:
,表示開始編碼,完成後會返回一個 Bool 值,並將數據寫入 CGImageDestination 提供的 DataConsumer 中。
位圖佔用的空間大小,其實就是像素數量x單像素佔用空間x幀數。因此減少圖片空間大小,其實就從這三個方向下手。其中單像素佔用空間,在直接色的狀況下,主要和色彩深度相關。在實際項目中,改變色彩深度會致使圖片顏色和原圖沒有保持徹底一致,筆者並不建議對色彩深度進行更改。而像素數量就是平時很是經常使用的圖片分辨率縮放。除此以外,JPG 格式還有特有的經過指定壓縮係數來進行有損壓縮。
後綴擴展名來判斷其實並不保險,真實的判斷方式應該是經過文件頭裏的信息進行判斷。
JPG | PNG | GIF |
---|---|---|
開頭:FF D8 + 結尾:FF D9 | 89 50 4E 47 0D 0A 1A 0A | 47 49 46 38 39/37 61 |
簡單判斷用前三個字節來判斷
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 |
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 中,改變色彩深度,原生的 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)
複製代碼
那麼對於經常使用的色彩深度,就能夠用這些參數的組合來完成。同時筆者在查看更底層的 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 來講,其原生的 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 的壓縮算法至關複雜,以致於主流使用均是用 libjpeg 這個普遍的庫進行編解碼(在 Android 7.0 上開始使用性能更好的 libjpeg-turbo,iOS 則是用 Apple 本身開發未開源的 AppleJPEG)。而在 iOS 和 Android 上,都有 Api 輸入壓縮係數,來壓縮 JPG。但具體壓縮係數如何影響壓縮大小,筆者並未深究。這裏只能簡單給出使用方法。
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
}
複製代碼
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 壓縮上有不少種思路。參考開源項目 gifsicle 和 ImageMagick 中的作法,大概有如下幾種。
因爲 GIF 支持全局調色盤和局部調色盤,在沒有局部調色盤的時候會用放在文件頭中的全局調色盤。因此對於顏色變化不大的 GIF,能夠將顏色放入全局調色盤中,去除局部調色盤。
對於顏色較少的 GIF,將調色盤大小減小,好比從 256 種減小到 128 種等。
對於背景一致,畫面中有一部分元素在變化的 GIF,能夠將多個元素和背景分開存儲,而後加上如何還原的信息
對於背景一致,畫面中有一部分元素在動的 GIF,能夠和前面一幀比較,將不動的部分透明化
對於幀數不少的 GIF,能夠抽取中間部分的幀,減小幀數
對於每幀分辨率很高的 GIF,將每幀的分辨率減少
對於動畫的 GIF,三、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 |
這裏還有一個問題,抽幀的時候,原來的幀可能使用了 三、4 的方法進行壓縮過,但還原的時候須要還原成完整的圖像幀,再從新編碼時,就沒有辦法再用 三、4 進行優化了。雖然幀減小了,但實際上會將幀還原成未作 三、4 優化的狀態,一增一減,壓縮的效果就沒那麼好了(因此這種壓縮仍是儘可能在服務器作)。抽幀後記得將中間被抽取的幀的時間累加在剩下的幀的時間上,否則幀速度就變快了,並且不要用抽取數x幀時間偷懶來計算,由於不必定全部幀的時間是同樣的。
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 原生對於 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 的 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 靜態圖用 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 先抽幀,抽幀間隔參考前文,最後採用逼近目標大小縮小分辨率。
/// 同步壓縮圖片到指定文件大小
///
/// - 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
}
複製代碼
/** * 返回同步壓縮圖片 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
}
複製代碼
注意在異步線程中使用,畢竟是耗時操做。
全部代碼均封裝成文件在 iOS 和 Android 中了,若有錯誤和建議,歡迎指出。