浅谈移动端图片压缩(iOS & Android)

分手后的思念是犯贱 2024-04-17 22:23 135阅读 0赞

在 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 这边,也是可以直接调用底层一点的框架的。

format_png

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

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。

format_png 1

11994763-6f25c32bd4d3b427

  1. 解码得到 CGImage

CGImageSourceCreateImageAtIndex: 或者 CGImageSourceCreateThumbnailAtIndex: 来获取生成的 CGImage,这里参数的 Index 就是第几帧图片,静态图传入 0 即可。

编码

  1. 创建 CGImageDestination

CGImageDestination 相当于 ImageIO 数据输出的抽象类。通用的使用方式 CGImageDestinationCreateWithDataConsumer: 需要提供一个 DataConsumer,可以置顶 URL、Data 等输入。也有通过传入 CFData 来进行创建的便捷方法 CGImageDestinationCreateWithData:,输出会写入到传入的 Data 中。方法还需要提供图片类型,图片帧数。

  1. 添加 CGImage

添加 CGImage 使用 CGImageDestinationAddImage: 方法,动图的话,按顺序多次调用就行了。

而且还有一个特别的 CGImageDestinationAddImageFromSource: 方法,添加的其实是一个 CGImageSource,有什么用呢,通过 options 参数,达到改变图像设置的作用。比如改变 JPG 的压缩参数,用上这个功能后,就不需要转换成更顶层的对象(比如 UIImage),减少了转换时的编解码的损耗,达到性能更优的目的。

  1. 进行编码

调用 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

  1. extension Data{
  2. enum ImageFormat {
  3. case jpg, png, gif, unknown
  4. }
  5. var imageFormat:ImageFormat {
  6. var headerData = [UInt8](repeating: 0, count: 3)
  7. self.copyBytes(to: &headerData, from:(0..<3))
  8. let hexString = headerData.reduce("") { $0 + String(($1&0xFF), radix:16) }.uppercased()
  9. var imageFormat = ImageFormat.unknown
  10. switch hexString {
  11. case "FFD8FF": imageFormat = .jpg
  12. case "89504E": imageFormat = .png
  13. case "474946": imageFormat = .gif
  14. default:break
  15. }
  16. return imageFormat
  17. }
  18. }

iOS 中除了可以用文件头信息以外,还可以将 Data 转成 CGImageSource,然后用 CGImageSourceGetType 这个 API,这样会获取到 ImageIO 框架支持的图片格式的的 UTI 标识的字符串。对应的标识符常量定义在 MobileCoreServices 框架下的 UTCoreTypes 中。






















字符串常量 UTI 格式(字符串原始值)
kUTTypePNG public.png
kUTTypeJPEG public.jpeg
kUTTypeGIF com.compuserve.gif

Andorid

  1. enum class ImageFormat{
  2. JPG, PNG, GIF, UNKNOWN
  3. }
  4. fun ByteArray.imageFormat(): ImageFormat {
  5. val headerData = this.slice(0..2)
  6. val hexString = headerData.fold(StringBuilder("")) { result, byte -> result.append( (byte.toInt() and 0xFF).toString(16) ) }.toString().toUpperCase()
  7. var imageFormat = ImageFormat.UNKNOWN
  8. when (hexString) {
  9. "FFD8FF" -> {
  10. imageFormat = ImageFormat.JPG
  11. }
  12. "89504E" -> {
  13. imageFormat = ImageFormat.PNG
  14. }
  15. "474946" -> {
  16. imageFormat = ImageFormat.GIF
  17. }
  18. }
  19. return imageFormat
  20. }

色彩深度改变

实际上,减少深度一般也就是从 32 位减少至 16 位,但颜色的改变并一定能让产品、用户、设计接受,所以笔者在压缩过程并没有实际使用改变色彩深度的方法,仅仅研究了做法。

iOS

在 iOS 中,改变色彩深度,原生的 CGImage 库中,没有简单的方法。需要自己设置参数,重新生成 CGImage。

  1. 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 的注释,里面也包含了常用的色彩深度用的参数。

format_png 2

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

这一块为了和 Android 保持一致,笔者封装了 Android 常用的色彩深度参数对应的枚举值。

  1. public enum ColorConfig{
  2. case alpha8
  3. case rgb565
  4. case argb8888
  5. case rgbaF16
  6. case unknown // 其余色彩配置
  7. }

