浅谈移动端图片压缩(iOS & Android) 您所在的位置:网站首页 图像压缩app 浅谈移动端图片压缩(iOS & Android)

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

2024-06-19 06:25| 来源: 网络整理| 查看: 265

在 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第三方库支持不支持不支持Android4.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 这边,也是可以直接调用底层一点的框架的。

屏幕快照 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。

11994763-6f25c32bd4d3b427

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:减少帧数 + 每帧分辨率缩放 + 减小调色盘

判断图片格式

后缀扩展名来判断其实并不保险,真实的判断方式应该是通过文件头里的信息进行判断。

JPGPNGGIF开头:FF D8 + 结尾:FF D989 50 4E 47 0D 0A 1A 0A47 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.. 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?, 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 的注释,里面也包含了常用的色彩深度用的参数。

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

这一块为了和 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 以及色彩深度对应关系如下表:

色彩深度iOSAndroid8 位灰度(只有透明度)bitsPerComponent: 8 bitsPerPixel: 8 bitmapInfo: CGImageAlphaInfo.alphaOnlyBitmap.Config.ALPHA_816 位色(R5+G6+R5)bitsPerComponent: 5 bitsPerPixel: 16 bitmapInfo: CGImageAlphaInfo.noneSkipFirstBitmap.Config.RGB_56532 位色(A8+R8+G8+B8)bitsPerComponent: 8 bitsPerPixel: 32 bitmapInfo: CGImageAlphaInfo.premultipliedFirstBitmap.Config.ARGB_888864 位色(R16+G16+B16+A16 但使用半精度减少一半储存空间)用于宽色域或HDRbitsPerComponent: 16 bitsPerPixel: 64 bitmapInfo: CGImageAlphaInfo.premultipliedLast + .floatComponentsBitmap.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 种等。

1490353055438_2367_1490353055781

1490353098026_7360_1490353098210

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

b522ac7896b320b4a9ee1eed1034e4fe_articlex

9e9fe93459fe7117909eb27771bdc182_articlex

433b41c29c6a70e64631a3d4c363e468_articlex

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

d3c7444d59eed11d98abbb7c4e1da7ec_articlex

e50b7f75feebb9bd056bb8dca9964873_articlex

704d70c65d22fb240cb5f6f7be5bbf86_articlex

对于帧数很多的 GIF,可以抽取中间部分的帧,减少帧数

对于每帧分辨率很高的 GIF,将每帧的分辨率减小

对于动画的 GIF,3、4 是很实用的,因为背景一般是不变的,但对于拍摄的视频转成的 GIF,就没那么实用了,因为存在轻微抖动,很难做到背景不变。但在移动端,除非将 ImageMagick 或者 gifsicle 移植到 iOS&Android 上,要实现前面 4 个方法是比较困难的。笔者这里只实现了抽帧,和每帧分辨率压缩。

至于抽帧的间隔,参考了文章中的数值。

帧数每 x 帧使用 1 帧40x = 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.. 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.. 1 { // 计算帧的间隔 let frameDurations = imageSource.frameDurations // 每一帧都进行缩放 let resizedImageFrames = (0.. { 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.. limitDataSize { maxCompression = compression } else { break } } if resultData.count


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有