CGBitmapInfo 由于是 Optional Set,可以封装用到的属性的便捷方法。

  1. extension CGBitmapInfo {
  2. init(_ alphaInfo:CGImageAlphaInfo, _ isFloatComponents:Bool = false) {
  3. var array = [
  4. CGBitmapInfo(rawValue: alphaInfo.rawValue),
  5. CGBitmapInfo(rawValue: CGImageByteOrderInfo.orderDefault.rawValue)
  6. ]
  7. if isFloatComponents {
  8. array.append(.floatComponents)
  9. }
  10. self.init(array)
  11. }
  12. }

那么 ColorConfig 对应的 CGImage 参数也可以对应起来了。

  1. extension ColorConfig{
  2. struct CGImageConfig{
  3. let bitsPerComponent:Int
  4. let bitsPerPixel:Int
  5. let bitmapInfo: CGBitmapInfo
  6. }
  7. var imageConfig:CGImageConfig?{
  8. switch self {
  9. case .alpha8:
  10. return CGImageConfig(bitsPerComponent: 8, bitsPerPixel: 8, bitmapInfo: CGBitmapInfo(.alphaOnly))
  11. case .rgb565:
  12. return CGImageConfig(bitsPerComponent: 5, bitsPerPixel: 16, bitmapInfo: CGBitmapInfo(.noneSkipFirst))
  13. case .argb8888:
  14. return CGImageConfig(bitsPerComponent: 8, bitsPerPixel: 32, bitmapInfo: CGBitmapInfo(.premultipliedFirst))
  15. case .rgbaF16:
  16. return CGImageConfig(bitsPerComponent: 16, bitsPerPixel: 64, bitmapInfo: CGBitmapInfo(.premultipliedLast, true))
  17. case .unknown:
  18. return nil
  19. }
  20. }
  21. }

反过来,判断 CGImage 的 ColorConfig 的方法。

  1. extension CGImage{
  2. var colorConfig:ColorConfig{
  3. if isColorConfig(.alpha8) {
  4. return .alpha8
  5. } else if isColorConfig(.rgb565) {
  6. return .rgb565
  7. } else if isColorConfig(.argb8888) {
  8. return .argb8888
  9. } else if isColorConfig(.rgbaF16) {
  10. return .rgbaF16
  11. } else {
  12. return .unknown
  13. }
  14. }
  15. func isColorConfig(_ colorConfig:ColorConfig) -> Bool{
  16. guard let imageConfig = colorConfig.imageConfig else {
  17. return false
  18. }
  19. if bitsPerComponent == imageConfig.bitsPerComponent &&
  20. bitsPerPixel == imageConfig.bitsPerPixel &&
  21. imageConfig.bitmapInfo.contains(CGBitmapInfo(alphaInfo)) &&
  22. imageConfig.bitmapInfo.contains(.floatComponents) {
  23. return true
  24. } else {
  25. return false
  26. }
  27. }
  28. }

对外封装的 Api,也就是直接介绍的 ImageIO 的使用步骤,只是参数不一样。

  1. /// 改变图片到指定的色彩配置
  2. ///
  3. /// - Parameters:
  4. /// - rawData: 原始图片数据
  5. /// - config: 色彩配置
  6. /// - Returns: 处理后数据
  7. public static func changeColorWithImageData(_ rawData:Data, config:ColorConfig) -> Data?{
  8. guard let imageConfig = config.imageConfig else {
  9. return rawData
  10. }
  11. guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
  12. let writeData = CFDataCreateMutable(nil, 0),
  13. let imageType = CGImageSourceGetType(imageSource),
  14. let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil),
  15. let rawDataProvider = CGDataProvider(data: rawData as CFData),
  16. let imageFrame = CGImage(width: Int(rawData.imageSize.width),
  17. height: Int(rawData.imageSize.height),
  18. bitsPerComponent: imageConfig.bitsPerComponent,
  19. bitsPerPixel: imageConfig.bitsPerPixel,
  20. bytesPerRow: 0,
  21. space: CGColorSpaceCreateDeviceRGB(),
  22. bitmapInfo: imageConfig.bitmapInfo,
  23. provider: rawDataProvider,
  24. decode: nil,
  25. shouldInterpolate: true,
  26. intent: .defaultIntent) else {
  27. return nil
  28. }
  29. CGImageDestinationAddImage(imageDestination, imageFrame, nil)
  30. guard CGImageDestinationFinalize(imageDestination) else {
  31. return nil
  32. }
  33. return writeData as Data
  34. }
  35. /// 获取图片的色彩配置
  36. ///
  37. /// - Parameter rawData: 原始图片数据
  38. /// - Returns: 色彩配置
  39. public static func getColorConfigWithImageData(_ rawData:Data) -> ColorConfig{
  40. guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
  41. let imageFrame = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
  42. return .unknown
  43. }
  44. return imageFrame.colorConfig
  45. }

Android

对于 Android 来说,其原生的 Bitmap 库有相当方便的转换色彩深度的方法,只需要传入 Config 就好。

  1. public Bitmap copy(Config config, boolean isMutable) {
  2. checkRecycled("Can't copy a recycled bitmap");
  3. if (config == Config.HARDWARE && isMutable) {
  4. throw new IllegalArgumentException("Hardware bitmaps are always immutable");
  5. }
  6. noteHardwareBitmapSlowCall();
  7. Bitmap b = nativeCopy(mNativePtr, config.nativeInt, isMutable);
  8. if (b != null) {
  9. b.setPremultiplied(mRequestPremultiplied);
  10. b.mDensity = mDensity;
  11. }
  12. return b;
  13. }

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 这个参数是无效。

  1. static func compressImageData(_ rawData:Data, compression:Double) -> Data?{
  2. guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
  3. let writeData = CFDataCreateMutable(nil, 0),
  4. let imageType = CGImageSourceGetType(imageSource),
  5. let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil) else {
  6. return nil
  7. }
  8. let frameProperties = [kCGImageDestinationLossyCompressionQuality: compression] as CFDictionary
  9. CGImageDestinationAddImageFromSource(imageDestination, imageSource, 0, frameProperties)
  10. guard CGImageDestinationFinalize(imageDestination) else {
  11. return nil
  12. }
  13. return writeData as Data
  14. }

Andoid

Andoird 用 Bitmap 自带的接口,并输出到流中。压缩系数是 0-100 之间的数值。这里的参数虽然可以填 Bitmap.CompressFormat.PNG,但当然也是无效的。

  1. val outputStream = ByteArrayOutputStream()
  2. val image = BitmapFactory.decodeByteArray(rawData,0,rawData.count())
  3. image.compress(Bitmap.CompressFormat.JPEG, compression, outputStream)
  4. resultData = outputStream.toByteArray()

GIF 的压缩

GIF 压缩上有很多种思路。参考开源项目 gifsicle 和 ImageMagick 中的做法,大概有以下几种。

  1. 由于 GIF 支持全局调色盘和局部调色盘,在没有局部调色盘的时候会用放在文件头中的全局调色盘。所以对于颜色变化不大的 GIF,可以将颜色放入全局调色盘中,去除局部调色盘。
  2. 对于颜色较少的 GIF,将调色盘大小减少,比如从 256 种减少到 128 种等。

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktNmJiYTE4Nzk3YTAzMTUxYS5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy8yMDAvZm9ybWF0L3dlYnA

    1490353055438_2367_1490353055781

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktNzQyZmZjNDFkZjlhNjRlZi5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy8yMDAvZm9ybWF0L3dlYnA

    1490353098026_7360_1490353098210

  3. 对于背景一致,画面中有一部分元素在变化的 GIF,可以将多个元素和背景分开存储,然后加上如何还原的信息

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktMmZjYmFiYjVmZDFkMTZhOC5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy8xMDAvZm9ybWF0L3dlYnA

    b522ac7896b320b4a9ee1eed1034e4fe_articlex

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktZTdiMDBjMzllNTkwY2ZjOC5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy81NjAvZm9ybWF0L3dlYnA

    9e9fe93459fe7117909eb27771bdc182_articlex

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktZGM4OTliMjk3YzE4ZDAyMi5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy81NjAvZm9ybWF0L3dlYnA

    433b41c29c6a70e64631a3d4c363e468_articlex

  4. 对于背景一致,画面中有一部分元素在动的 GIF,可以和前面一帧比较,将不动的部分透明化

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktOTdhM2VmOTZkNGUyMmUxMC5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy8xMjAvZm9ybWF0L3dlYnA

    d3c7444d59eed11d98abbb7c4e1da7ec_articlex

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktYjUwMGJjYzFhMzk5N2MwMy5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy81MjgvZm9ybWF0L3dlYnA

    e50b7f75feebb9bd056bb8dca9964873_articlex

    aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy85Njc4NjktYzcyOTAyYWQ0ODU2MTY2ZS5naWY_aW1hZ2VNb2dyMi9hdXRvLW9yaWVudC9zdHJpcHxpbWFnZVZpZXcyLzIvdy81MjgvZm9ybWF0L3dlYnA

    704d70c65d22fb240cb5f6f7be5bbf86_articlex

  5. 对于帧数很多的 GIF,可以抽取中间部分的帧,减少帧数
  6. 对于每帧分辨率很高的 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 中的使用方法。

  1. extension CGImageSource {
  2. func frameDurationAtIndex(_ index: Int) -> Double{
  3. var frameDuration = Double(0.1)
  4. guard let frameProperties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) as? [AnyHashable:Any], let gifProperties = frameProperties[kCGImagePropertyGIFDictionary] as? [AnyHashable:Any] else {
  5. return frameDuration
  6. }
  7. if let unclampedDuration = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? NSNumber {
  8. frameDuration = unclampedDuration.doubleValue
  9. } else {
  10. if let clampedDuration = gifProperties[kCGImagePropertyGIFDelayTime] as? NSNumber {
  11. frameDuration = clampedDuration.doubleValue
  12. }
  13. }
  14. if frameDuration < 0.011 {
  15. frameDuration = 0.1
  16. }
  17. return frameDuration
  18. }
  19. var frameDurations:[Double]{
  20. let frameCount = CGImageSourceGetCount(self)
  21. return (0..<frameCount).map{ self.frameDurationAtIndex($0) }
  22. }
  23. }

先去掉不要的帧,合并帧的时间,再重新生成帧就完成了。注意帧不要被拖得太长,不然体验不好,我这里给的最大值是 200ms。

  1. /// 同步压缩图片抽取帧数,仅支持 GIF
  2. ///
  3. /// - Parameters:
  4. /// - rawData: 原始图片数据
  5. /// - sampleCount: 采样频率,比如 3 则每三张用第一张,然后延长时间
  6. /// - Returns: 处理后数据
  7. static func compressImageData(_ rawData:Data, sampleCount:Int) -> Data?{
  8. guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
  9. let writeData = CFDataCreateMutable(nil, 0),
  10. let imageType = CGImageSourceGetType(imageSource) else {
  11. return nil
  12. }
  13. // 计算帧的间隔
  14. let frameDurations = imageSource.frameDurations
  15. // 合并帧的时间,最长不可高于 200ms
  16. 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) }
  17. // 抽取帧 每 n 帧使用 1 帧
  18. let sampleImageFrames = (0..<frameDurations.count).filter{ $0 % sampleCount == 0 }.compactMap{ CGImageSourceCreateImageAtIndex(imageSource, $0, nil) }
  19. guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, sampleImageFrames.count, nil) else{
  20. return nil
  21. }
  22. // 每一帧图片都进行重新编码
  23. zip(sampleImageFrames, mergeFrameDurations).forEach{
  24. // 设置帧间隔
  25. let frameProperties = [kCGImagePropertyGIFDictionary : [kCGImagePropertyGIFDelayTime: $1, kCGImagePropertyGIFUnclampedDelayTime: $1]]
  26. CGImageDestinationAddImage(imageDestination, $0, frameProperties as CFDictionary)
  27. }
  28. guard CGImageDestinationFinalize(imageDestination) else {
  29. return nil
  30. }
  31. return writeData as Data
  32. }

压缩分辨率也是类似的,每帧按分辨率压缩再重新编码就好。

Android

Android 原生对于 GIF 的支持就不怎么友好了,由于笔者 Android 研究不深,暂时先用 Glide 中的 GIF 编解码组件来完成。编码的性能比较一般,比不上 iOS,但除非换用更底层 C++ 库实现的编码库,Java 写的性能都很普通。先用 Gradle 导入 Glide,注意解码器是默认的,但编码器需要另外导入。

  1. api 'com.github.bumptech.glide:glide:4.8.0'
  2. api 'com.github.bumptech.glide:gifencoder-integration:4.8.0'

抽帧思路和 iOS 一样,只是 Glide 的这个 GIF 解码器没办法按指定的 index 取读取某一帧,只能一帧帧读取,调用 advance 方法往后读取。先从 GIF 读出头部信息,然后在读真正的帧信息。

  1. /**
  2. * 返回同步压缩 gif 图片 Byte 数据 [rawData] 的按 [sampleCount] 采样后的 Byte 数据
  3. */
  4. private fun compressGifDataWithSampleCount(context: Context, rawData: ByteArray, sampleCount: Int): ByteArray? {
  5. if (sampleCount <= 1) {
  6. return rawData
  7. }
  8. val gifDecoder = StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool))
  9. val headerParser = GifHeaderParser()
  10. headerParser.setData(rawData)
  11. val header = headerParser.parseHeader()
  12. gifDecoder.setData(header, rawData)
  13. val frameCount = gifDecoder.frameCount
  14. // 计算帧的间隔
  15. val frameDurations = (0 until frameCount).map { gifDecoder.getDelay(it) }
  16. // 合并帧的时间,最长不可高于 200ms
  17. val mergeFrameDurations = (0 until frameCount).filter { it % sampleCount == 0 }.map {
  18. min(
  19. frameDurations.subList(
  20. it,
  21. min(it + sampleCount, frameCount)
  22. ).fold(0) { acc, duration -> acc + duration }, 200
  23. )
  24. }
  25. // 抽取帧
  26. val sampleImageFrames = (0 until frameCount).mapNotNull {
  27. gifDecoder.advance()
  28. var imageFrame: Bitmap? = null
  29. if (it % sampleCount == 0) {
  30. imageFrame = gifDecoder.nextFrame
  31. }
  32. imageFrame
  33. }
  34. val gifEncoder = AnimatedGifEncoder()
  35. var resultData: ByteArray? = null
  36. try {
  37. val outputStream = ByteArrayOutputStream()
  38. gifEncoder.start(outputStream)
  39. gifEncoder.setRepeat(0)
  40. // 每一帧图片都进行重新编码
  41. sampleImageFrames.zip(mergeFrameDurations).forEach {
  42. // 设置帧间隔
  43. gifEncoder.setDelay(it.second)
  44. gifEncoder.addFrame(it.first)
  45. it.first.recycle()
  46. }
  47. gifEncoder.finish()
  48. resultData = outputStream.toByteArray()
  49. outputStream.close()
  50. } catch (e: IOException) {
  51. e.printStackTrace()
  52. }
  53. return resultData
  54. }

压缩分辨率的时候要注意,分辨率太大编码容易出现 Crash(应该是 OOM),这里设置为 512。

  1. /**
  2. * 返回同步压缩 gif 图片 Byte 数据 [rawData] 每一帧长边到 [limitLongWidth] 后的 Byte 数据
  3. */
  4. private fun compressGifDataWithLongWidth(context: Context, rawData: ByteArray, limitLongWidth: Int): ByteArray? {
  5. val gifDecoder = StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool))
  6. val headerParser = GifHeaderParser()
  7. headerParser.setData(rawData)
  8. val header = headerParser.parseHeader()
  9. gifDecoder.setData(header, rawData)
  10. val frameCount = gifDecoder.frameCount
  11. // 计算帧的间隔
  12. val frameDurations = (0..(frameCount - 1)).map { gifDecoder.getDelay(it) }
  13. // 计算调整后大小
  14. val longSideWidth = max(header.width, header.height)
  15. val ratio = limitLongWidth.toFloat() / longSideWidth.toFloat()
  16. val resizeWidth = (header.width.toFloat() * ratio).toInt()
  17. val resizeHeight = (header.height.toFloat() * ratio).toInt()
  18. // 每一帧进行缩放
  19. val resizeImageFrames = (0 until frameCount).mapNotNull {
  20. gifDecoder.advance()
  21. var imageFrame = gifDecoder.nextFrame
  22. if (imageFrame != null) {
  23. imageFrame = Bitmap.createScaledBitmap(imageFrame, resizeWidth, resizeHeight, true)
  24. }
  25. imageFrame
  26. }
  27. val gifEncoder = AnimatedGifEncoder()
  28. var resultData: ByteArray? = null
  29. try {
  30. val outputStream = ByteArrayOutputStream()
  31. gifEncoder.start(outputStream)
  32. gifEncoder.setRepeat(0)
  33. // 每一帧都进行重新编码
  34. resizeImageFrames.zip(frameDurations).forEach {
  35. // 设置帧间隔
  36. gifEncoder.setDelay(it.second)
  37. gifEncoder.addFrame(it.first)
  38. it.first.recycle()
  39. }
  40. gifEncoder.finish()
  41. resultData = outputStream.toByteArray()
  42. outputStream.close()
  43. return resultData
  44. } catch (e: IOException) {
  45. e.printStackTrace()
  46. }
  47. return resultData
  48. }

分辨率压缩

这个是最常用的,而且也比较简单。

iOS

iOS 的 ImageIO 提供了 CGImageSourceCreateThumbnailAtIndex 的 API 来创建缩放的缩略图。在 options 中添加需要缩放的长边参数即可。

  1. /// 同步压缩图片数据长边到指定数值
  2. ///
  3. /// - Parameters:
  4. /// - rawData: 原始图片数据
  5. /// - limitLongWidth: 长边限制
  6. /// - Returns: 处理后数据
  7. public static func compressImageData(_ rawData:Data, limitLongWidth:CGFloat) -> Data?{
  8. guard max(rawData.imageSize.height, rawData.imageSize.width) > limitLongWidth else {
  9. return rawData
  10. }
  11. guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
  12. let writeData = CFDataCreateMutable(nil, 0),
  13. let imageType = CGImageSourceGetType(imageSource) else {
  14. return nil
  15. }
  16. let frameCount = CGImageSourceGetCount(imageSource)
  17. guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, frameCount, nil) else{
  18. return nil
  19. }
  20. // 设置缩略图参数,kCGImageSourceThumbnailMaxPixelSize 为生成缩略图的大小。当设置为 800,如果图片本身大于 800*600,则生成后图片大小为 800*600,如果源图片为 700*500,则生成图片为 800*500
  21. let options = [kCGImageSourceThumbnailMaxPixelSize: limitLongWidth, kCGImageSourceCreateThumbnailWithTransform:true, kCGImageSourceCreateThumbnailFromImageIfAbsent:true] as CFDictionary
  22. if frameCount > 1 {
  23. // 计算帧的间隔
  24. let frameDurations = imageSource.frameDurations
  25. // 每一帧都进行缩放
  26. let resizedImageFrames = (0..<frameCount).compactMap{ CGImageSourceCreateThumbnailAtIndex(imageSource, $0, options) }
  27. // 每一帧都进行重新编码
  28. zip(resizedImageFrames, frameDurations).forEach {
  29. // 设置帧间隔
  30. let frameProperties = [kCGImagePropertyGIFDictionary : [kCGImagePropertyGIFDelayTime: $1, kCGImagePropertyGIFUnclampedDelayTime: $1]]
  31. CGImageDestinationAddImage(imageDestination, $0, frameProperties as CFDictionary)
  32. }
  33. } else {
  34. guard let resizedImageFrame = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options) else {
  35. return nil
  36. }
  37. CGImageDestinationAddImage(imageDestination, resizedImageFrame, nil)
  38. }
  39. guard CGImageDestinationFinalize(imageDestination) else {
  40. return nil
  41. }
  42. return writeData as Data
  43. }

Android

Android 静态图用 Bitmap 里面的 createScaleBitmap API 就好了,GIF 上文已经讲了。

  1. /**
  2. * 返回同步压缩图片 Byte 数据 [rawData] 的长边到 [limitLongWidth] 后的 Byte 数据,Gif 目标长边最大压缩到 512,超过用 512
  3. */
  4. fun compressImageDataWithLongWidth(context: Context, rawData: ByteArray, limitLongWidth: Int): ByteArray? {
  5. val format = rawData.imageFormat()
  6. if (format == ImageFormat.UNKNOWN) {
  7. return null
  8. }
  9. val (imageWidth, imageHeight) = rawData.imageSize()
  10. val longSideWidth = max(imageWidth, imageHeight)
  11. if (longSideWidth <= limitLongWidth) {
  12. return rawData
  13. }
  14. if (format == ImageFormat.GIF) {
  15. // 压缩 Gif 分辨率太大编码时容易崩溃
  16. return compressGifDataWithLongWidth(context, rawData, max(512, longSideWidth))
  17. } else {
  18. val image = BitmapFactory.decodeByteArray(rawData, 0, rawData.size)
  19. val ratio = limitLongWidth.toDouble() / longSideWidth.toDouble()
  20. val resizeImageFrame = Bitmap.createScaledBitmap(
  21. image,
  22. (image.width.toDouble() * ratio).toInt(),
  23. (image.height.toDouble() * ratio).toInt(),
  24. true
  25. )
  26. image.recycle()
  27. var resultData: ByteArray? = null
  28. when (format) {
  29. ImageFormat.PNG -> {
  30. resultData = resizeImageFrame.toByteArray(Bitmap.CompressFormat.PNG)
  31. }
  32. ImageFormat.JPG -> {
  33. resultData = resizeImageFrame.toByteArray(Bitmap.CompressFormat.JPEG)
  34. }
  35. else -> {
  36. }
  37. }
  38. resizeImageFrame.recycle()
  39. return resultData
  40. }
  41. }

限制大小的压缩方式

也就是将前面讲的方法综合起来,笔者这边给出一种方案,没有对色彩进行改变,JPG 先用二分法减少最多 6 次的压缩系数,GIF 先抽帧,抽帧间隔参考前文,最后采用逼近目标大小缩小分辨率。

iOS

  1. /// 同步压缩图片到指定文件大小
  2. ///
  3. /// - Parameters:
  4. /// - rawData: 原始图片数据
  5. /// - limitDataSize: 限制文件大小,单位字节
  6. /// - Returns: 处理后数据
  7. public static func compressImageData(_ rawData:Data, limitDataSize:Int) -> Data?{
  8. guard rawData.count > limitDataSize else {
  9. return rawData
  10. }
  11. var resultData = rawData
  12. // 若是 JPG,先用压缩系数压缩 6 次,二分法
  13. if resultData.imageFormat == .jpg {
  14. var compression: Double = 1
  15. var maxCompression: Double = 1
  16. var minCompression: Double = 0
  17. for _ in 0..<6 {
  18. compression = (maxCompression + minCompression) / 2
  19. if let data = compressImageData(resultData, compression: compression){
  20. resultData = data
  21. } else {
  22. return nil
  23. }
  24. if resultData.count < Int(CGFloat(limitDataSize) * 0.9) {
  25. minCompression = compression
  26. } else if resultData.count > limitDataSize {
  27. maxCompression = compression
  28. } else {
  29. break
  30. }
  31. }
  32. if resultData.count <= limitDataSize {
  33. return resultData
  34. }
  35. }
  36. // 若是 GIF,先用抽帧减少大小
  37. if resultData.imageFormat == .gif {
  38. let sampleCount = resultData.fitSampleCount
  39. if let data = compressImageData(resultData, sampleCount: sampleCount){
  40. resultData = data
  41. } else {
  42. return nil
  43. }
  44. if resultData.count <= limitDataSize {
  45. return resultData
  46. }
  47. }
  48. var longSideWidth = max(resultData.imageSize.height, resultData.imageSize.width)
  49. // 图片尺寸按比率缩小,比率按字节比例逼近
  50. while resultData.count > limitDataSize{
  51. let ratio = sqrt(CGFloat(limitDataSize) / CGFloat(resultData.count))
  52. longSideWidth *= ratio
  53. if let data = compressImageData(resultData, limitLongWidth: longSideWidth) {
  54. resultData = data
  55. } else {
  56. return nil
  57. }
  58. }
  59. return resultData
  60. }

Android

  1. /**
  2. * 返回同步压缩图片 Byte 数据 [rawData] 的数据大小到 [limitDataSize] 后的 Byte 数据
  3. */
  4. fun compressImageDataWithSize(context: Context, rawData: ByteArray, limitDataSize: Int): ByteArray? {
  5. if (rawData.size <= limitDataSize) {
  6. return rawData
  7. }
  8. val format = rawData.imageFormat()
  9. if (format == ImageFormat.UNKNOWN) {
  10. return null
  11. }
  12. var resultData = rawData
  13. // 若是 JPG,先用压缩系数压缩 6 次,二分法
  14. if (format == ImageFormat.JPG) {
  15. var compression = 100
  16. var maxCompression = 100
  17. var minCompression = 0
  18. try {
  19. val outputStream = ByteArrayOutputStream()
  20. for (index in 0..6) {
  21. compression = (maxCompression + minCompression) / 2
  22. outputStream.reset()
  23. val image = BitmapFactory.decodeByteArray(rawData, 0, rawData.size)
  24. image.compress(Bitmap.CompressFormat.JPEG, compression, outputStream)
  25. image.recycle()
  26. resultData = outputStream.toByteArray()
  27. if (resultData.size < (limitDataSize.toDouble() * 0.9).toInt()) {
  28. minCompression = compression
  29. } else if (resultData.size > limitDataSize) {
  30. maxCompression = compression
  31. } else {
  32. break
  33. }
  34. }
  35. outputStream.close()
  36. } catch (e: IOException) {
  37. e.printStackTrace()
  38. }
  39. if (resultData.size <= limitDataSize) {
  40. return resultData
  41. }
  42. }
  43. // 若是 GIF,先用抽帧减少大小
  44. if (format == ImageFormat.GIF) {
  45. val sampleCount = resultData.fitSampleCount()
  46. val data = compressGifDataWithSampleCount(context, resultData, sampleCount)
  47. if (data != null) {
  48. resultData = data
  49. } else {
  50. return null
  51. }
  52. if (resultData.size <= limitDataSize) {
  53. return resultData
  54. }
  55. }
  56. val (imageWidth, imageHeight) = resultData.imageSize()
  57. var longSideWidth = max(imageWidth, imageHeight)
  58. // 图片尺寸按比率缩小,比率按字节比例逼近
  59. while (resultData.size > limitDataSize) {
  60. val ratio = Math.sqrt(limitDataSize.toDouble() / resultData.size.toDouble())
  61. longSideWidth = (longSideWidth.toDouble() * ratio).toInt()
  62. val data = compressImageDataWithLongWidth(context, resultData, longSideWidth)
  63. if (data != null) {
  64. resultData = data
  65. } else {
  66. return null
  67. }
  68. }
  69. return resultData
  70. }

注意在异步线程中使用,毕竟是耗时操作。

最后

所有代码均封装成文件在 iOS 和 Android 中了,如有错误和建议,欢迎指出。

Reference

  • 无损压缩 vs 有损压缩 vs 损多少
  • 图片格式 jpg、png、gif各有什么优缺点?什么情况下用什么格式的图片呢?
  • 也谈图片压缩
  • 移动端图片格式调研
  • 浓缩的才是精华:浅析 GIF 格式图片的存储和压缩
  • 压缩gif的正确姿势
  • 谈谈 iOS 中图片的解压缩
  • iOS平台图片编解码入门教程(Image/IO篇)

发表评论

表情:
评论列表 (有 0 条评论,135人围观)

还没有评论,来说两句吧...

相关阅读

    相关 模型压缩

    有一个做深度学习模型部署的同学曾经提到过他目前的方向主要是模型压缩,就是对于部署在app上的模型在不影响性能的前提下如何减小模型的体量,我也会经常用Bert等transform

    相关 移动开发技术

    浅谈移动端开发技术 前言 之前上家公司主要是做移动端 H5 开发的,但相关技术和配套体系已经很成熟了,很难接触到背后的这套体系。 在现在的公司也做了一些零散的 H

    相关 pc和移动的响应式

    身为一个前端攻城狮,是不是经常遇到各种各样的响应式问题?下面我们来说一下: 1.响应式跟自适应有什么区别? [关于响应式布局,你必须要知道的][Link 1] 有些人可能